feat: 添加 WireGuard 设备管理并完善客户端分流

实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。

完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
This commit is contained in:
YANG JIANKUAN
2026-11-02 22:44:23 +08:00
parent f741aeeccd
commit a5d980a5e9
80 changed files with 4777 additions and 51 deletions

View File

@@ -13,10 +13,12 @@
"@proxy-station/shared": "workspace:*",
"naive-ui": "^2.40.4",
"pinia": "^2.3.0",
"qrcode": "^1.5.4",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.0.6",

View File

@@ -25,6 +25,7 @@ const navItems = [
{ path: '/nodes', label: '节点' },
{ path: '/groups', label: '策略组' },
{ path: '/rules', label: '规则' },
{ path: '/devices', label: '设备' },
{ path: '/subscriptions', label: '订阅' },
{ path: '/settings', label: '设置' },
];
@@ -132,6 +133,7 @@ const active = computed(() => route.path);
display: flex;
gap: 26px;
flex: 1;
min-width: 0;
}
/* 选中 = 粗下划线(站台线条),不加底不加边 */
@@ -145,6 +147,8 @@ const active = computed(() => route.path);
border-bottom: 3px solid transparent;
border-top: 3px solid transparent;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: color 0.1s;
}

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NButton, NInput, NSelect, NModal, NSpace, useDialog, useMessage } from 'naive-ui';
import QRCode from 'qrcode';
import type { WgDeviceDto, WgGatewayDto } from '@proxy-station/shared';
import { api } from '../api/client';
import { bytes, deviceStatus, expireObservation, timeLabel } from '../utils/wireguard';
import { copyText } from '../utils/clipboard';
const route = useRoute(); const router = useRouter(); const message = useMessage(); const dialog = useDialog();
const devices = ref<WgDeviceDto[]>([]); const gateways = ref<WgGatewayDto[]>([]);
const query = ref(''); const filter = ref('all'); const busy = ref(false); const loading = ref(false);
const selected = ref<(WgDeviceDto & { events: { message: string; createdAt: number }[] }) | null>(null);
const showCreate = ref(false); const editing = ref(false); const name = ref(''); const os = ref('iPadOS');
const requestId = ref(''); const createId = ref('');
const exportOpen = ref(false); const exportText = ref(''); const qr = ref(''); const exportName = ref('');
const gatewayOpen = ref(false); const server = ref(''); const gatewayKey = ref(''); const agentToken = ref('');
const systemOptions = ['macOS', 'iPadOS', 'iOS', 'Android', 'Windows', 'Linux', '其他'].map(value => ({ value, label: value }));
const filtered = computed(() => devices.value.filter(d => (!query.value || `${d.name} ${d.address} ${d.os}`.toLowerCase().includes(query.value.toLowerCase())) &&
(filter.value === 'all' || d.status === filter.value)));
let timer: ReturnType<typeof setInterval>;
async function refresh() {
if (loading.value) return;
loading.value = true;
try {
[devices.value, gateways.value] = await Promise.all([api.get<WgDeviceDto[]>('/api/wg/devices'), api.get<WgGatewayDto[]>('/api/wg/gateways')]);
if (selected.value) selected.value = await api.get(`/api/wg/devices/${selected.value.id}`);
} catch (e: any) { message.error(e.message); }
finally { loading.value = false; }
}
onMounted(async () => {
await refresh();
if (typeof route.query.device === 'string') await detail(route.query.device);
timer = setInterval(() => {
devices.value = devices.value.map(d => expireObservation(d));
if (selected.value) selected.value = expireObservation(selected.value);
refresh();
}, 15000);
});
onUnmounted(() => { clearInterval(timer); exportText.value = ''; qr.value = ''; agentToken.value = ''; });
async function detail(id: string) {
try { selected.value = await api.get(`/api/wg/devices/${id}`); editing.value = false; }
catch (e: any) { message.error(e.message); }
}
function requestUuid() {
const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function startCreate() {
name.value = ''; os.value = 'iPadOS'; requestId.value = requestUuid(); createId.value = ''; showCreate.value = true;
}
async function create() {
if (!name.value.trim() || busy.value) return;
busy.value = true;
try {
const device = await api.post<WgDeviceDto>('/api/wg/devices', { name: name.value, os: os.value, requestId: requestId.value });
createId.value = device.id;
await refresh(); await detail(device.id);
message.info('已保留设备与 IP,等待网关确认');
} catch (e: any) { message.error(e.message); }
finally { busy.value = false; }
}
async function action(device: WgDeviceDto, action: string) {
busy.value = true;
try { await api.post(`/api/wg/devices/${device.id}/actions`, { action }); await refresh(); message.info('任务已提交;网关确认前尚未完成'); }
catch (e: any) { message.error(e.message); }
finally { busy.value = false; }
}
function confirm(device: WgDeviceDto, kind: 'pause' | 'rotate' | 'remove') {
const texts = { pause: ['暂停接入', '将从网关运行与持久配置撤销 peer。确认前可能仍可通信,固定 IP 保留。'],
rotate: ['重置身份', '先撤销旧密钥,再应用新密钥;IP 保留。设备订阅刷新后取得新身份,独立配置需重新导入。'],
remove: ['移除设备', '网关确认撤销后归档设备、撤销全部设备链接。IP 不再分配给新设备。'] };
dialog.warning({ title: texts[kind][0], content: texts[kind][1], positiveText: '确认', negativeText: '取消', onPositiveClick: () => action(device, kind) });
}
async function save() {
if (!selected.value || !name.value.trim()) return;
try { await api.patch(`/api/wg/devices/${selected.value.id}`, { name: name.value, os: os.value }); editing.value = false; await refresh(); }
catch (e: any) { message.error(e.message); }
}
async function exportDevice(device: WgDeviceDto) {
busy.value = true;
try {
const result = await api.post<{ content: string }>('/api/wg/devices/' + device.id + '/exports');
exportText.value = result.content; exportName.value = device.name;
qr.value = await QRCode.toDataURL(result.content, { errorCorrectionLevel: 'M', margin: 3, width: 320 });
exportOpen.value = true;
} catch (e: any) { exportText.value = ''; qr.value = ''; message.error(e.message); }
finally { busy.value = false; }
}
function closeExport(open: boolean) { exportOpen.value = open; if (!open) { exportText.value = ''; qr.value = ''; } }
function download() {
const url = URL.createObjectURL(new Blob([exportText.value], { type: 'text/plain;charset=utf-8' }));
const link = document.createElement('a'); link.href = url; link.download = 'wireguard.conf'; link.click(); URL.revokeObjectURL(url);
}
function useSubscription(device: WgDeviceDto) { router.push({ path: '/subscriptions', query: { device: device.id } }); }
async function configureGateway() {
busy.value = true;
try {
const result = await api.post<{ agentToken: string }>('/api/wg/gateways', { server: server.value, port: 51820, publicKey: gatewayKey.value });
agentToken.value = result.agentToken; await refresh();
} catch (e: any) { message.error(e.message); }
finally { busy.value = false; }
}
</script>
<template>
<div>
<header class="page-head"><div><h1>设备</h1><p>个人设备网络 · 10.203.0.0/24 · MTU 1280</p></div><NSpace><NButton @click="gatewayOpen = true; agentToken = ''; server = gateways[0]?.server || ''; gatewayKey = gateways[0]?.publicKey || ''">网关</NButton><NButton type="primary" @click="startCreate">接入新设备</NButton></NSpace></header>
<div v-if="!gateways.length || gateways.some(g => !g.receivedAt || Date.now() - g.receivedAt > 45000)" class="notice">网关尚未接入或采集已过期。连接状态与速率显示未知;网关确认前任务不会显示成功。</div>
<section v-if="!selected" class="panel">
<div class="toolbar"><NInput v-model:value="query" placeholder="搜索名称、系统或 IP" aria-label="搜索设备" clearable /><NSelect v-model:value="filter" aria-label="设备状态" :options="[{ label: '全部状态', value: 'all' }, ...Object.entries(deviceStatus).map(([value, label]) => ({ value, label }))]" /></div>
<div class="device-list"><article v-for="d in filtered" :key="d.id" class="device-row">
<button class="device-name" @click="detail(d.id)"><strong>{{ d.name }}</strong><span>{{ d.os }} · <code>{{ d.address }}</code></span></button>
<div><span class="status" :class="d.status">{{ deviceStatus[d.status] }}</span><small>最后活动 {{ timeLabel(d.lastActivityAt) }}</small></div>
<div><span>↑ {{ bytes(d.uploadRate, true) }} ↓ {{ bytes(d.downloadRate, true) }}</span><small>累计 ↑ {{ bytes(d.uploadTotal) }} · ↓ {{ bytes(d.downloadTotal) }}</small></div>
<NButton size="small" @click="detail(d.id)">详情与管理</NButton>
</article><p v-if="!filtered.length" class="empty">暂无匹配的设备。现有身份会从旧设备订阅纳管,不创建演示设备。</p></div>
</section>
<template v-else>
<NButton text @click="selected = null">← 返回设备列表</NButton>
<section class="panel detail">
<div class="page-head"><div><h2>{{ selected.name }}</h2><p>{{ selected.os }} · <code>{{ selected.address }}</code></p></div><span class="status" :class="selected.status">{{ deviceStatus[selected.status] }}</span></div>
<div v-if="selected.job?.error" class="notice">{{ selected.job.error }}</div>
<div v-if="editing" class="edit"><NInput v-model:value="name" aria-label="设备名称" maxlength="64" /><NSelect v-model:value="os" :options="systemOptions" aria-label="操作系统" /><NSpace><NButton @click="save">保存</NButton><NButton @click="editing = false">取消</NButton></NSpace></div>
<dl><dt>固定私网 IP</dt><dd><code>{{ selected.address }}</code> <NButton size="tiny" @click="copyText(selected.address).then(ok => ok ? message.success('已复制') : message.info(selected!.address))">复制</NButton></dd><dt>最后活动</dt><dd>{{ timeLabel(selected.lastActivityAt) }}</dd><dt>最近采集</dt><dd>{{ timeLabel(selected.sampledAt) }}</dd><dt>设备上行 / 下行</dt><dd>{{ bytes(selected.uploadRate, true) }} / {{ bytes(selected.downloadRate, true) }}</dd><dt>累计上行 / 下行</dt><dd>{{ bytes(selected.uploadTotal) }} / {{ bytes(selected.downloadTotal) }}</dd><dt>设备公钥</dt><dd><code class="wrap">{{ selected.publicKey }}</code></dd></dl>
<NSpace><NButton :disabled="!selected.canDistribute" type="primary" @click="useSubscription(selected)">用于 Surge 订阅</NButton><NButton :loading="busy" :disabled="!selected.canDistribute" @click="exportDevice(selected)">导出独立 WireGuard 配置</NButton><NButton :loading="loading" @click="refresh">检查连接</NButton></NSpace>
<p class="help">生成、下载和导入配置不会将设备标为在线。近期握手与流量仅证明隧道活动,远程桌面需另行验证。</p>
<hr><h3>管理设备</h3><NSpace><NButton @click="editing = true; name = selected.name; os = selected.os">编辑信息</NButton><NButton v-if="selected.status === 'paused'" :disabled="busy" @click="action(selected, 'resume')">恢复接入</NButton><NButton v-else :disabled="busy || !selected.canDistribute" @click="confirm(selected, 'pause')">暂停接入</NButton><NButton :disabled="busy || !selected.canDistribute" @click="confirm(selected, 'rotate')">重置身份</NButton><NButton v-if="selected.job?.state !== 'confirmed'" :disabled="busy" @click="action(selected, 'retry')">重试当前任务</NButton><NButton type="error" :disabled="busy || selected.job?.state !== 'confirmed'" @click="confirm(selected, 'remove')">移除设备</NButton></NSpace>
<h3>操作记录</h3><p v-for="(event, index) in selected.events" :key="index" class="help">{{ timeLabel(event.createdAt) }} · {{ event.message }}</p>
</section>
</template>
<NModal :show="showCreate" preset="card" title="接入新设备" class="wg-modal" @update:show="showCreate = $event">
<template v-if="!createId"><p>自动预留固定 IP 和独立密钥,通过 njcq 互访个人设备。Surge 配置启用入站。</p><label>设备名称<NInput v-model:value="name" placeholder="例如:我的 iPad Pro" maxlength="64" /></label><label>操作系统<NSelect v-model:value="os" :options="systemOptions" /></label><NButton type="primary" :loading="busy" :disabled="!name.trim()" @click="create">创建设备并继续</NButton></template>
<template v-else><h3>{{ selected?.name }}</h3><p>固定 IP {{ selected?.address }} · {{ selected ? deviceStatus[selected.status] : '配置中' }}</p><p>身份和任务已保留,可关闭后从设备详情继续。网关确认前不会提供配置。</p><NSpace><NButton v-if="selected?.canDistribute" type="primary" @click="showCreate = false; useSubscription(selected)">前往 Surge 订阅</NButton><NButton v-if="selected?.canDistribute" @click="showCreate = false; exportDevice(selected)">独立导出</NButton><NButton @click="showCreate = false">查看设备详情</NButton></NSpace></template>
</NModal>
<NModal :show="exportOpen" preset="card" :title="`${exportName} · 独立 WireGuard 配置`" class="wg-modal" @update:show="closeExport"><img class="qr" :src="qr" alt="此设备 WireGuard 配置二维码,含私密身份" /><p>仅供此设备使用,请勿与另一台同时在线的设备复用。二维码在本浏览器生成。</p><NButton type="primary" @click="download">下载 .conf</NButton></NModal>
<NModal v-model:show="gatewayOpen" preset="card" title="njcq 网关" class="wg-modal" @after-leave="agentToken = ''">
<p v-for="g in gateways" :key="g.id">{{ g.id }} · {{ g.server }}:{{ g.port }}<br>最后采集 {{ timeLabel(g.receivedAt) }}</p>
<p>配置仅登记现有网关,不会部署 agent 或更改远端。安装步骤见项目 WireGuard 运维文档。</p>
<label>网关地址<NInput v-model:value="server" placeholder="网关域名或 IPv4" /></label><label>网关公钥<NInput v-model:value="gatewayKey" placeholder="从现有 wg-njcq 读取公钥" /></label>
<NButton :disabled="!server || !gatewayKey" :loading="busy" @click="configureGateway">{{ gateways[0]?.configured ? '轮换 agent 凭证' : '登记并签发 agent 凭证' }}</NButton>
<div v-if="agentToken" class="notice"><p>凭证仅显示这一次,配置到网关 agent 后妥善保存。</p><code class="wrap">{{ agentToken }}</code><NButton size="small" @click="copyText(agentToken)">复制凭证</NButton></div>
</NModal>
</div>
</template>
<style scoped>
h1 { margin: 0; font-size: 22px; } h2 { margin: 0; } p, small, dt { color: var(--muted); } small { display: block; margin-top: 6px; font-size: 11px; } code { font-family: var(--font-mono); }
.page-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; } .page-head p { margin: 8px 0 0; }
.panel { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); overflow: hidden; } .detail { padding: 24px; margin-top: 16px; } .toolbar { display: grid; grid-template-columns: 1fr 170px; gap: 12px; padding: 16px; border-bottom: 1px solid var(--line); }
.device-row { display: grid; grid-template-columns: minmax(150px,1fr) minmax(150px,1fr) minmax(180px,1fr) auto; align-items: center; gap: 16px; padding: 20px; border-bottom: 1px solid var(--line-soft); font-size: 13px; } .device-name { text-align: left; border: 0; padding: 0; background: transparent; color: var(--text); cursor: pointer; } .device-name strong { display: block; font-size: 15px; margin-bottom: 6px; } .device-name span { color: var(--muted); }
.status { display: inline-block; font-size: 12px; border-radius: 20px; padding: 3px 10px; background: var(--bg); color: var(--muted); } .online { color: var(--warp); background: var(--warp-dim); } .error, .revoking { color: var(--direct); background: var(--direct-dim); }
.notice { padding: 16px; border: 1px solid var(--line); background: var(--bg); border-radius: 8px; margin-bottom: 18px; } .empty { padding: 28px; } dl { display: grid; grid-template-columns: 150px 1fr; gap: 16px; margin: 24px 0; } dd { margin: 0; min-width: 0; } .wrap { overflow-wrap: anywhere; } .help { font-size: 12px; line-height: 1.7; } hr { border: 0; border-top: 1px solid var(--line); margin: 24px 0; } .edit { display: grid; gap: 12px; } .qr { display: block; margin: auto; width: 100%; max-width: 320px; }
:global(.wg-modal) { width: min(560px, calc(100vw - 32px)); } label { display: block; margin-bottom: 16px; font-size: 13px; }
@media (max-width: 800px) { .device-row { grid-template-columns: 1fr 1fr; } .device-row > * { min-width: 0; } }
@media (max-width: 480px) { .toolbar { grid-template-columns: 1fr; } .device-row { grid-template-columns: 1fr; } dl { grid-template-columns: 1fr; gap: 8px; } dd { margin-bottom: 8px; } .detail { padding: 16px; } }
</style>

