Files
mihomo-patchbay/web/views/RuleForm.vue
T
im0o 7d08f37248 规则表单:类型候选补全为内核全集,目标候选精简
类型照抄内核解析器的 switch(rules/parser.go:17-98),37 种全给——
候选列表是过滤式的,全集不构成负担,缺项才是。目标反着来:只建议
DIRECT 和 REJECT,策略组名手填——全列出来的话,每路入站自动生成的
三个专属组会把列表灌成一面墙。

按原文编辑的输入框给一个完整的逻辑规则做占位例子(原文模式存在的
理由就是这类规则)。弹层滚动条改细杆透明轨道。
2026-08-12 00:13:02 +08:00

355 lines
8.7 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, 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[]
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 TARGET_SUGGESTIONS = ['DIRECT', 'REJECT']
const dialog = ref<HTMLDialogElement | null>(null)
const rawMode = ref(false)
const raw = ref('')
const type = ref('DOMAIN-SUFFIX')
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 || 'DOMAIN-SUFFIX'
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()
if (!isStructuredEditable(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())
}
</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>{{ 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" />
</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="TARGET_SUGGESTIONS" 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>