Files
proxy-station/docs/design/wireguard-devices/wg-device-management.html
YANG JIANKUAN a5d980a5e9 feat: 添加 WireGuard 设备管理并完善客户端分流
实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。

完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
2026-11-02 22:44:23 +08:00

250 lines
53 KiB
HTML
Raw Permalink 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.

<div id="ps-wg-design" aria-label="Proxy Station 设备管理交互原型">
<style>
#ps-wg-design{--ps-bg:light-dark(#f7f7f4,#171c22);--ps-panel:light-dark(#fff,#202730);--ps-ink:light-dark(#2b3f5c,#e0e7ef);--ps-muted:light-dark(#68727f,#a5b0bd);--ps-line:light-dark(#e2e2dd,#35404c);--ps-teal:light-dark(#007a72,#6fd5c7);--ps-soft:light-dark(#eaf5f1,#253d3b);--ps-warn:light-dark(#956000,#f2c776);--ps-warn-bg:light-dark(#fff7e8,#3a3020);--ps-red:light-dark(#b13732,#ffb0a7);--ps-red-bg:light-dark(#fff1ee,#402925);color-scheme:light dark;background:var(--ps-bg);color:var(--ps-ink);font:14px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;border-radius:12px;overflow-wrap:anywhere;min-height:650px}
#ps-wg-design *{box-sizing:border-box}#ps-wg-design h1,#ps-wg-design h2,#ps-wg-design h3,#ps-wg-design p{margin:0}#ps-wg-design h1{font-size:27px;line-height:1.3;letter-spacing:-.6px;font-weight:650}#ps-wg-design h2{font-size:19px;font-weight:650}#ps-wg-design h3{font-size:15px;font-weight:600}#ps-wg-design button,#ps-wg-design input,#ps-wg-design select{font:inherit;color:inherit}#ps-wg-design button{cursor:var(--cursor-interaction,auto)}#ps-wg-design button:disabled{opacity:.5;cursor:default}#ps-wg-design button{background:none;border:0}#ps-wg-design svg,#ps-wg-design i[data-lucide]{width:17px;height:17px;flex:none}#ps-wg-design [hidden]{display:none!important}
#ps-wg-design .ps-top{display:flex;align-items:center;flex-wrap:wrap;gap:22px;padding:20px 110px 17px 26px;background:var(--ps-panel);border-bottom:1px solid var(--ps-line)}#ps-wg-design .ps-brand{display:flex;align-items:center;gap:9px;font-size:17px;font-weight:650;letter-spacing:-.3px}#ps-wg-design .ps-mark{background:var(--ps-teal);color:var(--ps-panel);display:grid;place-items:center;width:28px;height:28px;border-radius:8px;font-size:17px}#ps-wg-design .ps-nav{display:flex;gap:3px;flex-wrap:wrap}#ps-wg-design .ps-nav button{padding:6px 11px;border-radius:7px;font-size:13px}#ps-wg-design .ps-nav .ps-active{background:var(--ps-soft);color:var(--ps-teal);font-weight:600}#ps-wg-design .ps-demo{font-size:11px;color:var(--ps-muted);border:1px solid var(--ps-line);padding:3px 7px;border-radius:5px}
#ps-wg-design .ps-body{padding:26px;max-width:1200px;margin:auto}#ps-wg-design .ps-heading{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:22px}#ps-wg-design .ps-sub{color:var(--ps-muted);margin-top:7px;font-size:13px}#ps-wg-design .ps-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:9px 13px;border:1px solid var(--ps-line);border-radius:7px;background:var(--ps-panel);font-weight:500;min-height:39px;white-space:normal;text-align:center}#ps-wg-design .ps-primary{background:var(--ps-teal);border-color:var(--ps-teal);color:var(--ps-panel)}#ps-wg-design .ps-danger{color:var(--ps-red);border-color:var(--ps-line)}#ps-wg-design .ps-link{color:var(--ps-teal);padding:6px 0;display:inline-flex;gap:6px;align-items:center;text-align:left}#ps-wg-design .ps-small{font-size:12px;color:var(--ps-muted)}#ps-wg-design .ps-mono{font:12px/1.6 ui-monospace,SFMono-Regular,monospace;font-variant-numeric:tabular-nums}#ps-wg-design .ps-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}#ps-wg-design .ps-spread{justify-content:space-between}#ps-wg-design .ps-stack{display:grid;gap:18px}#ps-wg-design .ps-panel{background:var(--ps-panel);border:1px solid var(--ps-line);border-radius:10px}#ps-wg-design .ps-pad{padding:20px}#ps-wg-design .ps-line{border:0;border-top:1px solid var(--ps-line);margin:20px 0}#ps-wg-design .ps-notice{display:flex;align-items:flex-start;gap:9px;background:var(--ps-soft);padding:12px 14px;border-radius:8px;font-size:13px}#ps-wg-design .ps-notice svg{margin-top:2px;color:var(--ps-teal)}#ps-wg-design .ps-warning{background:var(--ps-warn-bg);color:var(--ps-warn)}#ps-wg-design .ps-error{background:var(--ps-red-bg);color:var(--ps-red)}#ps-wg-design .ps-badge{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;border-radius:5px;padding:3px 7px;font-size:11px;background:var(--ps-bg);color:var(--ps-muted)}#ps-wg-design .ps-badge:before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}#ps-wg-design .ps-good{background:var(--ps-soft);color:var(--ps-teal)}#ps-wg-design .ps-amber{background:var(--ps-warn-bg);color:var(--ps-warn)}#ps-wg-design .ps-bad{background:var(--ps-red-bg);color:var(--ps-red)}
#ps-wg-design .ps-network{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:15px 18px;margin-bottom:20px}#ps-wg-design .ps-network-title{font-size:14px;font-weight:600}#ps-wg-design .ps-network-icon{display:grid;place-items:center;background:var(--ps-bg);border:1px solid var(--ps-line);border-radius:8px;width:36px;height:36px}#ps-wg-design .ps-toolbar{padding:15px 18px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;border-bottom:1px solid var(--ps-line)}#ps-wg-design .ps-filter{display:flex;gap:6px;flex-wrap:wrap}#ps-wg-design .ps-filter button{padding:5px 9px;color:var(--ps-muted);border-radius:5px;font-size:12px}#ps-wg-design .ps-filter button[aria-pressed=true]{background:var(--ps-soft);color:var(--ps-teal)}#ps-wg-design input,#ps-wg-design select{padding:9px 11px;background:var(--ps-panel);border:1px solid var(--ps-line);border-radius:7px;min-width:0;max-width:100%}#ps-wg-design .ps-search{max-width:220px;font-size:12px}#ps-wg-design table{width:100%;border-collapse:collapse;text-align:left}#ps-wg-design th{font-size:11px;font-weight:500;color:var(--ps-muted);background:var(--ps-bg);padding:10px 16px}#ps-wg-design td{padding:17px 16px;border-top:1px solid var(--ps-line);vertical-align:middle}#ps-wg-design .ps-device-name{display:flex;align-items:center;gap:11px}#ps-wg-design .ps-device-icon{display:grid;place-items:center;width:34px;height:34px;background:var(--ps-bg);border-radius:8px;color:var(--ps-muted);flex:none}#ps-wg-design .ps-name-button{text-align:left;font-weight:600;padding:0;display:block}#ps-wg-design .ps-name-button:hover,#ps-wg-design .ps-link:hover{text-decoration:underline}#ps-wg-design .ps-cell-sub{display:block;color:var(--ps-muted);font-size:11px;margin-top:2px}#ps-wg-design .ps-copy{display:inline-flex;padding:5px;color:var(--ps-muted);vertical-align:middle}#ps-wg-design .ps-copy svg{width:13px;height:13px}#ps-wg-design .ps-table-foot{padding:13px 18px;color:var(--ps-muted);font-size:11px;border-top:1px solid var(--ps-line)}#ps-wg-design .ps-empty{text-align:center;padding:44px 20px;color:var(--ps-muted)}#ps-wg-design .ps-footer{display:flex;gap:8px;align-items:center;justify-content:space-between;margin-top:16px;font-size:11px;color:var(--ps-muted);flex-wrap:wrap}
#ps-wg-design .ps-back{margin-bottom:17px;font-size:12px}#ps-wg-design .ps-columns{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:20px;align-items:start}#ps-wg-design dl{display:grid;grid-template-columns:120px minmax(0,1fr);gap:14px;margin:20px 0 0}#ps-wg-design dt{color:var(--ps-muted);font-size:12px}#ps-wg-design dd{margin:0;font-size:13px}#ps-wg-design .ps-tabs{display:flex;border-bottom:1px solid var(--ps-line);gap:22px;margin-bottom:20px}#ps-wg-design .ps-tabs button{padding:11px 0;color:var(--ps-muted);border-bottom:2px solid transparent}#ps-wg-design .ps-tabs button[aria-selected=true]{color:var(--ps-teal);border-color:var(--ps-teal)}#ps-wg-design .ps-usage{display:grid;grid-template-columns:1fr 1fr;gap:15px;margin:18px 0}#ps-wg-design .ps-usage strong{display:block;font-size:23px;font-weight:550;font-variant-numeric:tabular-nums;margin-top:3px}#ps-wg-design .ps-event{display:grid;grid-template-columns:70px 1fr;gap:14px;padding:14px 0;border-top:1px solid var(--ps-line);font-size:13px}#ps-wg-design .ps-event:first-of-type{margin-top:14px}#ps-wg-design .ps-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}#ps-wg-design .ps-confirm{background:var(--ps-red-bg);padding:15px;border-radius:8px;margin-top:16px;font-size:13px}#ps-wg-design .ps-confirm .ps-actions{margin-top:12px}#ps-wg-design .ps-edit-form{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
#ps-wg-design .ps-wizard{max-width:780px;margin:0 auto}#ps-wg-design .ps-steps{display:flex;justify-content:space-between;gap:10px;margin:24px 0 28px}#ps-wg-design .ps-step{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--ps-muted)}#ps-wg-design .ps-step span{display:grid;place-items:center;border:1px solid var(--ps-line);border-radius:50%;width:24px;height:24px;font-size:11px;flex:none}#ps-wg-design .ps-step.ps-current{color:var(--ps-teal)}#ps-wg-design .ps-step.ps-current span{background:var(--ps-teal);border-color:var(--ps-teal);color:var(--ps-panel)}#ps-wg-design .ps-form{display:grid;gap:20px}#ps-wg-design .ps-field{display:grid;gap:7px}#ps-wg-design .ps-label{font-size:13px;font-weight:550}#ps-wg-design .ps-field input,#ps-wg-design .ps-field select{width:100%}#ps-wg-design .ps-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}#ps-wg-design .ps-option{padding:13px 11px!important;border:1px solid var(--ps-line)!important;border-radius:8px;text-align:left;background:var(--ps-panel)!important;display:flex;flex-direction:column;gap:5px;align-items:start;min-height:92px}#ps-wg-design .ps-option[aria-pressed=true]{border-color:var(--ps-teal)!important;background:var(--ps-soft)!important}#ps-wg-design .ps-option strong{font-size:13px;font-weight:550}#ps-wg-design .ps-option span{font-size:11px;color:var(--ps-muted)}#ps-wg-design .ps-option svg{margin-bottom:2px}#ps-wg-design details{border-top:1px solid var(--ps-line);padding-top:15px;font-size:12px}#ps-wg-design summary{color:var(--ps-muted);cursor:var(--cursor-interaction,auto)}#ps-wg-design .ps-wizard-foot{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-top:25px;padding-top:18px;border-top:1px solid var(--ps-line)}#ps-wg-design .ps-delivery{display:grid;grid-template-columns:180px minmax(0,1fr);gap:23px;align-items:start;margin:22px 0}#ps-wg-design .ps-qr{background:var(--ps-panel);border-radius:10px;padding:12px;width:174px;max-width:100%;border:1px solid var(--ps-line)}#ps-wg-design .ps-qr img{width:100%;display:block;image-rendering:pixelated}#ps-wg-design .ps-qr-label{text-align:center;color:var(--ps-muted);font-size:11px;margin-top:8px}#ps-wg-design .ps-instructions{padding-left:18px;margin:14px 0;font-size:13px;display:grid;gap:9px}#ps-wg-design .ps-code{padding:14px 17px;letter-spacing:3px;background:var(--ps-bg);border-radius:7px;font:22px/1.5 ui-monospace,monospace;margin:15px 0;width:fit-content}#ps-wg-design .ps-success-icon{width:48px;height:48px;background:var(--ps-soft);color:var(--ps-teal);display:grid;place-items:center;border-radius:50%;margin-bottom:18px}#ps-wg-design .ps-success-icon svg{width:24px;height:24px}#ps-wg-design .ps-success{padding:30px 24px}#ps-wg-design .ps-checking{display:flex;align-items:center;gap:12px;padding:20px;background:var(--ps-bg);border-radius:8px;margin:20px 0}#ps-wg-design .ps-checking svg{color:var(--ps-teal)}#ps-wg-design .ps-subscription{padding:20px;margin-top:16px}#ps-wg-design .ps-url{display:block;background:var(--ps-bg);padding:10px 12px;margin-top:10px;border-radius:6px;overflow-wrap:anywhere}#ps-wg-design .ps-toast{margin:0 26px 20px;padding:11px 14px;background:var(--ps-soft);border:1px solid var(--ps-line);border-radius:8px;font-size:13px;color:var(--ps-teal)}
@media(max-width:800px){#ps-wg-design .ps-top{gap:12px;padding-left:20px}#ps-wg-design .ps-nav{order:3;width:100%}#ps-wg-design .ps-body{padding:22px 20px}#ps-wg-design th,#ps-wg-design td{padding-left:12px;padding-right:12px}#ps-wg-design .ps-traffic-col{display:none}#ps-wg-design .ps-columns{grid-template-columns:1fr 1fr}}
@media(max-width:600px){#ps-wg-design{min-height:600px}#ps-wg-design .ps-top{padding:17px 16px 12px}#ps-wg-design .ps-brand{padding-right:80px}#ps-wg-design .ps-demo{display:none}#ps-wg-design .ps-nav{gap:0;margin-right:0}#ps-wg-design .ps-nav button:last-child{display:none}#ps-wg-design .ps-nav button{padding:7px 10px;font-size:12px;min-height:44px}#ps-wg-design .ps-body{padding:22px 15px}#ps-wg-design h1{font-size:24px}#ps-wg-design .ps-heading{align-items:flex-start;flex-wrap:wrap;gap:14px}#ps-wg-design .ps-heading>.ps-button{min-height:42px}#ps-wg-design .ps-network{align-items:flex-start;padding:14px;flex-direction:column;gap:8px}#ps-wg-design .ps-toolbar{padding:12px;gap:10px}#ps-wg-design .ps-search{max-width:none;width:100%;font-size:16px}#ps-wg-design table,#ps-wg-design tbody{display:block}#ps-wg-design thead{display:none}#ps-wg-design tr{display:grid;grid-template-columns:minmax(0,1fr) auto;padding:14px;border-top:1px solid var(--ps-line);gap:8px 10px}#ps-wg-design td{display:block;border:0;padding:0}#ps-wg-design td:first-child{grid-column:1;grid-row:1}#ps-wg-design td:nth-child(2){grid-column:1;grid-row:2;padding-left:45px}#ps-wg-design td:nth-child(3){grid-column:2;grid-row:1}#ps-wg-design td:nth-child(4){display:none}#ps-wg-design td:last-child{grid-column:2;grid-row:2;align-self:end}#ps-wg-design .ps-columns{grid-template-columns:1fr}#ps-wg-design .ps-pad{padding:17px}#ps-wg-design .ps-options{grid-template-columns:1fr}#ps-wg-design .ps-option{min-height:70px;display:grid;grid-template-columns:22px 1fr;gap:3px 9px}#ps-wg-design .ps-option svg{grid-row:1/3}#ps-wg-design .ps-option span{grid-column:2}#ps-wg-design .ps-steps{gap:6px}#ps-wg-design .ps-step{flex-direction:column;font-size:11px;gap:5px;text-align:center;flex:1}#ps-wg-design .ps-delivery{grid-template-columns:1fr;gap:16px}#ps-wg-design .ps-qr-wrap{justify-self:center}#ps-wg-design .ps-button{min-height:44px}#ps-wg-design input,#ps-wg-design select{font-size:16px}#ps-wg-design .ps-wizard-foot{align-items:flex-start;flex-wrap:wrap}#ps-wg-design .ps-toast{margin-left:15px;margin-right:15px}#ps-wg-design dl{grid-template-columns:90px 1fr;gap:13px}#ps-wg-design .ps-filter button{min-height:36px}#ps-wg-design .ps-success{padding:24px 18px}}
#ps-wg-design .ps-sub-selectors{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.5fr);gap:20px;align-items:start}
#ps-wg-design .ps-config-preview{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--ps-bg);color:var(--ps-ink);padding:15px;border-radius:7px;font:12px/1.8 ui-monospace,SFMono-Regular,monospace;margin:14px 0 0}
#ps-wg-design .ps-url{word-break:break-all}
@media(max-width:600px){#ps-wg-design .ps-sub-selectors{grid-template-columns:1fr;gap:16px}}
</style>
<header class="ps-top">
<div class="ps-brand"><span class="ps-mark">P</span>Proxy Station</div>
<nav class="ps-nav" aria-label="主导航">
<button type="button" disabled>节点</button><button type="button" disabled>策略组</button><button type="button" disabled>规则</button><button type="button" data-action="devices" class="ps-active">设备</button><button type="button" data-action="subscriptions">订阅</button><button type="button" disabled>设置</button>
</nav>
<span class="ps-demo">交互原型 · 演示数据</span>
</header>
<main class="ps-body" id="ps-wg-content"></main>
<div class="ps-toast" id="ps-wg-toast" role="status" aria-live="polite" hidden></div>
<script>
(() => {
const root = document.getElementById('ps-wg-design');
const main = root.querySelector('#ps-wg-content');
const toastBox = root.querySelector('#ps-wg-toast');
const initialDevices = [
{id:1,name:'Mac Studio',os:'macOS',ip:'10.203.0.3',status:'online',last:'12 秒前',up:'26.4 Mbps',down:'120 Kbps',incoming:true,events:['开始设备纳管,保留原有身份','首次连接已验证','最近握手:12 秒前']},
{id:2,name:'iPad Pro',os:'iPadOS',ip:'10.203.0.2',status:'online',last:'18 秒前',up:'108 Kbps',down:'26.2 Mbps',incoming:true,events:['开始设备纳管,保留原有身份','已领取 WireGuard 配置','最近握手:18 秒前']},
{id:3,name:'MacBook Air',os:'macOS',ip:'10.203.0.4',status:'online',last:'1 分钟前',up:'0 bps',down:'0 bps',incoming:true,events:['设备身份已创建','首次连接已验证']},
{id:4,name:'iPhone',os:'iOS',ip:'10.203.0.5',status:'offline',last:'4 小时前',up:'—',down:'—',incoming:true,events:['设备身份已创建','首次连接已验证','最后活动:4 小时前']},
{id:5,name:'工作笔记本',os:'Windows',ip:'10.203.0.6',status:'pending',last:'尚未连接',up:'—',down:'—',incoming:true,events:['设备身份已创建','网关配置已生效,等待首次握手']},
{id:6,name:'开发主机',os:'Linux',ip:'10.203.0.7',status:'paused',last:'昨天',up:'—',down:'—',incoming:true,events:['设备身份已创建','已暂停:网关已移除 peer 并持久化']}
];
let devices = initialDevices.map(d=>({...d,events:[...d.events]}));
let addressWatermark=7;
const activeJobs=new Set();
let subscriptionDevice=0,subscriptionFormat='surge',lastPublishedState='';
const publishedStates=new Set();
const demoQr='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKUAAAClAQMAAAAH5a+ZAAAABlBMVEUAAAD///+l2Z/dAAAAAnRSTlP//8i138cAAAAJcEhZcwAACxIAAAsSAdLdfvwAAAFpSURBVEiJ3ZYxjoUwDESNKChzBG4CF0MCiYvBTXKElBSI2bGBv3zplztb/BQRvBRJHM/Yhk/DvoQuZtYsVoPTCP5YK6Qr92xWzGhQhrYBdikt1jZHQsbB7XmQQU6ByaxLM/6Frpi4VEY99fjy2+I136IuoJE7qz+kT28ZJaAxlkQxVKRvahFQ3jM3S5rarcJuVjHSSuqgJ9iqwuv2WhoCPJgxW2d1/s0dDaX2znSNyG6WIr4qilJnLoyAi4M3jkxV0dh+LWP2+Lav+IroqfQ0562P9ftkIuqDZ8BJn2oRUN/eOiqdtMYdXxV1myYY+KLJX7OYkrpxEuxeGUj5paSgCt1C3T3ttG4hdZG7LrKFnxlLn5DeueO6oLlcDqOiUYcO9kbu1V5tdyU9eyOe4VI/4sYyencrPMP0iq+URmWouP1mz/5MREMcPeYchq2kHl8vtN6tuBQBIb16IzYRTmlvz07sz+mn8R30B4VjHnaCiv9uAAAAAElFTkSuQmCC';
let page='subscriptions',selected=1,detailTab='overview',filter='all',query='',confirmAction='',editing=false,wizard=null,toastTimer;
const design = {scenario:'正常采集'};
const labels={online:'近期在线',offline:'离线',pending:'待接入',paused:'已暂停',unknown:'状态未知',provisioning:'配置中',error:'配置异常',revoking:'正在暂停',restoring:'正在恢复'};
const badgeClass={online:'ps-good',pending:'ps-amber',error:'ps-bad',unknown:'ps-amber',provisioning:'ps-amber',revoking:'ps-amber',restoring:'ps-amber'};
const icon=(name)=>`<i data-lucide="${name}" aria-hidden="true"></i>`;
const esc=(value)=>String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const button=(label,action,cls='',attrs='')=>`<button type="button" class="ps-button ${cls}" data-action="${action}" ${attrs}>${label}</button>`;
const isStale=()=>design.scenario==='网关失联';
const status=d=>isStale()&&['online','offline'].includes(d.status)?'unknown':d.status;
const badge=d=>`<span class="ps-badge ${badgeClass[status(d)]||''}">${labels[status(d)]}</span>`;
const osIcon=os=>['iPadOS','iOS'].includes(os)?(os==='iPadOS'?'tablet':'smartphone'):(os==='Linux'?'server':'monitor');
const copy=(value)=>{try{const p=navigator.clipboard?.writeText(value);if(p)p.then(()=>toast('已复制:'+value)).catch(()=>toast('请手动复制:'+value));else toast('请手动复制:'+value);}catch{toast('请手动复制:'+value);}};
function toast(message){clearTimeout(toastTimer);toastBox.textContent=message;toastBox.hidden=false;toastTimer=setTimeout(()=>{toastBox.hidden=true;},5500);}
function save(){
try{
const snapshot={modelContent:{page,selectedDevice:devices.find(d=>d.id===selected)?.name||null,scenario:design.scenario,subscriptionDevice:devices.find(d=>d.id===subscriptionDevice)?.name||null,subscriptionFormat},privateContent:{version:2,page,selected,subscriptionDevice,subscriptionFormat,devices:devices.map(d=>({...d,events:d.events.slice(-5)})),filter,query,addressWatermark,wizard,scenario:design.scenario}};
if(new TextEncoder().encode(JSON.stringify(snapshot)).length>14500)snapshot.privateContent.devices.forEach(d=>{d.events=d.events.slice(-1);});
lastPublishedState=JSON.stringify(snapshot.privateContent);
publishedStates.add(lastPublishedState);if(publishedStates.size>32)publishedStates.delete(publishedStates.values().next().value);
window.openai?.setWidgetState?.(snapshot)?.catch(()=>{});
}catch{}
}
function restore(snapshot){const s=snapshot?.privateContent;if(!s||s.version!==2)return;subscriptionDevice=Number(s.subscriptionDevice)||0;subscriptionFormat=['surge','shadowrocket','shadowrocket-conf','clash'].includes(s.subscriptionFormat)?s.subscriptionFormat:'surge';if(['正常采集','网关失联','配置下发失败'].includes(s.scenario))design.scenario=s.scenario;if(Array.isArray(s.devices)&&s.devices.length<=20&&s.devices.every(d=>typeof d.name==='string'&&typeof d.ip==='string'&&Array.isArray(d.events)))devices=s.devices;wizard=s.wizard&&[1,2,3].includes(s.wizard.step)?s.wizard:null;page=['devices','detail','subscriptions','gateway','wizard','export'].includes(s.page)?s.page:'devices';if(page==='wizard'&&!wizard)page='devices';selected=Number(s.selected)||1;filter=s.filter||'all';query=typeof s.query==='string'?s.query:'';addressWatermark=Math.max(7,Number(s.addressWatermark)||7,...devices.map(d=>Number(d.ip.split('.').pop())));}
restore(window.openai?.widgetState);
window.addEventListener('openai:set_globals',e=>{if(e.detail?.globals?.widgetState){if(publishedStates.has(JSON.stringify(e.detail.globals.widgetState.privateContent)))return;restore(e.detail.globals.widgetState);render();resumeQueued();}});
function gatewayNotice(){return isStale()?`<div class="ps-notice ps-warning">${icon('wifi-off')}<div><strong>网关已 3 分钟未更新</strong><br>设备连接状态暂时未知。暂停等操作需等网关恢复后确认。</div></div>`:'';}
function network(){return `<section class="ps-panel ps-network"><div class="ps-row"><span class="ps-network-icon">${icon('network')}</span><div><div class="ps-network-title">个人设备网络 <span class="ps-small">/ njcq</span></div><div class="ps-small ps-mono">10.203.0.0/24 · 设备间互访</div></div></div><div class="ps-row"><span class="ps-badge ${isStale()?'ps-amber':'ps-good'}">${isStale()?'采集失联 · 3 分钟前':'网关正常 · 8 秒前更新'}</span><button type="button" class="ps-link ps-small" data-action="gateway">查看网关 ${icon('arrow-up-right')}</button></div></section>`;}
function listRows(){const filtered=devices.filter(d=>(filter==='all'||status(d)===filter)&&(!query||(d.name+' '+d.ip).toLowerCase().includes(query.toLowerCase())));return filtered.length?filtered.map(d=>`<tr><td><div class="ps-device-name"><span class="ps-device-icon">${icon(osIcon(d.os))}</span><div><button type="button" class="ps-name-button" data-action="detail" data-id="${d.id}">${esc(d.name)}</button><span class="ps-cell-sub">${esc(d.os)}</span></div></div></td><td><span class="ps-mono">${d.ip}</span><button type="button" class="ps-copy" aria-label="复制 ${esc(d.name)} 的 IP" data-action="copy" data-value="${d.ip}">${icon('copy')}</button></td><td>${badge(d)}</td><td><span class="ps-small">${status(d)==='unknown'?'采集已过期':d.last}</span></td><td class="ps-traffic-col"><span class="ps-mono">${status(d)==='online'?d.up:'—'}</span><span class="ps-cell-sub ps-mono">${status(d)==='online'?d.down:'—'}</span></td><td><button type="button" class="ps-link ps-small" data-action="${d.status==='pending'||d.status==='error'?'continue':'detail'}" data-id="${d.id}">${d.status==='pending'||d.status==='error'?'继续接入':'详情'} ${icon('chevron-right')}</button></td></tr>`).join(''):`<tr><td colspan="6"><div class="ps-empty">没有匹配的设备${query?',试试其他名称或 IP。':'。'}</div></td></tr>`;}
function devicesPage(){const count=devices.filter(d=>status(d)==='online').length;return `<div class="ps-heading"><div><h1>设备</h1><p class="ps-sub">管理私网中的设备,连接到它们的固定 IP。</p></div>${button(icon('plus')+' 接入新设备','new','ps-primary')}</div>${network()}<div class="ps-stack">${gatewayNotice()}<section class="ps-panel"><div class="ps-toolbar"><div class="ps-filter" aria-label="设备状态筛选">${[['all','全部 '+devices.length],['online','近期在线 '+count],['pending','待接入'],['paused','已暂停']].map(([v,l])=>`<button type="button" data-action="filter" data-value="${v}" aria-pressed="${filter===v}">${l}</button>`).join('')}</div><input class="ps-search" id="ps-search" aria-label="搜索设备名称或 IP" placeholder="搜索名称或 IP" value="${esc(query)}"></div><table aria-label="WireGuard 接入设备"><thead><tr><th>设备</th><th>私网 IP</th><th>状态</th><th>最后活动</th><th class="ps-traffic-col">上行 / 下行</th><th>操作</th></tr></thead><tbody id="ps-device-rows">${listRows()}</tbody></table><div class="ps-table-foot">流量方向以设备为准:上行 = 设备 → 网关;下行 = 网关 → 设备。近期握手不代表远程桌面已就绪。</div></section></div><div class="ps-footer"><span>每 15 秒更新 · 演示数据,未连接生产网关</span><button type="button" class="ps-link ps-small" data-action="subscriptions">原默认订阅仍在「订阅」中 ${icon('arrow-right')}</button></div>`;}
function compatibility(d){return `<div class="ps-notice">${icon('monitor')}<div><strong>通过固定 IP 访问设备</strong><br>在远程桌面中填写目标设备的私网 IP。网络握手与屏幕共享服务是否可用分开确认。</div></div>`;}
function detailsPage(){const d=devices.find(x=>x.id===selected)||devices[0];if(!d)return devicesPage();return `<button type="button" class="ps-link ps-back" data-action="devices">${icon('arrow-left')} 返回设备</button><div class="ps-heading"><div><div class="ps-row"><h1>${esc(d.name)}</h1>${badge(d)}</div><p class="ps-sub">${esc(d.os)} · 个人设备网络</p></div>${button(icon('copy')+' 复制 IP','copy','',`data-value="${d.ip}"`)}</div>${gatewayNotice()}<div class="ps-tabs" role="tablist" aria-label="设备详情"><button type="button" role="tab" id="ps-tab-overview" aria-controls="ps-detail-panel" aria-selected="${detailTab==='overview'}" data-action="tab" data-value="overview">连接概况</button><button type="button" role="tab" id="ps-tab-events" aria-controls="ps-detail-panel" aria-selected="${detailTab==='events'}" data-action="tab" data-value="events">操作记录 <span class="ps-small">${d.events.length}</span></button></div><div id="ps-detail-panel" role="tabpanel" aria-labelledby="ps-tab-${detailTab}">${detailTab==='events'?`<section class="ps-panel ps-pad"><h2>操作记录</h2>${[...d.events].reverse().map((ev,i)=>`<div class="ps-event"><span class="ps-small">${i===0?'最近':'此前'}</span><div>${esc(ev)}<div class="ps-small">演示记录</div></div></div>`).join('')}</section>`:`<div class="ps-columns"><div class="ps-stack"><section class="ps-panel ps-pad"><div class="ps-row ps-spread"><h2>连接信息</h2><button type="button" class="ps-link ps-small" data-action="edit">编辑名称 ${icon('pencil')}</button></div>${editing?`<form id="ps-edit-form" class="ps-edit-form"><input name="name" aria-label="设备名称" value="${esc(d.name)}" required maxlength="40"><button type="button" data-action="submit-form" data-form="ps-edit-form" class="ps-button ps-primary">保存</button><button type="button" class="ps-link" data-action="edit-cancel">取消</button></form>`:''}<dl><dt>固定私网 IP</dt><dd class="ps-mono">${d.ip}</dd><dt>最近活动</dt><dd>${status(d)==='unknown'?'采集已过期,暂时未知':d.last}</dd><dt>接入网关</dt><dd><button type="button" class="ps-link" style="padding:0" data-action="gateway">njcq ${icon('arrow-up-right')}</button></dd><dt>连接验证</dt><dd>${d.status==='pending'?'等待首次握手':d.status==='paused'?'已撤销网关接入':d.status==='error'?'网关配置未生效':'已验证首次握手'}<div class="ps-small">远程桌面可用性需在客户端单独确认</div></dd><dt>设备身份</dt><dd>此设备独立持有 <span class="ps-small">· 私钥不在列表展示</span></dd></dl><hr class="ps-line"><h3>当前流量</h3><div class="ps-usage"><div><span class="ps-small">设备上行</span><strong>${status(d)==='online'?d.up:'—'}</strong></div><div><span class="ps-small">设备下行</span><strong>${status(d)==='online'?d.down:'—'}</strong></div></div><p class="ps-small">${status(d)==='unknown'?'采集失联,已停止显示缓存速率。':'网关采样值 · 不代表 VNC 的画面码率'}</p></section>${compatibility(d)}</div><div class="ps-stack">${deviceConfigPanel(d)}<section class="ps-panel ps-pad"><h3>管理接入</h3><p class="ps-sub">暂停后,这台设备将无法通过私网通信。地址与记录保留。</p>${confirmAction?`<div class="ps-confirm" role="alert"><strong>${confirmAction==='pause'?'暂停 '+esc(d.name)+'?':confirmAction==='rotate'?'重置设备身份?':'移除设备?'}</strong><p style="margin-top:6px">${confirmAction==='pause'?'正在进行的私网连接会中断。需等网关确认撤销后,才会显示已暂停。':confirmAction==='rotate'?'旧配置会被撤销,此设备必须重新导入新配置。私网 IP 保持不变。':'先撤销网关接入,再归档设备。旧地址暂不分配给其他设备。'}</p><div class="ps-actions">${button('取消','cancel-confirm')}${button('确认'+(confirmAction==='pause'?'暂停':confirmAction==='rotate'?'重置':'移除'),'confirm','ps-danger')}</div></div>`:`<div class="ps-actions">${button(d.status==='paused'?icon('play')+' 恢复接入':icon('pause')+' 暂停接入',d.status==='paused'?'resume':'pause',d.status==='paused'?'':'ps-danger',['revoking','restoring','provisioning'].includes(d.status)?'disabled':'')}</div><div class="ps-row" style="margin-top:14px"><button type="button" class="ps-link ps-small" data-action="rotate">重置身份</button><span class="ps-small">·</span><button type="button" class="ps-link ps-small" data-action="remove">移除设备</button></div>`}</section></div></div>`}</div>`;}
function gatewayPage(){return `<button type="button" class="ps-link ps-back" data-action="devices">${icon('arrow-left')} 返回设备</button><div class="ps-heading"><div><h1>njcq 网关</h1><p class="ps-sub">个人设备网络的固定接入点</p></div><span class="ps-badge ${isStale()?'ps-amber':'ps-good'}">${isStale()?'采集失联':'运行正常'}</span></div><div class="ps-columns"><section class="ps-panel ps-pad"><h2>网关信息</h2><dl><dt>接入端点</dt><dd class="ps-mono">wg.example.net:51820 <span class="ps-small">(演示)</span></dd><dt>私网地址</dt><dd class="ps-mono">10.203.0.1/24</dd><dt>网络 MTU</dt><dd>1280</dd><dt>最近采集</dt><dd>${isStale()?'3 分钟前,已超时':'8 秒前'}</dd><dt>配置同步</dt><dd>${isStale()?'无法确认最新版本':'已确认运行与持久配置一致'}</dd><dt>纳管设备</dt><dd>${devices.length} 台</dd><dt>互访范围</dt><dd>10.203.0.0/24 · 设备间互访</dd></dl></section><div class="ps-stack">${gatewayNotice()||`<div class="ps-notice">${icon('check')}<div><strong>网关采集正常</strong><br>连接证据每 15 秒刷新。只有收到网关应用回执,操作才算生效。</div></div>`}<section class="ps-panel ps-pad"><h3>连接状态说明</h3><p class="ps-sub">近期在线:180 秒内有握手或流量增长。超过 45 秒没有采集,连接状态显示未知。</p><hr class="ps-line"><p class="ps-small">WireGuard 握手不能证明屏幕共享服务已开启,也不能给出端到端画面延迟和媒体丢包率。</p></section></div></div>`;}
function subscriptionsPage(){
const surge=subscriptionFormat==='surge';
const d=devices.find(x=>x.id===subscriptionDevice);
const attached=surge&&subscriptionDevice!==0;
const blocked=attached&&(!d||['paused','revoking','restoring','provisioning','error'].includes(d.status));
const formatNames={surge:'Surge',shadowrocket:'Shadowrocket 节点','shadowrocket-conf':'Shadowrocket 规则',clash:'Clash'};
const url=subscriptionUrl();
return `<div class="ps-heading"><div><h1>订阅</h1><p class="ps-sub">选择订阅格式,按需附加一台设备的 WireGuard 身份。</p></div><span class="ps-badge ps-good">原默认链接保留</span></div>
<section class="ps-panel ps-subscription">
<div class="ps-row ps-spread"><div><h2>Proxy Station</h2><p class="ps-small">同一份节点、策略组与规则 · 按设备生成 Surge 链接</p></div><span class="ps-badge ps-good">已启用</span></div>
<hr class="ps-line">
<div class="ps-sub-selectors">
<label class="ps-field"><span class="ps-label">订阅格式</span><select id="ps-sub-format" aria-label="订阅格式">${Object.entries(formatNames).map(([value,label])=>`<option value="${value}" ${subscriptionFormat===value?'selected':''}>${label}</option>`).join('')}</select></label>
${surge?`<label class="ps-field"><span class="ps-label">附加设备 WireGuard <span class="ps-small">(可选)</span></span><select id="ps-sub-device" aria-label="附加设备 WireGuard"><option value="0" ${subscriptionDevice===0?'selected':''}>不附加设备 · 纯代理订阅</option>${!d&&subscriptionDevice!==0?'<option selected disabled>所选设备已移除,请重新选择</option>':''}${devices.map(device=>`<option value="${device.id}" ${subscriptionDevice===device.id?'selected':''} ${['paused','revoking','restoring','provisioning','error'].includes(device.status)?'disabled':''}>${esc(device.name)} · ${device.ip} · ${labels[status(device)]}</option>`).join('')}</select></label>`:''}
</div>
${surge?`<p class="ps-small" style="margin-top:10px">设备来自「设备」列表。选择只影响当前生成的链接,不修改其他设备已保存的链接。</p>`:''}
<div class="ps-notice ${blocked?'ps-warning':''}" style="margin-top:20px" aria-live="polite">${icon(blocked?'circle-alert':attached?'network':'layers')}<div>${blocked?`<strong>此设备暂时不可生成接入配置</strong><br>${d?'请先恢复接入或处理配置异常,再选择此设备。':'设备已移除,原设备链接应停止下发配置。'}`:attached?`<strong>已附加 ${esc(d.name)} · <span class="ps-mono">${d.ip}</span></strong><br>保留原代理内容,同时包含此设备的私钥、地址与入站配置。此链接仅用于这台设备。`:`<strong>${surge?'纯代理订阅 · 不包含 WireGuard':'按原格式导出'}</strong><br>${surge?'只下发节点、策略组与规则,不附加设备地址或密钥。':'该格式保持原有导出行为,不附加本次设备身份。'}`}</div></div>
<div style="margin-top:22px"><div class="ps-row ps-spread"><h3>${attached?'设备专属订阅链接':'订阅链接'}</h3><span class="ps-small">示例地址 · 不含真实凭证</span></div><code id="ps-generated-url" class="ps-url ps-mono">${esc(url)}</code></div>
<div class="ps-actions">${button(icon('copy')+' 复制链接','copy-subscription','ps-primary',blocked?'disabled':'')}${button(icon('external-link')+' '+(surge?'导入 Surge':'导入客户端'),'import-subscription','',blocked?'disabled':'')}${surge&&subscriptionDevice?button('清除设备选择','clear-subscription-device'):''}${attached&&d?button('检查设备连接','check-selected-device','',blocked?'disabled':''):''}</div>
${surge?`<p class="ps-small" style="margin-top:12px">${attached?'清除设备选择后恢复原默认链接。每次更新仍按链接中绑定的设备生成配置。':'这就是原默认 Surge 地址。选择设备后,地址会带上专属接入凭证。'}</p>`:''}
<details style="margin-top:24px"><summary>查看下发内容结构</summary><p class="ps-small" style="margin-top:12px">结构预览,密钥已隐藏,不能作为真实配置导入。</p><pre id="ps-config-preview" class="ps-config-preview">${esc(subscriptionPreview())}</pre></details>
</section>
<div class="ps-footer"><span>交互原型 · 选项与链接均为演示</span><button type="button" class="ps-link ps-small" data-action="devices">管理设备 ${icon('arrow-right')}</button></div>`;
}
function startWizard(device){
wizard={step:device?2:1,id:device?.id||null,name:device?.name||'',os:device?.os||'iPadOS',incoming:true,busy:false,checking:false,verified:false,error:device?.status==='error'?'网关未能确认配置已生效。可重试同一设备任务。':'',fieldError:''};
page='wizard';render();
}
function delivery(w){
const d=devices.find(x=>x.id===w.id);if(!d)return '';
return `<div class="ps-row ps-spread"><div><h2>${esc(d.name)} 已创建</h2><p class="ps-sub">固定地址 <span class="ps-mono">${d.ip}</span> · 独立设备身份</p></div><span class="ps-badge ps-amber">等待首次连接</span></div>
<div class="ps-notice" style="margin-top:20px">${icon('check')}<div><strong>网关配置已生效</strong><br>接下来在 Surge 订阅中选择这台设备,或导出独立的 WireGuard 配置。</div></div>
<div class="ps-actions">${button(icon('link')+' 前往 Surge 订阅','use-surge-device','ps-primary',`data-id="${d.id}"`)}${button(icon('download')+' 导出独立 WireGuard 配置','export','',`data-id="${d.id}"`)}</div>
<hr class="ps-line"><p class="ps-small">添加设备不改变默认订阅。只有在 Surge 订阅中选择设备,生成的链接才包含它的 WireGuard 配置。</p>
<div class="ps-actions">${button('已完成导入,检查连接','to-verify')}${button('稍后接入','devices')}</div>`;
}
function wizardPage(){
const w=wizard;if(!w)return devicesPage();const d=devices.find(x=>x.id===w.id);
return `<div class="ps-wizard"><button type="button" class="ps-link ps-back" data-action="devices">${icon('arrow-left')} 返回设备</button>
<div class="ps-heading"><div><h1>${w.id?'设备接入':'接入新设备'}</h1><p class="ps-sub">设备管理身份与固定 IP,订阅页选择是否附加它。</p></div><span class="ps-demo">全流程模拟</span></div>
<div class="ps-steps">${['设备信息','获取配置','验证连接'].map((label,i)=>`<div class="ps-step ${w.step===i+1?'ps-current':''}"><span>${w.step>i+1?'✓':i+1}</span>${label}</div>`).join('')}</div>
<section class="ps-panel ps-pad">${w.step===1?`<h2>这是哪台设备?</h2><form id="ps-device-form" class="ps-form" style="margin-top:22px">
<label class="ps-field"><span class="ps-label">设备名称</span><input id="ps-new-name" name="name" aria-label="设备名称" placeholder="例如:我的 iPad Pro" value="${esc(w.name)}" maxlength="40" required autocomplete="off"></label>
<label class="ps-field"><span class="ps-label">操作系统</span><select id="ps-new-os" name="os" aria-label="操作系统">${['iPadOS','iOS','macOS','Windows','Linux'].map(os=>`<option ${os===w.os?'selected':''}>${os}</option>`).join('')}</select></label>
<div class="ps-notice">${icon('network')}<div><strong>加入「个人设备网络」</strong><br>自动生成独立密钥并分配固定 IP。通过 njcq 互访 10.203.0.0/24 中的设备。</div></div>
<details><summary>网络配置</summary><dl><dt>网络 MTU</dt><dd>1280</dd><dt>密钥</dt><dd>自动生成独立身份</dd><dt>入站访问</dt><dd>Surge 配置启用入站访问</dd></dl></details>
${w.fieldError?`<p class="ps-error ps-notice" role="alert">${esc(w.fieldError)}</p>`:''}
<div class="ps-wizard-foot"><span class="ps-small">网关确认后可在订阅中选择此设备</span><button type="button" data-action="submit-form" data-form="ps-device-form" class="ps-button ps-primary" ${w.busy?'disabled':''}>${w.busy?'正在配置网关…':'创建设备并继续'} ${icon('arrow-right')}</button></div></form>`:
w.step===2?w.error?`<div class="ps-notice ps-error" role="alert">${icon('circle-alert')}<div><strong>设备已保留,网关配置未成功</strong><br>${esc(w.error)}</div></div><dl><dt>设备</dt><dd>${esc(d.name)}</dd><dt>已预留 IP</dt><dd class="ps-mono">${d.ip}</dd><dt>重试方式</dt><dd>沿用同一身份与任务,不重复创建</dd></dl><div class="ps-actions">${button(w.busy?'正在重试…':'重试网关任务','retry','ps-primary',w.busy?'disabled':'')}${button('稍后继续','devices')}</div>`:delivery(w):
w.verified?`<div class="ps-success"><div class="ps-success-icon">${icon('check')}</div><h2>${esc(d.name)} 已完成接入</h2><p class="ps-sub">网关已观察到此设备的首次有效握手。</p><dl><dt>固定私网 IP</dt><dd class="ps-mono">${d.ip}</dd><dt>接入网关</dt><dd>njcq</dd><dt>连接验证</dt><dd>首次握手已确认 <span class="ps-small">(模拟)</span></dd></dl><div class="ps-notice" style="margin-top:23px">${icon('monitor')}<div>在远程桌面中填写目标设备的私网 IP。握手验证与远程桌面服务可用性分开确认。</div></div><div class="ps-actions">${button('查看设备详情','finish','ps-primary')}${button('返回设备列表','devices')}</div></div>`:
`<h2>等待首次连接</h2><p class="ps-sub">在这台设备启用已导入的隧道,再检查网关是否收到握手。</p><div class="ps-checking">${icon(w.checking?'loader-circle':'radio')}<div><strong>${w.checking?'正在等待网关确认…':'配置已就绪,设备尚未验证'}</strong><div class="ps-small">${d.ip} · ${esc(d.name)}</div></div></div>${isStale()?gatewayNotice():''}<p class="ps-small">生成订阅链接不代表设备在线。此原型会模拟收到首次握手。</p><div class="ps-actions">${button(w.checking?'正在检查…':'我已启用,检查连接','verify','ps-primary',w.checking||isStale()?'disabled':'')}${button('返回获取配置','install-back')}${button('稍后再验证','devices')}</div>`}
</section><div class="ps-footer"><span>交互原型 · 创建、导出、导入和握手均为模拟</span></div></div>`;
}
function subscriptionUrl(){
const base=`https://ps.example.net/sub/demo/${subscriptionFormat}`;
return subscriptionFormat==='surge'&&subscriptionDevice?`${base}?wg=demo-binding-${subscriptionDevice}`:base;
}
function subscriptionPreview(){
if(subscriptionFormat!=='surge')return '按所选格式输出原有节点或规则。\n不附加设备 WireGuard 身份。';
const d=devices.find(x=>x.id===subscriptionDevice);
const attached=subscriptionDevice!==0;
if(attached&&(!d||['paused','revoking','restoring','provisioning','error'].includes(d.status)))return '此设备不可用。正式接口应拒绝此设备链接,\n不会改为下发其他设备身份或静默降级为纯代理配置。';
return `#!MANAGED-CONFIG ${subscriptionUrl()} interval=86400
[Proxy]
# 原有代理节点
${attached?'NJCQ LAN = wireguard, section-name=njcq-lan\n':''}
[Proxy Group]
# 原有策略组;公网组排除 WG 节点
[Rule]
${attached?'IP-CIDR,10.203.0.0/24,NJCQ LAN,no-resolve\n':''}# 原有规则与 FINAL${attached?`\n
[WireGuard njcq-lan]
private-key = <${d.name} 的私钥,已隐藏>
self-ip = ${d.ip}
mtu = 1280
accept-incoming = true
peer = (public-key = <网关公钥>, preshared-key = <已隐藏>, allowed-ips = 10.203.0.0/24, endpoint = wg.example.net:51820, keepalive = 25)` : ''}`;
}
function exportPage(){
const d=devices.find(x=>x.id===selected);if(!d)return devicesPage();
const blocked=['paused','revoking','restoring','provisioning','error'].includes(d.status);
return `<div class="ps-wizard"><button type="button" class="ps-link ps-back" data-action="detail" data-id="${d.id}">${icon('arrow-left')} 返回设备详情</button>
<div class="ps-heading"><div><h1>导出 WireGuard 配置</h1><p class="ps-sub">${esc(d.name)} · <span class="ps-mono">${d.ip}</span></p></div>${badge(d)}</div>
<section class="ps-panel ps-pad"><h2>独立配置</h2><p class="ps-sub">用于标准 WireGuard 客户端,与代理订阅独立。同一设备的身份保持一致。</p>
${blocked?`<div class="ps-notice ps-warning" style="margin-top:18px">${icon('circle-alert')}<div>此设备当前不可领取接入配置,请先恢复接入或处理网关任务。</div></div>`:''}
<div class="ps-delivery"><div class="ps-qr-wrap"><div class="ps-qr"><img src="${demoQr}" alt="演示二维码,不含真实 WireGuard 密钥"></div><p class="ps-qr-label">演示二维码 · 无设备密钥</p></div><div><h3>下载或扫描导入</h3><ol class="ps-instructions"><li>在目标设备的 WireGuard 客户端添加隧道。</li><li>导入 .conf 文件,或扫描配置二维码。</li><li>启用隧道后,回到设备页检查连接。</li></ol>${button(icon('download')+' 下载 .conf','download','ps-primary',blocked?'disabled':'')}<p class="ps-small" style="margin-top:10px">此处仅演示,不下载真实私钥。</p></div></div>
<div class="ps-notice">${icon('shield')}<div>配置只属于 ${esc(d.name)}。请不要在另一台同时在线的设备上复用。</div></div>
<div class="ps-actions">${button('使用 Surge 订阅接入','use-surge-device','',`data-id="${d.id}" ${blocked?'disabled':''}`)}${button('检查设备连接','check-device','',`data-id="${d.id}" ${blocked?'disabled':''}`)}</div>
</section></div>`;
}
function deviceConfigPanel(d){
const blocked=['paused','revoking','restoring','provisioning','error'].includes(d.status);
return `<section class="ps-panel ps-pad"><h3>获取设备配置</h3><p class="ps-sub">在 Surge 订阅中附加此设备身份,或导出标准 WireGuard 配置。</p><div class="ps-actions">${button(icon('link')+' 用于 Surge 订阅','use-surge-device','ps-primary',`data-id="${d.id}" ${blocked?'disabled':''}`)}${button(icon('download')+' 导出独立 WireGuard 配置','export','',`data-id="${d.id}"`)}</div><hr class="ps-line"><p class="ps-small">订阅页不选择设备时,仍是干净的节点、策略组与规则订阅。导出的独立配置使用同一个设备身份。</p></section>`;
}
function render(){root.querySelectorAll('.ps-nav [data-action]').forEach(n=>{const active=n.dataset.action==='subscriptions'?page==='subscriptions':page!=='subscriptions';n.classList.toggle('ps-active',active);if(active)n.setAttribute('aria-current','page');else n.removeAttribute('aria-current');});main.innerHTML=page==='devices'?devicesPage():page==='detail'?detailsPage():page==='gateway'?gatewayPage():page==='subscriptions'?subscriptionsPage():page==='export'?exportPage():wizardPage();root.querySelectorAll('button,summary').forEach(el=>el.classList.add('cursor-interaction'));if(globalThis.lucide)lucide.createIcons({attrs:{width:16,height:16}});}
function event(d,text){d.events.push(text);if(d.events.length>15)d.events.shift();}
function applyAction(kind,deviceId=selected){
const d=devices.find(d=>d.id===deviceId);if(!d||activeJobs.has(deviceId))return;
confirmAction='';
if(!d.queuedAction){d.queuedAction=kind;d.status=kind==='resume'?'restoring':'revoking';event(d,'已请求'+({pause:'暂停',remove:'移除',rotate:'重置身份',resume:'恢复接入'}[kind])+',等待网关确认');}
render();
if(isStale()){toast('操作已排队,网关恢复并确认前不会标记成功。');save();return;}
activeJobs.add(deviceId);
setTimeout(()=>{
activeJobs.delete(deviceId);
const target=devices.find(x=>x.id===deviceId);if(!target)return;
if(isStale()){save();return;}
delete target.queuedAction;
if(kind==='remove'){devices=devices.filter(x=>x.id!==deviceId);if(page==='detail'&&selected===deviceId)page='devices';toast('模拟:网关撤销已确认,设备已归档。');}
else if(kind==='rotate'){target.status='pending';target.last='等待重新导入';event(target,'旧身份已撤销,新身份已应用,等待更新订阅或重新导入');toast('模拟:身份已重置,请更新设备订阅或重新导入独立配置。');}
else{target.status=kind==='pause'?'paused':'offline';target.last=kind==='pause'?'已撤销网关接入':'等待重新连接';event(target,kind==='pause'?'网关确认:peer 已从运行与持久配置移除':'网关确认:peer 已恢复,等待设备连接');toast(kind==='pause'?'模拟:网关确认撤销,此设备已暂停。':'模拟:接入已恢复,等待设备重新连接。');}
render();save();
},650);
}
function resumeQueued(){if(!isStale())devices.filter(d=>d.queuedAction).forEach(d=>applyAction(d.queuedAction,d.id));}
root.addEventListener('click',e=>{
const el=e.target.closest('button[data-action]');if(!el||el.disabled)return;
const a=el.dataset.action,v=el.dataset.value;
if(a==='submit-form'){const form=root.querySelector('#'+el.dataset.form);if(form.reportValidity())handleForm(form);return;}
const d=devices.find(x=>x.id===selected);
if(a==='copy'){copy(v);return;}
if(a==='copy-subscription'){copy(subscriptionUrl());return;}
if(a==='import-subscription'){toast('演示:将以当前链接导入'+(subscriptionFormat==='surge'?' Surge':'客户端')+'。不会修改真实客户端或将设备标为在线。');return;}
if(['devices','subscriptions','gateway'].includes(a)){page=a;confirmAction='';editing=false;render();save();return;}
if(a==='new'){startWizard();return;}
if(a==='detail'){selected=Number(el.dataset.id);page='detail';detailTab='overview';confirmAction='';editing=false;}
if(a==='filter')filter=v;
if(a==='tab')detailTab=v;
if(a==='edit')editing=true;
if(a==='edit-cancel')editing=false;
if(a==='continue'){selected=Number(el.dataset.id);startWizard(devices.find(x=>x.id===selected));return;}
if(a==='export'){selected=Number(el.dataset.id)||selected;page='export';}
if(a==='use-surge-device'){subscriptionDevice=Number(el.dataset.id)||selected;subscriptionFormat='surge';page='subscriptions';}
if(a==='clear-subscription-device')subscriptionDevice=0;
if(a==='check-selected-device'||a==='check-device'){
const target=devices.find(x=>x.id===(a==='check-selected-device'?subscriptionDevice:Number(el.dataset.id)));
if(target){selected=target.id;startWizard(target);wizard.step=3;render();save();}return;
}
if(['pause','rotate','remove'].includes(a))confirmAction=a;
if(a==='cancel-confirm')confirmAction='';
if(a==='confirm'){applyAction(confirmAction);return;}
if(a==='resume'){applyAction('resume');return;}
if(a==='download'){toast('演示:正式产品将下载此设备的独立 .conf;原型不含真实私钥。');return;}
if(a==='to-verify')wizard.step=3;
if(a==='install-back')wizard.step=2;
if(a==='finish'){selected=wizard.id;page='detail';detailTab='overview';}
if(a==='retry'){
const id=wizard.id;if(wizard.busy)return;wizard.busy=true;render();
setTimeout(()=>{const target=devices.find(x=>x.id===id);if(!target)return;target.status='pending';event(target,'重试成功:同一身份已在网关生效');design.scenario='正常采集';if(wizard?.id===id){wizard.busy=false;wizard.error='';}render();save();},700);return;
}
if(a==='verify'){
const id=wizard.id;if(wizard.checking||isStale())return;wizard.checking=true;render();
setTimeout(()=>{const target=devices.find(x=>x.id===id);if(!target)return;target.status='online';target.last='刚刚';target.up='12 Kbps';target.down='8 Kbps';event(target,'首次有效握手已确认(原型模拟)');if(wizard?.id===id){wizard.checking=false;wizard.verified=true;}render();save();},950);return;
}
render();save();
});
root.addEventListener('input',e=>{if(e.target.id==='ps-search'){query=e.target.value;root.querySelector('#ps-device-rows').innerHTML=listRows();if(globalThis.lucide)lucide.createIcons({attrs:{width:16,height:16}});save();}if(e.target.id==='ps-new-name')wizard.name=e.target.value;});
root.addEventListener('change',e=>{
if(e.target.id==='ps-new-os')wizard.os=e.target.value;
else if(e.target.id==='ps-sub-format'){subscriptionFormat=e.target.value;subscriptionDevice=0;}
else if(e.target.id==='ps-sub-device')subscriptionDevice=Number(e.target.value);
else return;
render();save();
});
function handleForm(form){if(form.id==='ps-edit-form'){const name=new FormData(form).get('name').trim();if(!name)return;const d=devices.find(x=>x.id===selected);d.name=name;event(d,'更新设备名称');editing=false;render();save();return;}if(form.id!=='ps-device-form'||wizard.busy)return;const w=wizard;w.name=w.name.trim();w.fieldError='';if(!w.name){w.fieldError='请填写设备名称。';render();return;}if(devices.some(d=>d.name===w.name)){w.fieldError='已有同名设备,请用位置或用途区分。';render();return;}if(devices.length>=20){w.fieldError='演示最多创建 20 台设备。';render();return;}w.busy=true;render();setTimeout(()=>{const maxOctet=addressWatermark++;const id=maxOctet;const failure=design.scenario==='配置下发失败'||isStale();const d={id,name:w.name,os:w.os,ip:'10.203.0.'+(maxOctet+1),incoming:w.incoming,status:failure?'error':'pending',last:'尚未连接',up:'—',down:'—',events:['已创建独立身份并预留 IP',failure?'网关未确认配置,等待重试':'网关确认运行与持久配置已生效']};devices.push(d);w.id=id;w.busy=false;w.step=2;if(failure)w.error=isStale()?'网关采集失联,暂时无法确认应用结果。':'配置同步超时。设备身份和预留地址已保留,可安全重试。';render();save();},750);}
root.addEventListener('submit',e=>{e.preventDefault();handleForm(e.target);});
root.addEventListener('keydown',e=>{const form=e.target.closest('form');if(e.key==='Enter'&&form&&e.target.tagName!=='BUTTON'){e.preventDefault();if(form.reportValidity())handleForm(form);}});
render();resumeQueued();
if(globalThis.Tweak){const tweak=new Tweak({container:root,onChange:()=>{render();save();resumeQueued();}});tweak.addSelect(design,'scenario',{label:'网关与接入情境',options:['正常采集','网关失联','配置下发失败']});}
})();
</script>
</div>