文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作 目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状), 成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。 file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示, 编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。 订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取 (component/resource/fetcher.go:226-228),关掉开关就是不写这个键。 配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。 规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条 代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网 直连」,只在有缺口时出现。 所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
366 lines
9.0 KiB
Vue
366 lines
9.0 KiB
Vue
<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())
|
||
}
|
||
|
||
/** 点在遮罩上就关闭。内容区的点击 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" />
|
||
</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>
|