Files
mihomo-patchbay/web/views/RuleForm.vue
T
im0o 48e97bd91f 规则编辑:建议列表全量展开,目标补齐候选,删除免确认
组合框展开先给全量、本次展开输入过才过滤——按当前值过滤的话,预填的
类型会把列表滤到只剩它自己,MATCH 这类排在后面的选项等于不存在。类型
因此可以默认留空,placeholder 当示例。

目标建议由页面传入:全局规则给内置两个加用户自建的组和自有线路;附加
规则多一项本路专属组——「这个载荷走本路代理」写的就是它,放在拦截规则
前面还能做豁免。生成的专属组不列,每路三个会把列表灌成一面墙。

原文/字段切换只拦真正整行编辑的类型(isRawOnlyRule):之前用
isStructuredEditable 拦,新增时的空原文永远过不了,卡死在原文模式。

删除规则和附加规则不再二次确认——删错了有撤回,确认框只是多一步;
toast 带上被删规则的原文。
2026-08-12 13:16:58 +08:00

365 lines
9.1 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">
/**
* 新增或编辑一条分流规则。
*
* 常见规则按「类型,载荷,目标」三段编辑,目标之后的选项段(no-resolve 这类)单独
* 一个输入框、原样拼回,改一条规则不会丢掉它的选项。逻辑规则、子规则这类结构
* 不是三段的按整行原文编辑——为每种规则类型建模不值得,内核认识的类型比这里
* 列出来的多得多。
*/
import { computed, ref, watch } from 'vue'
import { buildRule, isRawOnlyRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
import ComboBox from '../components/ComboBox.vue'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{
open: boolean
/** 传了就是编辑:下标加原文;没传就是新增 */
editing: { index: number; raw: string } | null
/** RULE-SET 载荷的候选:已声明的规则组 */
providerNames: string[]
/** 目标的建议。只是建议,手填任何出站名都合法 */
targetOptions: string[]
saving: boolean
}>()
const emit = defineEmits<{ close: []; submit: [rule: string] }>()
/**
* 内核认识的全部规则类型,照抄解析器的 switch(rules/parser.go:17-98),
* 按域名、地理、IP、端口、进程、入站、逻辑的次序排。
*/
const RULE_TYPES = [
'DOMAIN',
'DOMAIN-SUFFIX',
'DOMAIN-KEYWORD',
'DOMAIN-REGEX',
'DOMAIN-WILDCARD',
'GEOSITE',
'GEOIP',
'SRC-GEOIP',
'IP-CIDR',
'IP-CIDR6',
'SRC-IP-CIDR',
'IP-SUFFIX',
'SRC-IP-SUFFIX',
'IP-ASN',
'SRC-IP-ASN',
'DST-PORT',
'SRC-PORT',
'IN-PORT',
'PROCESS-NAME',
'PROCESS-PATH',
'PROCESS-NAME-REGEX',
'PROCESS-PATH-REGEX',
'PROCESS-NAME-WILDCARD',
'PROCESS-PATH-WILDCARD',
'NETWORK',
'UID',
'DSCP',
'IN-TYPE',
'IN-USER',
'IN-NAME',
'REMATCH-NAME',
'AND',
'OR',
'NOT',
'SUB-RULE',
'RULE-SET',
'MATCH',
]
const dialog = ref<HTMLDialogElement | null>(null)
const rawMode = ref(false)
const raw = ref('')
const type = ref('')
const payload = ref('')
const target = ref('')
const extras = ref('')
const isMatch = computed(() => type.value.trim().toUpperCase() === 'MATCH')
const isRuleSet = computed(() => type.value.trim().toUpperCase() === 'RULE-SET')
/** 结构化表单当前的内容拼成一行。 */
function structured(): string {
return buildRule({
type: type.value.trim(),
payload: isMatch.value ? null : payload.value.trim() || null,
target: target.value.trim(),
extras: extras.value
.split(',')
.map((part) => part.trim())
.filter(Boolean),
})
}
const canSubmit = computed(() => {
if (props.saving) return false
if (rawMode.value) return raw.value.trim().includes(',')
if (type.value.trim() === '' || target.value.trim() === '') return false
return isMatch.value || payload.value.trim() !== ''
})
watch(
() => props.open,
(open) => {
if (open) {
const source = props.editing?.raw ?? ''
raw.value = source
if (source !== '' && !isStructuredEditable(source)) {
rawMode.value = true
} else {
rawMode.value = false
const parsed = parseRule(source)
// 新增时留空:预填的类型会把下拉的建议过滤到只剩它自己
type.value = parsed.type
payload.value = parsed.payload ?? ''
target.value = parsed.target ?? ''
extras.value = parsed.extras.join(',')
}
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
/** 切换编辑方式时带上当前内容:切成原文就把表单拼成一行,切回表单就重新拆。 */
function toggleMode() {
if (rawMode.value) {
const line = raw.value.trim()
// 只有逻辑规则这类整行编辑的类型留在原文模式;空行、没写完的行都带回表单
// 接着填——按 isStructuredEditable 拦的话,新增时的空原文会让人永远切不回去
if (isRawOnlyRule(line)) return
const parsed = parseRule(line)
type.value = parsed.type
payload.value = parsed.payload ?? ''
target.value = parsed.target ?? ''
extras.value = parsed.extras.join(',')
rawMode.value = false
} else {
raw.value = structured()
rawMode.value = true
}
}
function submit() {
if (!canSubmit.value) return
emit('submit', rawMode.value ? raw.value.trim() : structured())
}
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? '编辑规则' : '新增规则' }}</h2>
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
</header>
<div v-if="rawMode" class="body">
<label class="row">
<span class="k">规则</span>
<span class="v">
<input
v-model="raw"
class="mono"
type="text"
placeholder="AND,((DOMAIN-SUFFIX,baidu.com),(NETWORK,UDP)),DIRECT"
autocomplete="off"
spellcheck="false"
/>
</span>
</label>
</div>
<div v-else class="body">
<label class="row">
<span class="k">类型</span>
<span class="v">
<ComboBox v-model="type" mono :options="RULE_TYPES" placeholder="DOMAIN-SUFFIX" />
</span>
</label>
<label v-if="!isMatch" class="row">
<span class="k">载荷</span>
<span class="v">
<ComboBox
v-if="isRuleSet"
v-model="payload"
mono
:options="providerNames"
placeholder="规则组名"
/>
<input
v-else
v-model="payload"
class="mono"
type="text"
placeholder="youtube.com"
autocomplete="off"
spellcheck="false"
/>
</span>
</label>
<label class="row">
<span class="k">目标</span>
<span class="v">
<ComboBox v-model="target" mono :options="targetOptions" placeholder="DIRECT" />
</span>
</label>
<label v-if="!isMatch" class="row">
<span class="k">选项</span>
<span class="v">
<input
v-model="extras"
class="mono"
type="text"
placeholder="no-resolve"
autocomplete="off"
spellcheck="false"
/>
</span>
</label>
</div>
<footer class="dlg-foot">
<button type="button" class="btn mode" @click="toggleMode">
{{ rawMode ? '按字段编辑' : '按原文编辑' }}
</button>
<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 {
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);
}
.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);
}
/* 编辑方式的切换放左边,和右边的确认/取消拉开 */
.mode {
margin-right: auto;
}
.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>