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

800 lines
22 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)的管理。
*
* 和路由页同一条界线:增删改规则、增删规则组是配置动作,都走 commit(),有校验和
* 重载的等待;「立刻更新」规则组是运行时动作,不写配置文件,直接调内核接口。
*
* 顺序是分流语义的一部分。这张表的任何操作都只动被动到的那一行,其余行的相对
* 顺序不变——排序只有上移下移,没有任何隐式重排。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { collectOutboundNames } from '@shared/inbounds.ts'
import {
findRuleIssues,
firstMatchIndex,
insertBeforeMatch,
missingLanRules,
moveRule,
parseRule,
ruleProvidersOf,
ruleSetReferences,
rulesOf,
setRuleProviders,
setRules,
} from '@shared/rules.ts'
import type { IniImportPlan, RuleIssue } from '@shared/rules.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
import type { RuleProviderInfo } from '../api'
import { kernel } from '../runtime'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import RuleForm from './RuleForm.vue'
import RuleGroupForm from './RuleGroupForm.vue'
import IniImportForm from './IniImportForm.vue'
import IconAdd from '~icons/ri/add-line'
import IconUp from '~icons/ri/arrow-up-line'
import IconDown from '~icons/ri/arrow-down-line'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconRefresh from '~icons/ri/refresh-line'
import IconLoad from '~icons/ri/loader-4-line'
const props = defineProps<{ data: ConfigObject; revision: string }>()
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
const emit = defineEmits<{ saved: [] }>()
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
const rules = computed(() => rulesOf(props.data))
/** 每行拆好的样子,模板直接读,不在渲染里反复拆。 */
const rows = computed(() =>
rules.value.map((rule, index) => ({ index, raw: rule, ...parseRule(rule) })),
)
const providers = computed(() =>
Object.entries(ruleProvidersOf(props.data)).map(([name, decl]) => ({
name,
behavior: typeof decl.behavior === 'string' ? decl.behavior : '',
source:
typeof decl.url === 'string'
? decl.url
: typeof decl.path === 'string'
? decl.path
: '',
})),
)
const providerNames = computed(() => providers.value.map((p) => p.name))
const outbounds = computed(() => collectOutboundNames(props.data))
const outboundNames = computed(() => [
...outbounds.value.builtins,
...outbounds.value.groups,
...outbounds.value.ownProxies,
])
/** 第一条 MATCH 的下标。它之后的行标成不可达。 */
const matchAt = computed(() => firstMatchIndex(rules.value))
/** 断引用按行归好,模板里直接查。 */
const issuesByIndex = computed(() => {
const issueMap = new Map<number, RuleIssue[]>()
for (const issue of findRuleIssues(
rules.value,
new Set(outboundNames.value),
new Set(providerNames.value),
)) {
issueMap.set(issue.index, [...(issueMap.get(issue.index) ?? []), issue])
}
return issueMap
})
const missingLan = computed(() => missingLanRules(rules.value))
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
/** 所有改配置文件的动作都从这里走。 */
async function commit(raw: ConfigObject, success: string) {
saving.value = true
failure.value = null
done.value = ''
try {
const result = await api.applyConfig(raw, props.revision)
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
emit('saved')
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
} finally {
saving.value = false
}
}
function withRules(next: string[]): ConfigObject {
const data = clone()
setRules(data, next)
return data
}
// ── 分流规则 ────────────────────────────────────
const ruleFormOpen = ref(false)
/** 编辑时是下标,新增时是 null */
const ruleEditing = ref<number | null>(null)
function openAddRule() {
ruleEditing.value = null
ruleFormOpen.value = true
}
function openEditRule(index: number) {
ruleEditing.value = index
ruleFormOpen.value = true
}
async function submitRule(ruleText: string) {
const editing = ruleEditing.value
const next =
editing === null
? insertBeforeMatch(rules.value, [ruleText])
: rules.value.map((rule, i) => (i === editing ? ruleText : rule))
await commit(withRules(next), editing === null ? '已新增规则' : '已保存')
// 失败时不能关对话框——关了再打开就是空表单
if (!failure.value) ruleFormOpen.value = false
}
async function move(index: number, delta: -1 | 1) {
if (saving.value) return
await commit(withRules(moveRule(rules.value, index, delta)), '顺序已保存')
}
/** 待确认删除的规则下标。非 null 时确认对话框打开着。 */
const removingRule = ref<number | null>(null)
async function removeRuleConfirmed() {
const index = removingRule.value
removingRule.value = null
if (index === null || saving.value) return
await commit(
withRules(rules.value.filter((_, i) => i !== index)),
'已删除这条规则',
)
}
async function applyLanPreset() {
if (saving.value || missingLan.value.length === 0) return
await commit(
withRules(insertBeforeMatch(rules.value, missingLan.value)),
`已加入 ${missingLan.value.length} 条内网直连规则`,
)
}
// ── 规则组 ─────────────────────────────────────
/** 每个规则组的运行时状态:条数、上次更新。内核不在线时是空的。 */
const providerState = ref<Record<string, RuleProviderInfo>>({})
async function reloadProviderState() {
if (!kernel.value.online) {
providerState.value = {}
return
}
providerState.value = await api
.ruleProviders()
.then((r) => r.providers)
.catch(() => ({}))
}
onMounted(() => void reloadProviderState())
// 配置重读(保存成功、别的页面改完回来)之后跟着刷一遍,条数可能刚变过
watch(
() => props.revision,
() => void reloadProviderState(),
)
/** 还没拉取过时内核给的是零值时间,显示成横线而不是 0001 年。 */
function formatTime(value: string | undefined): string {
if (!value) return '—'
const time = new Date(value)
if (Number.isNaN(time.getTime()) || time.getFullYear() <= 1) return '—'
return time.toLocaleString()
}
const groupFormOpen = ref(false)
const importOpen = ref(false)
async function submitGroup(entry: { name: string; declaration: ConfigObject }) {
const next = clone()
setRuleProviders(next, { ...ruleProvidersOf(next), [entry.name]: entry.declaration })
await commit(next, `已新增规则组 ${entry.name}`)
if (!failure.value) {
groupFormOpen.value = false
void reloadProviderState()
}
}
async function submitImport(plan: IniImportPlan) {
const next = clone()
setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers })
setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...plan.finals])
const added = plan.rules.length + plan.finals.length
await commit(
next,
`已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则`,
)
if (!failure.value) {
importOpen.value = false
void reloadProviderState()
}
}
/** 待确认删除的规则组。 */
const removingProvider = ref<string | null>(null)
/** 删除被拦下的规则组:rules 里还有 RULE-SET 引用它,先把引用列出来。 */
const blockedProvider = ref<{ name: string; refs: string[] } | null>(null)
const blockedDialog = ref<HTMLDialogElement | null>(null)
watch(blockedProvider, (blocked) => {
if (blocked) blockedDialog.value?.showModal()
else blockedDialog.value?.close()
})
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
function onBlockedBackdrop(event: MouseEvent) {
if (event.target === blockedDialog.value) blockedProvider.value = null
}
function askRemoveProvider(name: string) {
const refs = ruleSetReferences(rules.value, name)
if (refs.length > 0) {
blockedProvider.value = { name, refs: refs.map((i) => rules.value[i]!) }
} else {
removingProvider.value = name
}
}
async function removeProviderConfirmed() {
const name = removingProvider.value
removingProvider.value = null
if (name === null || saving.value) return
const next = clone()
const rest = ruleProvidersOf(next)
delete rest[name]
setRuleProviders(next, rest)
await commit(next, `已删除规则组 ${name}`)
if (!failure.value) void reloadProviderState()
}
/** 正在等内核重新拉取的规则组名字。 */
const updating = ref<string | null>(null)
/** 立刻更新是运行时动作:不写配置文件,不走 commit(),也就没有保存的等待感。 */
async function updateNow(name: string) {
if (updating.value !== null || saving.value) return
updating.value = name
failure.value = null
done.value = ''
try {
await api.updateRuleProvider(name)
await reloadProviderState()
} catch (error) {
failure.value = {
message: error instanceof ApiError ? error.message : String(error),
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
}
} finally {
updating.value = null
}
}
</script>
<template>
<div class="page">
<header class="head">
<div class="sums">
<div class="sum">
<span class="sum-n">{{ rules.length }}</span>
<span class="sum-k">分流规则</span>
</div>
<div class="sum">
<span class="sum-n">{{ providers.length }}</span>
<span class="sum-k">规则组</span>
</div>
</div>
</header>
<div v-if="failure" class="failure">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<section class="sec">
<header class="sec-head">
<h2>分流规则</h2>
<div class="sec-acts">
<button
type="button"
class="btn"
:disabled="saving || missingLan.length === 0"
@click="applyLanPreset"
>
内网直连
</button>
<button type="button" class="btn" :disabled="saving" @click="openAddRule">
<IconAdd />
<span>新增</span>
</button>
</div>
</header>
<ol v-if="rules.length > 0" class="rows">
<li v-for="row in rows" :key="row.index">
<div :class="['line', matchAt >= 0 && row.index > matchAt && 'is-dead']">
<span class="idx mono">{{ row.index + 1 }}</span>
<span class="rtype mono">{{ row.type }}</span>
<span class="payload mono">
<template v-if="row.payload !== null">
<span :class="{ bad: issuesByIndex.get(row.index)?.some((i) => i.kind === 'rule-set') }">
{{ row.payload }}
</span>
</template>
<span v-if="row.extras.length > 0" class="extras">
{{ row.extras.join(', ') }}
</span>
</span>
<span class="rtarget mono">
<span :class="{ bad: issuesByIndex.get(row.index)?.some((i) => i.kind === 'target') }">
{{ row.target }}
</span>
<span v-if="issuesByIndex.has(row.index)" class="tag tag-bad">断引用</span>
<span v-if="matchAt >= 0 && row.index > matchAt" class="tag">不可达</span>
</span>
<span class="acts">
<button
type="button"
class="icon-btn"
title="上移"
:disabled="saving || row.index === 0"
@click="move(row.index, -1)"
>
<IconUp />
</button>
<button
type="button"
class="icon-btn"
title="下移"
:disabled="saving || row.index === rules.length - 1"
@click="move(row.index, 1)"
>
<IconDown />
</button>
<button
type="button"
class="icon-btn"
title="编辑"
:disabled="saving"
@click="openEditRule(row.index)"
>
<IconEdit />
</button>
<button
type="button"
class="icon-btn danger"
title="删除"
:disabled="saving"
@click="removingRule = row.index"
>
<IconDelete />
</button>
</span>
</div>
</li>
</ol>
<p v-else class="empty">没有分流规则,走规则的流量都会直连。</p>
</section>
<section class="sec">
<header class="sec-head">
<h2>规则组</h2>
<div class="sec-acts">
<button type="button" class="btn" :disabled="saving" @click="importOpen = true">
导入 ini
</button>
<button type="button" class="btn" :disabled="saving" @click="groupFormOpen = true">
<IconAdd />
<span>新增</span>
</button>
</div>
</header>
<ol v-if="providers.length > 0" class="rows">
<li v-for="provider in providers" :key="provider.name">
<div class="line gline">
<span class="gname">{{ provider.name }}</span>
<span class="gbehavior mono">{{ provider.behavior || '—' }}</span>
<span class="gsource mono" :title="provider.source">{{ provider.source }}</span>
<span class="gcount mono">{{ providerState[provider.name]?.ruleCount ?? '—' }}</span>
<span class="gtime mono">{{ formatTime(providerState[provider.name]?.updatedAt) }}</span>
<span class="acts">
<button
type="button"
class="icon-btn"
:title="kernel.online ? '立刻更新' : '内核未连接'"
:disabled="!kernel.online || updating !== null || saving"
@click="updateNow(provider.name)"
>
<IconLoad v-if="updating === provider.name" class="spin" />
<IconRefresh v-else />
</button>
<button
type="button"
class="icon-btn danger"
title="删除"
:disabled="saving"
@click="askRemoveProvider(provider.name)"
>
<IconDelete />
</button>
</span>
</div>
</li>
</ol>
<p v-else class="empty">没有规则组。</p>
</section>
<RuleForm
:open="ruleFormOpen"
:editing="ruleEditing === null ? null : { index: ruleEditing, raw: rules[ruleEditing]! }"
:outbound-names="outboundNames"
:provider-names="providerNames"
:saving="saving"
@close="ruleFormOpen = false"
@submit="submitRule"
/>
<RuleGroupForm
:open="groupFormOpen"
:taken-names="providerNames"
:saving="saving"
@close="groupFormOpen = false"
@submit="submitGroup"
/>
<IniImportForm
:open="importOpen"
:outbound-names="outboundNames"
:existing-provider-names="providerNames"
:saving="saving"
@close="importOpen = false"
@submit="submitImport"
/>
<ConfirmDialog
:open="removingRule !== null"
:message="removingRule === null ? '' : `删除这条规则?${rules[removingRule]}`"
action="删除"
@close="removingRule = null"
@confirm="removeRuleConfirmed"
/>
<ConfirmDialog
:open="removingProvider !== null"
:message="removingProvider === null ? '' : `删除规则组「${removingProvider}」?`"
action="删除"
@close="removingProvider = null"
@confirm="removeProviderConfirmed"
/>
<!-- 删除被 RULE-SET 引用拦下时不给确认键:先改掉这些规则,才轮得到删 -->
<dialog
ref="blockedDialog"
class="dlg"
@click="onBlockedBackdrop"
@close="blockedProvider = null"
@cancel.prevent="blockedProvider = null"
>
<div v-if="blockedProvider" class="dlg-body">
<p class="msg">
规则组「{{ blockedProvider.name }}」正被 {{ blockedProvider.refs.length }} 条规则引用:
</p>
<ul class="refs">
<li v-for="rule in blockedProvider.refs" :key="rule" class="mono">{{ rule }}</li>
</ul>
<footer class="dlg-foot">
<button type="button" class="btn" @click="blockedProvider = null">知道了</button>
</footer>
</div>
</dialog>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
margin-bottom: var(--s-5);
}
.sums {
display: flex;
gap: var(--s-6);
}
.sum {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.sum-n {
font-size: var(--t-2xl);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.sum-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.failure {
display: flex;
flex-direction: column;
gap: var(--s-1);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);
background: var(--c-danger-soft);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-4);
font-size: var(--t-sm);
}
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
.sec + .sec {
margin-top: var(--s-7);
}
.sec-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
margin-bottom: var(--s-3);
}
.sec-head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.sec-acts {
display: flex;
gap: var(--s-2);
}
.btn {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
}
.btn:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.btn:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
overflow: hidden;
}
.rows li + li {
border-top: 1px solid var(--c-border);
}
.line {
display: grid;
grid-template-columns: 2rem 8.5rem minmax(0, 1fr) minmax(6rem, auto) auto;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
}
.idx {
font-size: var(--t-xs);
color: var(--c-text-faint);
font-variant-numeric: tabular-nums;
}
.rtype {
font-size: var(--t-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.payload {
display: flex;
align-items: baseline;
gap: var(--s-3);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extras {
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.rtarget {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--s-2);
font-size: var(--t-sm);
}
/* 不可达:在第一条 MATCH 之后,永远轮不到它 */
.is-dead .rtype,
.is-dead .payload,
.is-dead .rtarget {
color: var(--c-text-faint);
}
.bad {
color: var(--c-danger);
}
.tag {
flex: none;
padding: 0 var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: 999px;
font-size: var(--t-xs);
color: var(--c-text-soft);
line-height: 1.6;
}
.tag-bad {
border-color: color-mix(in srgb, var(--c-danger) 45%, var(--c-border));
color: var(--c-danger);
}
.acts {
display: flex;
gap: var(--s-1);
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.icon-btn.danger:hover:not(:disabled) {
color: var(--c-danger);
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.gline {
grid-template-columns: minmax(8rem, 1fr) 5.5rem minmax(0, 1.6fr) 4rem 10.5rem auto;
}
.gname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gbehavior {
font-size: var(--t-sm);
color: var(--c-text-soft);
}
.gsource {
font-size: var(--t-sm);
color: var(--c-text-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gcount {
font-size: var(--t-sm);
text-align: right;
font-variant-numeric: tabular-nums;
}
.gtime {
font-size: var(--t-xs);
color: var(--c-text-soft);
font-variant-numeric: tabular-nums;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty {
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.dlg {
width: min(480px, 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-body {
padding: var(--s-5);
}
.refs {
margin: var(--s-3) 0 0;
padding-left: 1.2em;
font-size: var(--t-sm);
color: var(--c-text-soft);
}
.dlg-foot {
display: flex;
justify-content: flex-end;
margin-top: var(--s-5);
}
</style>