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

View File

@@ -0,0 +1,353 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import {
NButton,
NCard,
NForm,
NFormItem,
NInput,
NInputNumber,
NModal,
NSelect,
NSpace,
NSwitch,
NTag,
useDialog,
useMessage,
} from 'naive-ui';
import { nodeEgress, type GroupDto, type GroupInput } from '@proxy-station/shared';
import { useGroupsStore } from '../stores/groups';
import { useNodesStore } from '../stores/nodes';
import EgressBadge from '../components/EgressBadge.vue';
import { nodeDisplayName } from '../utils/nodeName';
const store = useGroupsStore();
const nodesStore = useNodesStore();
const message = useMessage();
const dialog = useDialog();
onMounted(() => {
store.fetchAll();
nodesStore.fetchAll();
});
const showForm = ref(false);
const editing = ref<GroupDto | null>(null);
const form = ref<GroupInput>(blankForm());
const memberSelection = ref<string[]>([]);
function blankForm(): GroupInput {
return {
name: '',
type: 'select',
testUrl: null,
interval: null,
tolerance: null,
filterRegex: null,
includeAllNodes: false,
sortOrder: 0,
members: [],
};
}
/** 成员选项builtin + 其他组 + 节点,用前缀区分 */
const memberOptions = computed(() => [
{ type: 'group' as const, label: '— 内置策略 —', key: 'h1', children: ['DIRECT', 'REJECT'].map((b) => ({ label: b, value: `builtin:${b}` })) },
{
type: 'group' as const,
label: '— 策略组 —',
key: 'h2',
children: store.groups.filter((g) => g.id !== editing.value?.id).map((g) => ({ label: g.name, value: `group:${g.name}` })),
},
{
type: 'group' as const,
label: '— 节点 —',
key: 'h3',
children: nodesStore.nodes.map((n) => ({ label: n.name, value: `node:${n.id}` })),
},
]);
function openCreate() {
editing.value = null;
form.value = blankForm();
memberSelection.value = [];
showForm.value = true;
}
function openEdit(group: GroupDto) {
editing.value = group;
form.value = {
name: group.name,
type: group.type,
testUrl: group.testUrl,
interval: group.interval,
tolerance: group.tolerance,
filterRegex: group.filterRegex,
includeAllNodes: group.includeAllNodes,
sortOrder: group.sortOrder,
members: [],
};
memberSelection.value = group.members.map((m) =>
m.kind === 'node' ? `node:${m.nodeId}` : m.kind === 'builtin' ? `builtin:${m.refName}` : `group:${m.refName}`
);
showForm.value = true;
}
async function submit() {
form.value.members = memberSelection.value.map((v) => {
const [kind, ...rest] = v.split(':');
const ref = rest.join(':');
return kind === 'node' ? { kind: 'node' as const, nodeId: ref } : { kind: kind as 'group' | 'builtin', refName: ref };
});
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(group: GroupDto) {
dialog.warning({
title: '删除策略组',
content: `确认删除「${group.name}」?引用它的规则策略不会自动更新。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => store.remove(group.id),
});
}
/** 排序:客户端订阅里的策略组顺序即此顺序 */
async function move(group: GroupDto, dir: -1 | 1) {
const ids = store.groups.map((g) => g.id);
const i = ids.indexOf(group.id);
const j = i + dir;
if (j < 0 || j >= ids.length) return;
[ids[i], ids[j]] = [ids[j], ids[i]];
await store.reorder(ids);
}
/** regex 组实时预览:当前节点里有哪些会被圈中 */
function matchedNodes(group: GroupDto) {
if (!group.filterRegex) return [];
try {
const re = new RegExp(group.filterRegex);
return nodesStore.nodes.filter((n) => re.test(n.name));
} catch {
return [];
}
}
function memberLabel(m: GroupDto['members'][number]): string {
if (m.kind === 'node') return nodesStore.nodes.find((n) => n.id === m.nodeId)?.name ?? '(已删除节点)';
return m.refName ?? '';
}
</script>
<template>
<div>
<div class="page-head">
<h1 class="display page-title">策略组</h1>
<NButton type="primary" @click="openCreate">添加策略组</NButton>
</div>
<p class="hint">顺序即客户端里策略组的排列顺序停用的组不会下发引用它的规则一并跳过</p>
<div class="group-grid">
<NCard v-for="(g, i) in store.groups" :key="g.id" size="small" class="group-card" :class="{ off: !g.enabled }">
<template #header>
<span class="group-order mono">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="group-name">{{ g.name }}</span>
<NTag size="small" :bordered="false" style="margin-left: 8px">{{ g.type }}</NTag>
</template>
<template #header-extra>
<NSpace size="small" align="center">
<NButton size="tiny" quaternary :disabled="i === 0" @click="move(g, -1)"></NButton>
<NButton size="tiny" quaternary :disabled="i === store.groups.length - 1" @click="move(g, 1)"></NButton>
<NSwitch size="small" :value="g.enabled" @update:value="(v: boolean) => store.toggle(g.id, v)" />
<NButton size="tiny" quaternary @click="openEdit(g)">编辑</NButton>
<NButton v-if="g.source === 'custom'" size="tiny" quaternary type="error" @click="confirmDelete(g)">删除</NButton>
</NSpace>
</template>
<div v-if="g.includeAllNodes" class="group-line">
<span class="group-label">收纳</span>全部启用节点
</div>
<div v-if="g.filterRegex" class="group-line">
<span class="group-label">筛选</span>
<span class="mono regex">{{ g.filterRegex.length > 48 ? g.filterRegex.slice(0, 48) + '…' : g.filterRegex }}</span>
</div>
<div v-if="g.filterRegex" class="group-line group-line-block">
<div v-if="matchedNodes(g).length" class="matched-wrap">
<div class="matched-count"><span class="group-label">命中</span>{{ matchedNodes(g).length }} 个节点</div>
<div class="matched-list">
<div v-for="n in matchedNodes(g)" :key="n.id" class="matched-row" :title="n.name">
<EgressBadge :egress="nodeEgress(n)" compact />
<span class="matched-name">{{ nodeDisplayName(n) }}</span>
</div>
</div>
</div>
<span v-else class="muted"><span class="group-label">命中</span>当前没有节点名匹配</span>
</div>
<div v-if="g.members.length" class="group-line">
<span class="group-label">成员</span>
<span class="members">{{ g.members.map(memberLabel).join('、') }}</span>
</div>
</NCard>
</div>
<NModal
:show="showForm"
preset="card"
:title="editing ? `编辑策略组 · ${editing.name}` : '添加策略组'"
style="max-width: 560px"
@update:show="showForm = $event"
>
<NForm label-placement="left" label-width="96">
<NFormItem label="名称" required>
<NInput v-model:value="form.name" />
</NFormItem>
<NFormItem label="类型">
<NSelect
v-model:value="form.type"
:options="['select', 'url-test', 'fallback'].map((t) => ({ label: t, value: t }))"
/>
</NFormItem>
<NFormItem label="成员(有序)">
<NSelect v-model:value="memberSelection" multiple filterable :options="memberOptions" placeholder="选择节点、组或内置策略" />
</NFormItem>
<NFormItem label="收纳全部节点">
<NSwitch v-model:value="form.includeAllNodes" />
</NFormItem>
<NFormItem label="名称筛选正则">
<NInput v-model:value="form.filterRegex" placeholder="如 SG|Singapore配合收纳全部节点或成员组" class="mono" />
</NFormItem>
<template v-if="form.type !== 'select'">
<NFormItem label="测速 URL">
<NInput v-model:value="form.testUrl" placeholder="http://cp.cloudflare.com/generate_204" class="mono" />
</NFormItem>
<NFormItem label="间隔(秒)">
<NInputNumber v-model:value="form.interval" :min="10" style="width: 100%" />
</NFormItem>
<NFormItem label="容差ms">
<NInputNumber v-model:value="form.tolerance" :min="0" style="width: 100%" />
</NFormItem>
</template>
</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: 20px;
}
.page-title {
font-size: 20px;
margin: 0;
}
.hint {
color: var(--muted);
font-size: 12px;
margin: -12px 0 16px;
}
/* 同行卡片等宽等高 */
.group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.group-card.off {
opacity: 0.55;
}
.group-order {
font-size: 11px;
color: var(--muted);
margin-right: 10px;
}
.group-name {
font-weight: 600;
}
.group-line {
font-size: 12px;
margin-bottom: 6px;
line-height: 1.7;
display: flex;
align-items: baseline;
}
.group-label {
color: var(--muted);
margin-right: 10px;
flex-shrink: 0;
}
.regex {
color: var(--warp);
font-size: 11px;
word-break: break-all;
}
/* 命中清单:一行一个,节点名与出口标签左右对齐 */
.matched-wrap {
flex: 1;
min-width: 0;
}
/* 命中区整块换行,让清单占满卡片宽度 */
.group-line-block {
display: block;
}
.matched-count {
color: var(--muted);
margin-bottom: 3px;
}
/* 全部铺开不滚动;两列等宽,无左缩进 */
.matched-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 12px;
}
.matched-row {
display: flex;
align-items: center;
gap: 7px;
padding: 2.5px 0;
min-width: 0;
}
.matched-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.members {
color: var(--text);
}
.muted {
color: var(--muted);
}
</style>

300
web/src/pages/NodesPage.vue Normal file
View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue';
import {
NButton,
NDataTable,
NEmpty,
NSpace,
NSwitch,
useDialog,
useMessage,
type DataTableColumns,
} from 'naive-ui';
import { nodeEgress, type NodeDto, type NodeInput, type Protocol } from '@proxy-station/shared';
import { useNodesStore } from '../stores/nodes';
import { stationLabel, stationOf } from '../components/stations';
import EgressBadge from '../components/EgressBadge.vue';
import ProtoDot from '../components/ProtoDot.vue';
import NodeFormModal from '../components/NodeFormModal.vue';
import ImportModal from '../components/ImportModal.vue';
import { copyText } from '../utils/clipboard';
import { nodeDisplayName } from '../utils/nodeName';
const store = useNodesStore();
const message = useMessage();
const dialog = useDialog();
const showForm = ref(false);
const showImport = ref(false);
const editing = ref<NodeDto | null>(null);
onMounted(() => store.fetchAll());
const PROTOCOL_ORDER: Protocol[] = ['vmess', 'trojan', 'shadowsocks', 'hysteria2'];
const EGRESS_ORDER = { direct: 0, warp: 1, unknown: 2 };
/** 站点展示域名去掉子域首段cc.sg.example.com → sg.example.com */
function baseDomain(server: string): string {
const parts = server.split('.');
return parts.length >= 4 ? parts.slice(1).join('.') : server;
}
/** 按站点分段,段内先按协议、再按出口排序 */
const sections = computed(() => {
const byStation = new Map<string, NodeDto[]>();
for (const n of store.nodes) {
const key = stationOf(n);
if (!byStation.has(key)) byStation.set(key, []);
byStation.get(key)!.push(n);
}
return [...byStation.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, list]) => ({
code,
label: stationLabel(code),
domain: baseDomain(list[0].server),
nodes: [...list].sort(
(a, b) =>
PROTOCOL_ORDER.indexOf(a.protocol) - PROTOCOL_ORDER.indexOf(b.protocol) ||
EGRESS_ORDER[nodeEgress(a)] - EGRESS_ORDER[nodeEgress(b)]
),
}));
});
function openCreate() {
editing.value = null;
showForm.value = true;
}
function openEdit(node: NodeDto) {
editing.value = node;
showForm.value = true;
}
async function handleSubmit(input: NodeInput, id?: string) {
try {
if (id) await store.update(id, input);
else await store.create(input);
showForm.value = false;
message.success(id ? '已保存' : '已添加');
} catch (e: any) {
message.error(e.message);
}
}
function handleDelete(id: string) {
const node = store.nodes.find((n) => n.id === id);
dialog.warning({
title: '删除节点',
content: `确认删除「${node?.name}」?引用它的策略组成员会一并移除。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await store.remove(id);
showForm.value = false;
message.success('已删除');
},
});
}
async function copyUri(node: NodeDto) {
try {
const uri = await store.exportUri(node.id);
if (await copyText(uri)) message.success('分享链接已复制');
else message.warning('浏览器阻止了自动复制,请改用 HTTPS 或 localhost 访问');
} catch (e: any) {
message.error(e.message);
}
}
/** 用分享链接唤起本机已安装的客户端,把这条线路导进去 */
async function connect(node: NodeDto) {
try {
const uri = await store.exportUri(node.id);
window.location.href = uri;
} catch (e: any) {
message.error(e.message);
}
}
const columns = computed<DataTableColumns<NodeDto>>(() => [
{
title: '名称',
key: 'name',
ellipsis: { tooltip: true },
// 出口词由「出口」列承担,这里隐去避免重复
render: (row) => nodeDisplayName(row),
},
{
title: '协议',
key: 'protocol',
width: 148,
render: (row) =>
h('span', { class: 'proto-cell' }, [
h(ProtoDot, { protocol: row.protocol }),
h('span', {}, row.protocol),
]),
},
{
title: '出口',
key: 'egress',
width: 92,
render: (row) => h(EgressBadge, { egress: nodeEgress(row) }),
},
{
title: '服务器',
key: 'server',
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'mono cell-mono' }, `${row.server}:${row.port}`),
},
{
title: '路径',
key: 'wsPath',
width: 108,
render: (row) => h('span', { class: 'mono cell-mono' }, row.wsPath ?? '—'),
},
{
title: '启用',
key: 'enabled',
width: 72,
render: (row) =>
h(NSwitch, {
size: 'small',
value: row.enabled,
onUpdateValue: (v: boolean) => store.toggle(row.id, v),
}),
},
{
title: '',
key: 'actions',
width: 176,
render: (row) =>
h(NSpace, { size: 2, wrap: false }, () => [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => copyUri(row) }, () => '复制'),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => connect(row) }, () => '连接'),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
]),
},
]);
</script>
<template>
<div>
<div class="page-head">
<h1 class="display page-title">节点</h1>
<NSpace>
<NButton ghost @click="showImport = true">批量导入</NButton>
<NButton type="primary" @click="openCreate">添加节点</NButton>
</NSpace>
</div>
<NEmpty
v-if="!store.loading && store.nodes.length === 0"
description="还没有节点。粘贴分享链接批量导入,或手动添加。"
style="margin-top: 80px"
>
<template #extra>
<NButton type="primary" @click="showImport = true">批量导入</NButton>
</template>
</NEmpty>
<section v-for="st in sections" :key="st.code" class="station">
<header class="station-head">
<span class="station-name display">{{ st.label }}</span>
<span class="station-domain mono">{{ st.domain }}</span>
<span class="station-count">{{ st.nodes.length }} 条线路</span>
</header>
<div class="strip"><i class="strip-direct" /><i class="strip-warp" /></div>
<NDataTable
:columns="columns"
:data="st.nodes"
:bordered="false"
:single-line="false"
size="small"
:row-key="(n: NodeDto) => n.id"
/>
</section>
<NodeFormModal v-model:show="showForm" :node="editing" @submit="handleSubmit" @delete="handleDelete" />
<ImportModal v-model:show="showImport" />
</div>
</template>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 20px;
margin: 0;
}
.station + .station {
margin-top: 26px;
}
.station-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 0 2px 9px;
}
.station-name {
font-size: 17px;
white-space: nowrap;
}
.station-domain {
font-size: 12px;
color: var(--muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.station-count {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
/* 站牌双色条:琥珀 = 直连,青 = WARP */
.strip {
display: flex;
height: 3px;
border-radius: 2px;
overflow: hidden;
margin-bottom: 8px;
}
.strip i {
flex: 1;
}
.strip-direct {
background: var(--direct);
}
.strip-warp {
background: var(--warp);
}
:deep(.proto-cell) {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
:deep(.cell-mono) {
font-size: 12px;
}
</style>

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-SETDOMAIN-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>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { NButton, NCard, NInput, NSpace, NTabPane, NTabs, useMessage } from 'naive-ui';
import { getAdminToken, setAdminToken } from '../api/client';
import { useSubscriptionsStore } from '../stores/subscriptions';
const store = useSubscriptionsStore();
const message = useMessage();
const sections = ref<Record<string, string>>({});
const adminToken = ref(getAdminToken());
const activeSection = ref('General');
onMounted(async () => {
await store.fetchAll();
});
watch(
() => store.settings,
(s) => {
if (!s) return;
sections.value = { ...s.surgeSections };
},
{ immediate: true }
);
async function saveSections() {
await store.saveSettings({ surgeSections: sections.value });
message.success('已保存');
}
function saveAdminToken() {
setAdminToken(adminToken.value.trim());
message.success(adminToken.value.trim() ? '已保存到本浏览器' : '已清除');
}
</script>
<template>
<div>
<h1 class="display page-title">设置</h1>
<NCard title="管理鉴权" size="small" class="block">
<p class="hint">
服务端设置了 ADMIN_TOKEN 环境变量时管理接口需要携带同样的 token此处保存的值仅存在本浏览器 localStorage
</p>
<NSpace>
<NInput v-model:value="adminToken" type="password" show-password-on="click" placeholder="ADMIN_TOKEN" class="mono" style="width: 320px" />
<NButton @click="saveAdminToken">保存</NButton>
</NSpace>
</NCard>
<NCard title="Surge 原文段落" size="small" class="block">
<p class="hint">
这些段落按原文透传进 Surge 配置[General][MITM] 此处直接编辑文本ShadowRocketClashMeta 不使用这些内容
</p>
<NTabs v-model:value="activeSection" type="line" size="small">
<NTabPane v-for="(content, name) in sections" :key="name" :name="String(name)" :tab="String(name)">
<NInput
v-model:value="sections[String(name)]"
type="textarea"
:rows="16"
class="mono section-editor"
/>
</NTabPane>
</NTabs>
<NButton type="primary" style="margin-top: 12px" @click="saveSections">保存段落</NButton>
</NCard>
</div>
</template>
<style scoped>
.page-title {
font-size: 20px;
margin: 0 0 20px;
}
.block {
margin-bottom: 16px;
max-width: 860px;
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0 0 12px;
}
.section-editor :deep(textarea) {
font-family: var(--font-mono) !important;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,404 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue';
import { NButton, NPopover, NSpace, NSwitch, useDialog, useMessage } from 'naive-ui';
import { SHADOWROCKET_SUB_NAME, type TokenDto } from '@proxy-station/shared';
import { useSubscriptionsStore } from '../stores/subscriptions';
import { useNodesStore } from '../stores/nodes';
import { copyText } from '../utils/clipboard';
import TokenFormModal from '../components/TokenFormModal.vue';
const store = useSubscriptionsStore();
const nodesStore = useNodesStore();
const message = useMessage();
const dialog = useDialog();
onMounted(() => {
store.fetchAll();
nodesStore.fetchAll();
});
const showForm = ref(false);
const editing = ref<TokenDto | null>(null);
function openCreate() {
editing.value = null;
showForm.value = true;
}
function openEdit(token: TokenDto) {
editing.value = token;
showForm.value = true;
}
async function handleSubmit(payload: { name: string; nodeIds: string[] }, id?: string) {
try {
if (id) await store.updateToken(id, payload);
else await store.createToken(payload);
showForm.value = false;
message.success(id ? '已保存' : '订阅已创建');
} catch (e: any) {
message.error(e.message);
}
}
function confirmDelete(token: TokenDto) {
dialog.warning({
title: '删除订阅',
content: `删除「${token.name}」后,使用该 URL 的客户端将无法再更新配置。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => store.removeToken(token.id),
});
}
/** 订阅完整 URL当前页面 origin + 后端返回的路径 */
function fullUrl(path: string): string {
return `${window.location.origin}${path}`;
}
/** 节点范围文案:空数组表示不限制 */
function scopeLabel(t: TokenDto): string {
const total = nodesStore.nodes.filter((n) => n.enabled).length;
return t.nodeIds.length === 0 ? `全部节点 · ${total}` : `${t.nodeIds.length} / ${total} 节点`;
}
async function copy(text: string) {
if (await copyText(text)) {
message.success('已复制');
} else {
manualCopyText.value = text;
message.warning('浏览器阻止了自动复制,请手动复制下方链接');
}
}
/** 自动复制失败时的兜底展示 */
const manualCopyText = ref('');
const manualCopyInput = ref<HTMLInputElement | null>(null);
watch(manualCopyText, async (v) => {
if (!v) return;
await nextTick();
manualCopyInput.value?.focus();
manualCopyInput.value?.select();
});
</script>
<template>
<div>
<div class="page-head">
<h1 class="display page-title">订阅</h1>
<NButton type="primary" @click="openCreate">新建订阅</NButton>
</div>
<div class="token-grid">
<article v-for="t in store.tokens" :key="t.id" class="token" :class="{ off: !t.enabled }">
<header class="token-head">
<div class="token-title">
<span class="token-name">{{ t.name }}</span>
<span class="token-scope">{{ scopeLabel(t) }}</span>
</div>
<NSpace size="small" align="center">
<NSwitch size="small" :value="t.enabled" @update:value="(v: boolean) => store.toggleToken(t.id, v)" />
<NButton size="tiny" quaternary @click="openEdit(t)">编辑</NButton>
<NButton size="tiny" quaternary type="error" @click="confirmDelete(t)">删除</NButton>
</NSpace>
</header>
<div class="rows">
<div class="row">
<span class="row-client">Surge<span class="row-os">macOS</span></span>
<code class="mono row-url">{{ fullUrl(t.urls.surge) }}</code>
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.surge))">复制</NButton>
</div>
<div class="row">
<span class="row-client">
ShadowRocket<span class="row-os">iOS</span>
<NPopover trigger="hover" placement="bottom-start" style="max-width: 320px">
<template #trigger>
<button class="info" aria-label="导入说明">?</button>
</template>
<p class="pop-title">分两步导入</p>
<p class="pop-body">
App 订阅里添加节点链接并把订阅命名为
<code class="mono pop-code">{{ SHADOWROCKET_SUB_NAME }}</code>
配置文件按这个名字引用节点名字必须完全一致
</p>
<p class="pop-body"> 配置里添加规则链接并启用</p>
</NPopover>
</span>
<div class="row-split">
<div class="split-line">
<span class="split-tag">节点</span>
<code class="mono row-url">{{ fullUrl(t.urls.shadowrocket) }}</code>
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.shadowrocket))">复制</NButton>
</div>
<div class="split-line">
<span class="split-tag">规则</span>
<code class="mono row-url">{{ fullUrl(t.urls.shadowrocketConf) }}</code>
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.shadowrocketConf))">复制</NButton>
</div>
</div>
</div>
<div class="row">
<span class="row-client">ClashMeta<span class="row-os">Android</span></span>
<code class="mono row-url">{{ fullUrl(t.urls.clash) }}</code>
<NButton size="tiny" quaternary @click="copy(fullUrl(t.urls.clash))">复制</NButton>
</div>
</div>
<footer class="token-foot">
最近访问 {{ t.lastAccessAt ? new Date(t.lastAccessAt).toLocaleString() : '从未' }} · {{ t.accessCount }}
</footer>
</article>
</div>
<!-- 自动复制失败时的兜底全选好的输入框长按或 Cmd+C 即可 -->
<div v-if="manualCopyText" class="manual-copy">
<span class="manual-copy-label">手动复制这条链接</span>
<input
ref="manualCopyInput"
class="mono manual-copy-input"
:value="manualCopyText"
readonly
@focus="($event.target as HTMLInputElement).select()"
/>
<NButton size="tiny" quaternary @click="manualCopyText = ''">关闭</NButton>
</div>
<TokenFormModal v-model:show="showForm" :token="editing" :nodes="nodesStore.nodes" @submit="handleSubmit" />
</div>
</template>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
margin: 0;
}
.token-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(560px, 1fr));
gap: 14px;
}
.token {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
.token.off {
opacity: 0.55;
}
.token-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 18px;
border-bottom: 1px solid var(--line-soft);
}
.token-title {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.token-name {
font-size: 15px;
font-weight: 700;
}
/* 节点范围徽标 */
.token-scope {
font-size: 11px;
color: var(--warp);
background: var(--warp-dim);
padding: 2px 8px;
border-radius: 999px;
white-space: nowrap;
}
.rows {
padding: 6px 18px 10px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.row + .row {
border-top: 1px solid var(--line-soft);
}
.row-client {
width: 128px;
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.row-os {
font-size: 10px;
font-weight: 500;
color: var(--muted);
}
.info {
appearance: none;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
width: 16px;
height: 16px;
flex: 0 0 16px; /* 不参与 flex 收缩,否则会被压成椭圆 */
box-sizing: border-box;
border-radius: 50%;
font-size: 10px;
line-height: 1;
cursor: help;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.info:hover {
color: var(--text);
border-color: var(--muted);
}
.row-url {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text);
background: var(--bg);
padding: 5px 9px;
border-radius: 6px;
}
/* ShadowRocket 的两条链接 */
.row-split {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.split-line {
display: flex;
align-items: center;
gap: 8px;
}
.split-tag {
font-size: 11px;
color: var(--muted);
width: 26px;
flex-shrink: 0;
}
.token-foot {
padding: 9px 18px;
border-top: 1px solid var(--line-soft);
font-size: 11px;
color: var(--muted);
background: var(--bg);
}
.pop-title {
margin: 0 0 6px;
font-size: 12.5px;
font-weight: 600;
}
.pop-body {
margin: 0 0 6px;
font-size: 12px;
line-height: 1.7;
color: var(--muted);
}
.pop-code {
color: var(--text);
background: var(--bg);
padding: 1px 5px;
border-radius: 4px;
font-size: 11px;
}
.manual-copy {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
padding: 10px 14px;
background: var(--direct-dim);
border: 1px solid var(--direct);
border-radius: 10px;
}
.manual-copy-label {
font-size: 12.5px;
color: var(--direct);
white-space: nowrap;
}
.manual-copy-input {
flex: 1;
min-width: 0;
font-size: 12px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--text);
}
@media (max-width: 720px) {
.token-grid {
grid-template-columns: 1fr;
}
.row {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.row-client {
width: auto;
}
}
</style>