Files
proxy-station/web/src/pages/RulesPage.vue
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

267 lines
7.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">
import { computed, h, onMounted, ref } from 'vue';
import {
NButton,
NDataTable,
NModal,
NForm,
NFormItem,
NInput,
NSelect,
NSpace,
NSwitch,
NTag,
useDialog,
useMessage,
type DataTableColumns,
} from 'naive-ui';
import { RULE_TYPES, type RuleDto, type RuleInput } from '@proxy-station/shared';
import { useRulesStore } from '../stores/rules';
import { useGroupsStore } from '../stores/groups';
import RulesetPreviewDrawer from '../components/RulesetPreviewDrawer.vue';
const store = useRulesStore();
const groupsStore = useGroupsStore();
const message = useMessage();
const dialog = useDialog();
onMounted(() => {
store.fetchAll();
groupsStore.fetchAll();
});
const showForm = ref(false);
const editing = ref<RuleDto | null>(null);
const previewRule = ref<RuleDto | null>(null);
const showPreview = ref(false);
function openPreview(rule: RuleDto) {
previewRule.value = rule;
showPreview.value = true;
}
const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT', params: [], enabled: true });
const policyOptions = computed(() => [
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
...groupsStore.groups.filter((g) => g.enabled).map((g) => ({ label: g.name, value: g.name })),
]);
const typeOptions = RULE_TYPES.filter((t) => t !== 'RULE-SET' && t !== 'DOMAIN-SET' && t !== 'FINAL').map((t) => ({
label: t,
value: t,
}));
function openCreate() {
editing.value = null;
form.value = { type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT', params: [], enabled: true };
showForm.value = true;
}
function openEdit(rule: RuleDto) {
editing.value = rule;
form.value = {
type: rule.type as RuleInput['type'],
value: rule.value,
policy: rule.policy,
params: rule.params,
enabled: rule.enabled,
};
showForm.value = true;
}
async function submit() {
try {
if (editing.value) await store.update(editing.value.id, form.value);
else await store.create(form.value);
showForm.value = false;
message.success('已保存');
} catch (e: any) {
message.error(e.message);
}
}
function confirmDelete(rule: RuleDto) {
dialog.warning({
title: '删除规则',
content: `确认删除这条 ${rule.type} 规则?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => store.remove(rule.id),
});
}
function confirmReset() {
dialog.warning({
title: '重置为模板规则',
content: '模板来源的规则将恢复为初始状态,你添加的自定义规则会保留。继续?',
positiveText: '重置',
negativeText: '取消',
onPositiveClick: async () => {
await store.reset();
message.success('已重置');
},
});
}
async function move(rule: RuleDto, dir: -1 | 1) {
const ids = store.rules.map((r) => r.id);
const i = ids.indexOf(rule.id);
const j = i + dir;
if (j < 0 || j >= ids.length) return;
[ids[i], ids[j]] = [ids[j], ids[i]];
await store.reorder(ids);
}
async function changePolicy(rule: RuleDto, policy: string) {
await store.update(rule.id, {
type: rule.type as RuleInput['type'],
value: rule.value,
policy,
params: rule.params,
enabled: rule.enabled,
});
}
const columns = computed<DataTableColumns<RuleDto>>(() => [
{ title: '#', key: 'seq', width: 56, render: (_row, i) => h('span', { class: 'mono', style: 'color:var(--muted);font-size:12px' }, String(i + 1)) },
{
title: '类型',
key: 'type',
width: 130,
render: (row) =>
h(
NTag,
{ size: 'small', bordered: false, type: row.type === 'RULE-SET' || row.type === 'DOMAIN-SET' ? 'info' : 'default' },
() => row.type
),
},
{
title: '匹配值',
key: 'value',
ellipsis: { tooltip: true },
render: (row) =>
row.rulesetName
? h('span', { style: 'cursor:pointer', onClick: () => openPreview(row), title: '点击预览规则集内容' }, [
h('span', { style: 'text-decoration:underline dotted var(--muted)' }, row.rulesetName),
h('span', { class: 'mono', style: 'color:var(--muted);font-size:11px;margin-left:8px' }, row.rulesetUrl ?? ''),
])
: h('span', { class: 'mono', style: 'font-size:12px' }, row.value ?? '—'),
},
{
title: '策略',
key: 'policy',
width: 170,
render: (row) =>
h(NSelect, {
size: 'small',
value: row.policy,
options: policyOptions.value,
consistentMenuWidth: false,
onUpdateValue: (v: string) => changePolicy(row, v),
}),
},
{
title: '来源',
key: 'source',
width: 76,
render: (row) => h('span', { style: 'color:var(--muted);font-size:12px' }, row.source === 'template' ? '模板' : '自定义'),
},
{
title: '启用',
key: 'enabled',
width: 70,
render: (row) => h(NSwitch, { size: 'small', value: row.enabled, disabled: row.type === 'FINAL', onUpdateValue: (v: boolean) => store.toggle(row.id, v) }),
},
{
title: '',
key: 'actions',
width: 170,
render: (row, i) =>
h(NSpace, { size: 2 }, () => [
h(NButton, { size: 'tiny', quaternary: true, disabled: i === 0 || row.type === 'FINAL', onClick: () => move(row, -1) }, () => '↑'),
h(NButton, { size: 'tiny', quaternary: true, disabled: i >= store.rules.length - 2 || row.type === 'FINAL', onClick: () => move(row, 1) }, () => '↓'),
...(row.type !== 'RULE-SET' && row.type !== 'DOMAIN-SET' && row.type !== 'FINAL'
? [h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑')]
: []),
...(row.source === 'custom' && row.type !== 'FINAL'
? [h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除')]
: []),
]),
},
]);
</script>
<template>
<div>
<div class="page-head">
<h1 class="display page-title">规则</h1>
<NSpace>
<NButton ghost @click="confirmReset">重置为模板</NButton>
<NButton type="primary" @click="openCreate">添加规则</NButton>
</NSpace>
</div>
<p class="hint">规则自上而下匹配。RULE-SET/DOMAIN-SET 引用外部规则集,点击名称可预览内容;末尾 FINAL 是兜底策略。</p>
<NDataTable
:columns="columns"
:data="store.rules"
:loading="store.loading"
:bordered="false"
size="small"
:row-key="(r: RuleDto) => r.id"
:max-height="640"
virtual-scroll
/>
<RulesetPreviewDrawer v-model:show="showPreview" :rule="previewRule" />
<NModal
:show="showForm"
preset="card"
:title="editing ? '编辑规则' : '添加自定义规则'"
style="max-width: 480px"
@update:show="showForm = $event"
>
<NForm label-placement="left" label-width="72">
<NFormItem label="类型">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem label="匹配值">
<NInput v-model:value="form.value" placeholder="如 example.com / 1.2.3.0/24" class="mono" />
</NFormItem>
<NFormItem label="策略">
<NSelect v-model:value="form.policy" :options="policyOptions" filterable />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="showForm = false">取消</NButton>
<NButton type="primary" @click="submit">保存</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 20px;
margin: 0;
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0 0 16px;
}
</style>