init: init proj

This commit is contained in:
2026-08-25 11:35:05 +08:00
commit bf7f2df67e
80 changed files with 9936 additions and 0 deletions

266
web/src/pages/RulesPage.vue Normal file
View File

@@ -0,0 +1,266 @@
<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: 'Proxy', params: [], enabled: true });
const policyOptions = computed(() => [
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
...groupsStore.groups.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: 'Proxy', 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, 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, onClick: () => move(row, -1) }, () => '↑'),
h(NButton, { size: 'tiny', quaternary: true, disabled: i === store.rules.length - 1, 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'
? [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>