View File

@@ -35,7 +35,7 @@ async function saveSections() {
<NCard title="Surge 原文段落" size="small" class="block">
<p class="hint">
这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/ClashMeta 不使用这些内容。
这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/FlClash 不使用这些内容。
</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)">

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue';
import { NButton, NPopover, NSpace, NSwitch, useDialog, useMessage } from 'naive-ui';
import type { TokenDto } from '@proxy-station/shared';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { NButton, NPopover, NSpace, NSwitch, NSelect, useDialog, useMessage } from 'naive-ui';
import { useRoute, useRouter } from 'vue-router';
import { api } from '../api/client';
import { deviceStatus, expireObservation } from '../utils/wireguard';
import type { WgDeviceDto, WgBindingDto, TokenDto } from '@proxy-station/shared';
import { useSubscriptionsStore } from '../stores/subscriptions';
import { useNodesStore } from '../stores/nodes';
import { copyText } from '../utils/clipboard';
@@ -12,10 +15,64 @@ const nodesStore = useNodesStore();
const message = useMessage();
const dialog = useDialog();
onMounted(() => {
store.fetchAll();
nodesStore.fetchAll();
const route = useRoute(); const router = useRouter();
const devices = ref<WgDeviceDto[]>([]);
const choices = ref<Record<string, string | null>>({});
const bindings = ref<Record<string, WgBindingDto | null>>({});
const bindingCache = new Map<string, WgBindingDto>();
const bindingErrors = ref<Record<string, string>>({});
const issuing = ref<Record<string, boolean>>({});
const formats = ref<Record<string, string>>({});
const formatOptions = [{ label: 'Surge', value: 'surge' }, { label: 'Shadowrocket', value: 'shadowrocket' }, { label: 'FlClash', value: 'clash' }];
let timer: ReturnType<typeof setInterval>;
async function refreshDevices() { devices.value = await api.get<WgDeviceDto[]>('/api/wg/devices'); }
onMounted(async () => {
try {
await Promise.all([store.fetchAll(), nodesStore.fetchAll(), refreshDevices()]);
if (typeof route.query.device === 'string') {
const token = store.tokens.find(t => t.enabled && !t.wgDeviceId);
if (token) await chooseDevice(token, route.query.device);
}
} catch (e: any) { message.error(e.message); }
timer = setInterval(() => {
devices.value = devices.value.map(d => expireObservation(d));
refreshDevices().catch((e: Error) => message.error(e.message));
}, 15000);
});
onUnmounted(() => { clearInterval(timer); bindingCache.clear(); });
function deviceOptions() {
return devices.value.map(d => ({ value: d.id, label: `${d.name} · ${d.address} · ${deviceStatus[d.status]}`, disabled: !d.canDistribute }));
}
async function chooseDevice(t: TokenDto, id: string | null) {
choices.value[t.id] = id; bindings.value[t.id] = null; bindingErrors.value[t.id] = ''; manualCopyText.value = '';
if (!id) { issuing.value[t.id] = false; return; }
const purpose = formats.value[t.id] === 'clash' ? 'clash' : 'surge';
issuing.value[t.id] = true;
try {
const cacheKey = `${t.id}:${id}:${purpose}`;
const result = bindingCache.get(cacheKey) ?? await api.post<WgBindingDto>(`/api/tokens/${t.id}/wg-bindings`, { deviceId: id, purpose });
bindingCache.set(cacheKey, result);
if (choices.value[t.id] === id && (formats.value[t.id] || 'surge') === purpose) bindings.value[t.id] = result;
} catch (e: any) { if (choices.value[t.id] === id && (formats.value[t.id] || 'surge') === purpose) bindingErrors.value[t.id] = e.message; }
finally { if (choices.value[t.id] === id && (formats.value[t.id] || 'surge') === purpose) issuing.value[t.id] = false; }
}
function blocked(t: TokenDto) {
return !t.enabled || (!!choices.value[t.id] && (!bindings.value[t.id] || !devices.value.find(d => d.id === choices.value[t.id])?.canDistribute));
}
function surgeUrl(t: TokenDto) { return fullUrl(choices.value[t.id] ? bindings.value[t.id]?.path ?? '' : t.urls.surge); }
function clashUrl(t: TokenDto) { return fullUrl(choices.value[t.id] ? bindings.value[t.id]?.path ?? '' : t.urls.clash); }
function changeFormat(t: TokenDto, value: string) { formats.value[t.id] = value; chooseDevice(t, null); }
function importSurge(t: TokenDto) {
if (!blocked(t)) window.location.href = `surge:///install-config?url=${encodeURIComponent(surgeUrl(t))}`;
}
async function revokeBinding(t: TokenDto) {
const binding = bindings.value[t.id]; if (!binding) return;
dialog.warning({ title: '撤销此设备链接', content: '此链接将无法再下载配置;已下载的网络身份仍可接入。要阻止网络访问,请在设备页暂停设备。', positiveText: '撤销链接', negativeText: '取消', onPositiveClick: async () => {
try { await api.delete(`/api/tokens/${t.id}/wg-bindings/${binding.id}`); bindingCache.delete(`${t.id}:${binding.deviceId}:${formats.value[t.id] === 'clash' ? 'clash' : 'surge'}`);
bindings.value[t.id] = null; bindingErrors.value[t.id] = '此链接已撤销,可重新生成。'; manualCopyText.value = '';
} catch (e: any) { message.error(e.message); }
} });
}
const showForm = ref(false);
const editing = ref<TokenDto | null>(null);
@@ -106,13 +163,32 @@ watch(manualCopyText, async (v) => {
</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="format-select"><label :for="`format-${t.id}`">订阅格式</label><NSelect :id="`format-${t.id}`" :value="formats[t.id] || 'surge'" :options="formatOptions" @update:value="(value: string) => changeFormat(t, value)" /></div>
<template v-if="!formats[t.id] || formats[t.id] === 'surge'">
<div v-if="t.wgDeviceId" class="device-notice">旧设备专属入口 · 保留原身份和 URL。附加设备功能请使用普通代理订阅;设备身份请在设备页管理。</div>
<div v-else class="device-select"><label :for="`device-${t.id}`">附加设备 WireGuard(可选)</label><NSelect :id="`device-${t.id}`" :value="choices[t.id] || null" :options="deviceOptions()" clearable placeholder="不附加设备 · 纯代理订阅" :disabled="!t.enabled" @update:value="(value: string | null) => chooseDevice(t, value)" /></div>
<div v-if="choices[t.id]" class="device-notice"><strong>{{ devices.find(d => d.id === choices[t.id])?.name || '此设备已移除' }}</strong> · {{ devices.find(d => d.id === choices[t.id])?.address }}<p>链接仅供这台设备使用。其他已生成的设备链接不受当前选择影响。</p><p v-if="blocked(t)">{{ bindingErrors[t.id] || (issuing[t.id] ? '正在生成设备链接…' : '设备不可下发,请检查设备状态。') }}</p></div>
<div class="row">
<span class="row-client">Surge</span>
<code class="mono row-url">{{ blocked(t) && choices[t.id] ? '设备链接暂不可用' : surgeUrl(t) }}</code>
<NButton size="tiny" quaternary :disabled="blocked(t)" @click="copy(surgeUrl(t))">复制</NButton>
</div>
<NSpace class="surge-actions"><NButton size="small" :disabled="blocked(t)" @click="importSurge(t)">导入 Surge</NButton><template v-if="choices[t.id]"><NButton size="small" @click="chooseDevice(t, null)">清除设备选择</NButton><NButton size="small" @click="router.push({ path: '/devices', query: { device: choices[t.id] } })">检查设备连接</NButton><NButton v-if="bindings[t.id]" size="small" @click="revokeBinding(t)">撤销此链接</NButton><NButton v-else size="small" :disabled="issuing[t.id]" @click="chooseDevice(t, choices[t.id])">重新生成链接</NButton></template></NSpace>
<details class="config-preview"><summary>查看下发内容结构</summary><pre>#!MANAGED-CONFIG {{ choices[t.id] ? '&lt;此设备的完整专属链接,含 wg 参数&gt;' : surgeUrl(t) }} interval=86400
<div class="row">
[Proxy]
# 原有代理节点
{{ choices[t.id] || t.wgDeviceId ? 'NJCQ WireGuard LAN = wireguard, section-name=njcq-lan' : '' }}
[Proxy Group]
# 原有策略组(WG 不进入公网代理池)
[Rule]
{{ choices[t.id] || t.wgDeviceId ? 'IP-CIDR,10.203.0.0/24,NJCQ WireGuard LAN,no-resolve' : '' }}
# 原有规则与 FINAL
{{ choices[t.id] || t.wgDeviceId ? '[WireGuard njcq-lan]\nprivate-key = &lt;已隐藏&gt;\npreshared-key = &lt;已隐藏&gt;\naccept-incoming = true' : '' }}</pre></details>
<p v-if="!choices[t.id] && !t.wgDeviceId" class="pop-body">原默认订阅地址。仅包含节点、策略组与规则,不附加设备身份。</p>
</template>
<div v-if="formats[t.id] === 'shadowrocket'" class="row">
<span class="row-client">
ShadowRocket<span class="row-os">iOS</span>
<NPopover trigger="hover" placement="bottom-start" style="max-width: 320px">
@@ -138,11 +214,12 @@ watch(manualCopyText, async (v) => {
</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>
<template v-if="formats[t.id] === 'clash'">
<div class="device-select"><label :for="`clash-device-${t.id}`">附加设备 WireGuard(可选)</label><NSelect :id="`clash-device-${t.id}`" :value="choices[t.id] || null" :options="deviceOptions()" clearable placeholder="不附加设备 · 纯代理订阅" :disabled="!t.enabled" @update:value="(value: string | null) => chooseDevice(t, value)" /></div>
<div v-if="choices[t.id]" class="device-notice"><strong>{{ devices.find(d => d.id === choices[t.id])?.name || '此设备已移除' }}</strong> · {{ devices.find(d => d.id === choices[t.id])?.address }}<p>仅供该设备使用。WireGuard 用于主动访问私网,Android 本机入站尚未验证;请勿在多台设备同时使用同一身份。</p><p v-if="blocked(t)">{{ bindingErrors[t.id] || (issuing[t.id] ? '正在生成设备链接…' : '设备不可下发,请检查设备状态。') }}</p></div>
<div class="row"><span class="row-client">FlClash</span><code class="mono row-url">{{ blocked(t) && choices[t.id] ? '设备链接暂不可用' : clashUrl(t) }}</code><NButton size="tiny" quaternary :disabled="blocked(t)" @click="copy(clashUrl(t))">复制</NButton></div>
<NSpace v-if="choices[t.id]" class="surge-actions"><NButton size="small" @click="chooseDevice(t, null)">清除设备选择</NButton><NButton size="small" @click="router.push({ path: '/devices', query: { device: choices[t.id] } })">检查设备连接</NButton><NButton v-if="bindings[t.id]" size="small" @click="revokeBinding(t)">撤销此链接</NButton><NButton v-else size="small" :disabled="issuing[t.id]" @click="chooseDevice(t, choices[t.id])">重新生成链接</NButton></NSpace>
</template>
</div>
<footer class="token-foot">
@@ -169,6 +246,12 @@ watch(manualCopyText, async (v) => {
</template>
<style scoped>
.format-select, .device-select { display: grid; gap: 8px; margin: 14px 0; font-size: 12px; }
.device-notice { padding: 12px; border: 1px solid var(--line); border-radius: 6px; font-size: 12px; background: var(--bg); }
.device-notice p { margin: 8px 0 0; color: var(--muted); }
.surge-actions { margin: 12px 0; }
.config-preview { margin: 16px 0; font-size: 12px; }
.config-preview pre { overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg); padding: 12px; }
.page-head {
display: flex;
justify-content: space-between;
@@ -183,7 +266,7 @@ watch(manualCopyText, async (v) => {
.token-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(560px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 560px), 1fr));
gap: 14px;
}
@@ -217,6 +300,10 @@ watch(manualCopyText, async (v) => {
.token-name {
font-size: 15px;
font-weight: 700;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 节点范围徽标 */
@@ -397,4 +484,9 @@ watch(manualCopyText, async (v) => {
width: auto;
}
}
@media (max-width: 480px) {
.token-head { flex-wrap: wrap; }
.token-title { max-width: 100%; }
}
</style>

View File

@@ -9,6 +9,7 @@ export const router = createRouter({
{ path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } },
{ path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } },
{ path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } },
{ path: '/devices', component: () => import('./pages/DevicesPage.vue'), meta: { title: '设备' } },
{ path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } },
{ path: '/settings', component: () => import('./pages/SettingsPage.vue'), meta: { title: '设置' } },
{ path: '/:pathMatch(.*)*', redirect: '/nodes' },

View File

@@ -0,0 +1,19 @@
import type { WgDeviceDto } from '@proxy-station/shared';
export const deviceStatus: Record<WgDeviceDto['status'], string> = {
provisioning: '配置中', restoring: '正在恢复', revoking: '正在撤销', error: '配置异常',
pending: '待接入', online: '近期在线', offline: '离线', unknown: '状态未知', paused: '已暂停', archived: '已移除',
};
export function bytes(value: number | null, rate = false): string {
if (value === null) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let index = 0;
while (value >= 1024 && index < units.length - 1) { value /= 1024; index++; }
return `${value.toFixed(index ? 1 : 0)} ${units[index]}${rate ? '/s' : ''}`;
}
export const timeLabel = (time: number | null) => time ? new Date(time).toLocaleString() : '尚无记录';
/** Keep cached UI honest even when the management API itself becomes unreachable. */
export function expireObservation<T extends WgDeviceDto>(device: T, now = Date.now()): T {
if (device.sampledAt !== null && now - device.sampledAt <= 45000) return device;
const connectionState = ['pending', 'online', 'offline', 'unknown'].includes(device.status);
return { ...device, ...(connectionState ? { status: 'unknown' as const } : {}), uploadRate: null, downloadRate: null };
}