Files
mihomo-patchbay/web/views/RuleGroupForm.vue
T
im0o 9e87517a4e 规则页:全局分流规则的编辑与规则组管理
新增 RulesPage.vue 和规则、规则组、ini 导入三个对话框,纯逻辑放在
shared/rules.ts。拆分规则字符串复用 vendor 的 splitRuleFields——引号和
嵌套括号里的逗号不会被当成字段分隔;目标之后的选项段原样拼回,改一条
规则不会丢掉它的 no-resolve 尾巴。规则组的缓存路径显式生成:不写 path
时内核按 url 哈希自己算一个,那个路径这里算不出来。
2026-08-11 18:53:21 +08:00

288 lines
7.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 新增一个规则组(rule-providers 里的一条声明)。
*
* 内容的拉取、缓存、定时更新全是内核的活,这里只写声明:URL 生成 http 类型,
* 本地路径生成 file 类型,缓存路径自动补一个确定的——不写 path 时内核按 url
* 哈希自己生成,那个路径这里算不出来。
*/
import { computed, ref, watch } from 'vue'
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{
open: boolean
/** 已被占用的规则组名 */
takenNames: string[]
saving: boolean
}>()
const emit = defineEmits<{
close: []
submit: [entry: { name: string; declaration: ConfigObject }]
}>()
const dialog = ref<HTMLDialogElement | null>(null)
const name = ref('')
const source = ref('')
const behavior = ref('classical')
const format = ref<'yaml' | 'text' | 'mrs'>('yaml')
/** 用户手选过格式就不再跟着来源推断 */
const formatTouched = ref(false)
const isUrl = computed(() => /^https?:\/\//i.test(source.value.trim()))
watch(source, (value) => {
if (!formatTouched.value) format.value = inferRuleFormat(value.trim())
})
const nameTaken = computed(() => props.takenNames.includes(name.value.trim()))
/** 内核没有实现 classical 行为的 mrs 格式(rules/provider/ 下只有 domain 和 ipcidr 两种策略) */
const mrsConflict = computed(() => format.value === 'mrs' && behavior.value === 'classical')
const canSubmit = computed(
() =>
name.value.trim() !== '' &&
source.value.trim() !== '' &&
!nameTaken.value &&
!mrsConflict.value &&
!props.saving,
)
watch(
() => props.open,
(open) => {
if (open) {
name.value = ''
source.value = ''
behavior.value = 'classical'
format.value = 'yaml'
formatTouched.value = false
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
function submit() {
if (!canSubmit.value) return
const trimmedName = name.value.trim()
const trimmedSource = source.value.trim()
const declaration: ConfigObject = isUrl.value
? {
type: 'http',
behavior: behavior.value,
url: trimmedSource,
path: ruleProviderPath(trimmedName, format.value),
format: format.value,
interval: 86400,
}
: { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value }
emit('submit', { name: trimmedName, declaration })
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>新增规则组</h2>
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
</header>
<div class="body">
<label class="row">
<span class="k">名称</span>
<span class="v">
<input v-model="name" type="text" placeholder="ads" autocomplete="off" />
<span v-if="nameTaken" class="bad">名称已存在</span>
</span>
</label>
<label class="row">
<span class="k">来源</span>
<span class="v">
<input
v-model="source"
class="mono"
type="text"
placeholder="https://… 或本地文件路径"
autocomplete="off"
spellcheck="false"
/>
<span v-if="source.trim() !== ''" class="note">
{{ isUrl ? 'http 类型,由内核定时拉取' : 'file 类型,读本机文件' }}
</span>
</span>
</label>
<label class="row">
<span class="k">行为</span>
<span class="v">
<select v-model="behavior">
<option value="classical">classical</option>
<option value="domain">domain</option>
<option value="ipcidr">ipcidr</option>
</select>
<span class="note">
清单每行带 DOMAIN,、IP-CIDR, 这类前缀的是 classical;纯域名列表是 domain;
纯 IP 网段列表是 ipcidr
</span>
</span>
</label>
<label class="row">
<span class="k">格式</span>
<span class="v">
<select v-model="format" @change="formatTouched = true">
<option value="yaml">yaml</option>
<option value="text">text</option>
<option value="mrs">mrs</option>
</select>
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
</span>
</label>
</div>
<footer class="dlg-foot">
<button type="button" class="btn" @click="emit('close')">取消</button>
<button type="submit" class="btn btn-go" :disabled="!canSubmit">
{{ saving ? '保存中' : '保存' }}
</button>
</footer>
</form>
</dialog>
</template>
<style scoped>
.dlg {
width: min(520px, calc(100vw - 2 * var(--s-5)));
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-4) var(--s-5);
border-bottom: 1px solid var(--c-border);
}
.dlg-head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.x {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.x:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.body {
display: flex;
flex-direction: column;
gap: var(--s-4);
padding: var(--s-5);
}
.row {
display: grid;
grid-template-columns: 4rem 1fr;
gap: var(--s-4);
align-items: baseline;
}
.k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.v {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
input,
select {
width: 100%;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-bg);
}
select {
appearance: none;
padding-right: var(--s-6);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16'%3E%3Cpath fill='%23898f98' d='M12 15.6 5.7 9.3l1.4-1.4 4.9 4.9 4.9-4.9 1.4 1.4z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--s-3) center;
}
.note {
font-size: var(--t-xs);
color: var(--c-text-faint);
line-height: 1.5;
}
.bad {
font-size: var(--t-xs);
color: var(--c-danger);
}
.dlg-foot {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
padding: var(--s-4) var(--s-5);
border-top: 1px solid var(--c-border);
}
.btn {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
}
.btn-go {
background: var(--c-accent);
border-color: transparent;
color: #fff;
font-weight: 500;
}
.btn-go:disabled {
background: var(--c-surface-sunken);
border-color: var(--c-border);
color: var(--c-text-faint);
cursor: not-allowed;
}
</style>