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

@@ -0,0 +1,171 @@
# WireGuard 设备管理与 Surge 按设备订阅
状态:已确认产品设计与原型,2026-11-02。已完成本地实现,具体接口、迁移和验收见 [实现记录](../../WIREGUARD-DEVICES.md);现已部署 LA 应用与 njcq agent,未切换活动客户端;原有设备只读纳管与凭证端点已通过,用户同日随后已确认 macOS/iPadOS/iOS 的 Surge + WireGuard 全面实测可工作,停止 Shadowrocket WG 下发兼容;生命周期与长期网络场景不据此外推。
开始开发请先读 [实现交接](HANDOFF.md),其中列出已有代码、实现缺口、真实配置位置与历史验证边界。
## 产品决策
设备页独立管理 WireGuard 身份、固定 IP、状态和网络生命周期。设备模型不记录“使用客户端”,创建和导出时也不要求选择客户端。当前个人设备统一使用支持 WireGuard 与入站的 Surge,仍保留标准 WireGuard 独立配置导出。
订阅页选择 Surge 格式后,提供“附加设备 WireGuard(可选)”下拉框,数据来自设备管理列表:
| 选择 | 下发配置 | 链接行为 |
|---|---|---|
| 不附加设备 | 干净的代理节点、策略组与规则,无设备 WG 身份 | 使用原默认 Surge URL,逐字保持不变 |
| 选择某台设备 | 原代理内容 + 此设备独立 WG 身份、私网规则和入站设置 | 基于原入口生成带设备凭证的派生 URL |
| 清除选择 | 恢复纯代理配置 | 恢复原默认 URL |
**选择状态必须体现在生成链接的凭证中,不能保存在服务端的全局“当前设备”变量。** 否则 Mac 选择设备后会改变 iPad 下次刷新所得到的身份。
本方案统一的是订阅入口与节点、策略、规则来源,不承诺包含不同私钥的各设备使用逐字相同的完整 URL。设备专属链接需在对应设备导入一次,之后每次更新仍包含同一设备身份。不再需要额外身份模块、客户端选择或复制私钥。
## 信息架构
主导航沿用:节点 / 策略组 / 规则 / **设备** / **订阅** / 设置。
设备页保留现有列表与操作:名称、系统、固定私网 IP、状态、最后活动、上行/下行、详情、暂停/恢复、重置身份与移除。显示方向以设备为准,上行为设备到网关,下行为网关到设备。窄屏沿用设备条目布局。
设备详情增加两个清晰入口:“用于 Surge 订阅”和“导出独立 WireGuard 配置”。前者打开订阅页并预选此设备,后者打开独立导出页。两种方式引用同一设备身份,不另分配 IP 或密钥。
设备信息中删除客户端名称与兼容性分支。系统字段继续用于识别设备,不作为私钥领取认证凭据。
## 添加设备
1. 填写名称、系统。网关、网络、MTU、IP 和密钥自动处理。创建前展示默认设备互访范围。
2. 事务内预留唯一 IP 和独立 keypair/PSK,创建幂等网关任务。只有网关确认运行配置和持久配置生效后,才显示“待接入”。失败保留同一设备和 IP,重试不重复创建。
3. 创建完成后可选“前往 Surge 订阅”或“导出独立 WireGuard 配置”。也可稍后从设备列表继续。
4. 导入后单独检查首次握手。生成、复制链接、下载配置或点击导入均不直接变为在线。
第一版使用现有 njcq 网关、10.203.0.0/24、MTU 1280,保留现有 iPad .2 与 Mac .3。网关只转发个人私网互访,不新增 Internet 出口或 NAT。
## 订阅交互
订阅卡片保留当前节点范围与启用状态。选择格式为 Surge 后显示设备下拉框,默认“不附加设备 · 纯代理订阅”。选项包含名称、私网 IP 和当前状态;离线设备可以选择,首次未握手但网关已生效的设备也可以选择。
已暂停、正在撤销、正在恢复、配置中、配置异常的设备仍在列表显示,但不可新选择生成接入链接。采集失联不等于身份被暂停,不因遥测未知就错误撤销既有权限。
选择设备后,页面就地更新:
- 明确显示所选设备和 IP,以及该链接仅供此设备使用。
- 链接包含对应的受控设备凭证;“复制链接”和“导入 Surge”使用相同的当前 URL。
- 保留“清除设备选择”,随时返回原默认 URL。
- 提供配置结构预览,私钥/PSK 始终遮蔽,不把真实密钥放进普通 DOM、浏览器持久化状态或原型。
- 提供“检查设备连接”,回到所选设备的真实握手验证。
切换其他订阅格式时清除设备附加选择;其他格式保持原行为,不静默继承 Surge 的个人身份。清除选择只改变当前导出的链接,不撤销设备,不修改其他设备已导入的链接,也不覆盖同一订阅 token 的默认设置。
如果选择的设备随后被暂停或移除,界面明确显示不可下发,禁用复制/导入。旧设备 URL 在服务端也应拒绝,不能默默返回纯代理配置,更不能换成另一台设备身份。
## 链接、授权与生成规则
示意入口:
```text
纯代理:/sub/<原订阅 token>/surge
附加设备:/sub/<原订阅 token>/surge?wg=<不可枚举的设备分发凭证>
```
`wg` 不是裸设备 ID、名称或 IP。共享代理订阅 token 的持有者不能通过枚举 `?device=1/2/3` 获取任意设备私钥。
管理员在订阅页选择设备后,由已认证的管理 API 签发或复用绑定“订阅 + 设备 + 导出用途”的高熵分发凭证。数据库保存凭证哈希及作用域,可单独撤销。服务端校验原订阅 token 和设备凭证,确认二者绑定关系、设备仍存在且允许接入,再附加身份。
此凭证是能读取该设备私密配置的长期能力凭证,不能作为无敏感性的普通分享链接。网页不必展示内部 token 管理细节,但要说明链接只能用于所选设备。导入地址和凭证不写入公开日志、统计或 Referer;敏感响应 no-store,缓存不得跨设备串用。
首次跨设备分享可使用短期领取票据,兑换后获取该设备长期订阅链接。短期票据不直接充当托管配置的长期更新 URL,避免过期后自动刷新失效。GET 预览不消耗领取会话,由显式确认的 POST 原子绑定;并发最多一个会话成功,允许同一领取会话短时重试。
**带设备配置的 `#!MANAGED-CONFIG` 必须写入完整的带 `wg` URL。** 如果仍写原纯代理 URL,下次自动更新就会丢掉 WG 身份。原默认纯代理配置的 header 继续写原 URL。
生成流水线遵循:原订阅节点权限 → 原代理 IR → 通过授权的一个设备身份 → Surge 完整配置。用户原有公网代理选择与策略名称尽量保持不变;设备 WG 节点不进入 Automatic、All Nodes、AI 等公网代理池,只供专用私网策略引用。
纯代理响应不包含 `[WireGuard ...]`、WG 代理声明、WG 私网路由、设备地址、私钥或 PSK。附加响应只包含被授权的一台设备,不包含设备列表或其他设备身份。入站按用户当前 Surge 能力启用,具体字段与平台行为须通过目标版本验收。
## 独立 WireGuard 导出
设备详情继续提供二维码与 `.conf` 下载。配置引用同一 device keypair、PSK 和地址;导出不创建另一个 peer,不修改代理订阅,也不因生成配置就标记在线。
普通列表 DTO 不返回私钥、PSK、完整配置或长期分发凭证。导出/领取使用独立授权接口,正式二维码在本地或受控服务生成,不调用在线二维码服务。暂停或网关任务未确认时不继续签发接入配置。
原型二维码为无密钥的演示载荷,下载与导入仅显示模拟结果。
## 状态与操作
状态仍由期望生命周期、网关应用回执、连接遥测三部分计算。
| 状态 | 判定与行为 |
|---|---|
| 配置中 | 已创建身份,网关尚未确认 |
| 待接入 | peer 已生效,从未出现有效握手 |
| 近期在线 | 新鲜采样且 180 秒内有握手或流量增长 |
| 离线 | 采集正常,已连接过,但超出活跃阈值;不代表设备电源关闭 |
| 状态未知 | 采集超过 45 秒未更新,展示最后采集时间并隐藏实时速率 |
| 正在暂停 | 已请求移除 peer,尚未收到确认,可能仍可通信 |
| 已暂停 | 网关运行配置与持久配置均确认移除 peer,记录与 IP 保留 |
| 配置异常 | 网关应用失败或配置冲突,保留幂等任务重试 |
网关每 15 秒上报,页面 15 秒刷新;使用相对握手年龄和中心接收时间处理时钟偏差。计数周期重置时重新建立速率基线,不显示负流量;累计用量按纳管后的有效增量持久记录。
WireGuard 数据不直接证明 VNC 健康、端到端画面延迟或媒体丢包。地址漫游也不直接判定身份被盗用。
- 改名只修改元数据;设备凭证绑定 ID,不随名称变化失效。
- 暂停必须在网关移除 peer 并持久化;仅停用订阅链接不能撤销已导入的网络身份。
- 恢复在网关确认 peer 重新生效后允许连接。
- 重置身份先撤销旧 key 再生效新 key,IP 保留。既有未泄露的设备订阅可以继续通过刷新获取新身份,独立 `.conf` 需重新导入。若泄露的是分发链接,另行撤销并重新签发其凭证。
- 移除先撤销 peer,确认后归档、撤销所有绑定凭证。IP 不立即复用。被移除设备的旧 URL 拒绝下发,不能指向后续设备。
## 数据模型与接口
复用上一版设备生命周期实体:`wg_networks`、`wg_gateways`、`wg_devices`、`wg_jobs`、`wg_observations`、用量聚合、`wg_events`、短期 `wg_enrollments`。
设备实体包含名称、系统、固定 IP、公钥、加密存储的私钥/PSK、期望状态和应用版本,**不包含客户端选择**。增加 `wg_subscription_bindings`,保存订阅 ID、设备 ID、分发凭证哈希、用途、创建/访问/撤销时间。它是订阅与设备的受控关系,不是把个人身份写回公共订阅。
建议管理接口:
- `GET/POST /api/wg/devices`、`GET/PATCH /api/wg/devices/:id`。
- `POST /api/wg/devices/:id/actions`、`GET /api/wg/jobs/:id`。
- `GET /api/wg/gateways`。
- `POST /api/tokens/:id/wg-bindings`:沿用现有 `/api/tokens` 资源命名,校验设备权限,签发/复用当前组合凭证;选择变化时不改默认订阅。接口已部署,生产验证见实现记录。
- `POST /api/wg/devices/:id/exports`:授权领取标准配置或生成短期接入票据。
- 公开受凭证保护的拉取入口为原 `/sub/:token/surge` 加可选 `wg`;签名错误、跨订阅使用、暂停或归档明确拒绝,不降级泄露或换身份。
默认托管模式由服务端生成独立密钥、加密保存,再通过授权的 HTTPS 分发。不能声称私钥永不离开设备;若未来增加设备端生成,需要单独实现客户端公钥登记流程。
网关 agent 使用独立认证主动 HTTPS 上报,不持有全站管理员 token。受限 helper 只操作固定 WG 接口,不接受任意 shell。配置更新原子持久化、幂等重试、合并受管 peer,避免重启隧道或删除非受管设备。
## 迁移与验收
先备份,再按网关公钥与客户端公钥纳管现有 Mac/iPad,保留 key、PSK、IP、MTU 和现有连接。只读采集先行,不因平台迁移重建 peer。
现有独立 WG token/旧导出 URL 保留明确的兼容映射,避免立即中断已安装设备;默认共享 URL 保持纯代理。迁移到新方式时,在同一个订阅页选择对应设备,导入带凭证的派生 URL。默认公共地址不失效,但需要 WG 身份的设备托管地址会增加设备参数,这是本设计的明确行为。
实施顺序:只读纳管与采集 → 新增/暂停/恢复/独立导出 → 订阅设备绑定与 Surge 生成 → 三端更新、入站与跨 ISP 远程桌面验收。不因原型完成而声称全部客户端媒体能力已验收。
关键验收:
1. 未选设备时响应完全无 WG,原默认 URL 保持不变。
2. Mac、iPad、iPhone 的派生 URL 各只包含本机身份;选择 B 不改变已生成的 A 链接。
3. 没有私密设备凭证、篡改设备 ID、跨订阅复用凭证均不能读到私钥。
4. 托管 header 保留完整设备 URL,更新/重启后不丢 WG;清除网页选择不撤销已有设备链接。
5. 网关确认前不假成功,失败重试不重复创建,暂停后真实网络不可达,采集失联不显示旧在线/速率。
6. 独立导出与 Surge 引用同一身份;设备暂停、移除、重置与失效链接行为一致。
7. 现有设备不断线纳管;原公网分流行为不因附加 WG 扩大或改变。
## 可点击原型与验证
交互源文件:[wg-device-management.html](wg-device-management.html)。默认打开订阅页,可进入设备列表和详情。所有 URL、设备状态、导出、导入和握手为演示,不发生产请求。
主要体验路径:Surge 订阅 → 选择设备 → 查看带身份链接及结构 → 清除选择恢复纯代理。设备页可新增、详情查看、复制 IP、暂停/恢复、重置/移除及导出独立 WG 配置。
本次浏览器实际检查通过:
- 未选设备、选择 Mac、选择 iPad、清除选择,以及切换非 Surge 格式后的链接与配置结构变化。
- 选中 Mac 的结构仅包含 .3,选中 iPad 仅包含 .2;托管更新 header 保留当前带设备凭证的完整 URL。
- 新增设备不再出现客户端选择;创建后即出现在订阅下拉中,未握手仍为待接入;模拟握手后才显示在线。
- 从设备详情导出独立 WG 配置保留同一 IP;暂停后禁止继续生成接入链接。
- 移除已选设备后旧链接保持明确不可用,清除选择才回到纯代理;没有自动切换身份或静默降级。
- 订阅页与独立导出页在 320、360、736、1024 宽度均无横向溢出,深色外观可用。
- JavaScript 语法、片段结构与无网络请求检查通过;最终浏览器检查无控制台错误。
以上仅验证原型交互与演示结构。真实凭证授权、网关生命周期和三端订阅结果属于实施验收。
参考:[Surge WireGuard](https://manual.nssurge.com/policies/wireguard.html)、[托管配置更新](https://manual.nssurge.com/profile/managed-profile.html)、[WireGuard 工具与状态](https://git.zx2c4.com/wireguard-tools/about/src/man/wg.8)、[现有组网记录](../../WIREGUARD-LAN.md)。

View File

@@ -0,0 +1,99 @@
# WireGuard 设备管理实现交接
更新:2026-11-02。下述最初缺口已完成本地实现,包含设备加密存储/旧库迁移、网关 agent/回执、设备 UI 和订阅绑定。当前接续请先读 [实现与上线验收](../../WIREGUARD-DEVICES.md)。已部署 LA 应用与 njcq agent,原有两台设备只读纳管已完成;未刷新活动客户端;下方“已有能力与实现缺口”保留为实施前基线。
## 开始实现前先读
1. [完整产品与技术设计](DESIGN.md):最新需求与验收标准的唯一方案入口。
2. [可点击原型](wg-device-management.html):设备列表、详情、独立导出,以及订阅页选择设备。可直接用浏览器打开;在对话内也可交互。数据、链接、二维码与握手均为演示。
3. [既有 WG 实现与验证记录](../../WIREGUARD-LAN.md):2026-11-01 已落地的设备专用 token 模型、njcq 配置和历史验收,不代表新设备管理功能已实现。
4. 根目录 [AGENTS.md](../../../AGENTS.md):仓库修改、验证、凭据、部署与线上数据操作约定。
## 用户最终确认的交互
- 个人 macOS、iPadOS、iOS 设备统一使用支持 WireGuard 与入站的 Surge。用户同日随后已在三端全面实测 Surge + WireGuard 可工作,并要求停止 Shadowrocket WG 下发兼容;实际网络/版本/生命周期的具体证据范围仍以用户回报和实现记录为准。
- 保留设备列表、私网 IP、状态、最后活动、上下行、管理操作及独立 WireGuard `.conf` / 二维码导出。
- 去掉“使用客户端”,不按 Surge / Shadowrocket / 原生 WG 分支创建不同设备身份。
- 在订阅页选 Surge 后,增加可清除的设备下拉框,来源为设备列表。
- 未选设备:原默认 Surge URL 不变,配置只含原节点、策略组和规则,无 WG 配置项。
- 选中设备:基于同一订阅入口生成带设备分发凭证的派生 URL,原代理内容加该设备的 WG 配置。不同设备完整 URL 可以不同;它们共享节点与规则来源。
- 清除选择:恢复原默认纯代理 URL。只影响当前导出的链接,不修改其他设备的既有链接,不撤销 peer。
- 新方案不安装身份模块,也不再靠新建一条独立订阅卡片表达设备。初次迁移保留旧设备专用 token;用户同日后续要求仅保留一个订阅,生产已退休两条旧设备卡片,设备通过原主订阅附加。详见实现记录。
**选择某设备不能修改服务端全局状态或覆盖共享 token 的 wireguard 字段。** 已生成的 A 设备 URL 必须继续获取 A 身份,不能随另一个浏览器选择 B 而变化。
## 已有能力与实现缺口(实施前基线)
| 部分 | 当前代码 | 后续工作 |
|---|---|---|
| 身份存储 | `sub_tokens.wireguard` 可空 JSON,真实密钥由当前管理员 DTO 返回 | 建立独立设备实体、加密密钥存储和不含私钥的列表 DTO;迁移兼容旧 token |
| 订阅生成 | `irFor()` 附加当前 token 的身份,Surge/WG 序列化器已可用 | 将身份来源改为经过授权的设备绑定;无选择保持纯代理 |
| 自更新地址 | Surge 路由的 `selfUrl` 当前没有查询参数 | 保留经过验证的设备凭证参数,否则下一次自动刷新丢 WG |
| 独立导出 | 标准 `.conf` 与 Shadowrocket WG YAML 已有生成器 | 标准导出改为设备授权入口,复用同一身份,不再额外创建 peer |
| 设备 UI | 当前只有订阅卡片附带 WG 地址/链接 | 新增设备页、详情、状态与接入流程;订阅页新增设备下拉框 |
| 网关生命周期 | 一次性安装脚本预置两台设备 | 独立网关采集、幂等 peer 应用/撤销、运行与持久配置回执;不是只操作下载 token |
| `?wg=...` | 当前订阅路由尚未解析这一参数 | 实现高熵、不可枚举、可撤销、绑定订阅和设备的分发凭证 |
上述表格为实施前的代码基线,已由本地新实现补齐;最新路径和行为见实现记录。原“当前代码”来自本地源码核对,不是本次重新读取生产 API 或远端配置的结果。工作区有其他未提交的 FFSB、规则、生成器等变更,需保留并按文件审阅,不用整目录覆盖或回滚。
## 代码定位
| 路径 | 作用 |
|---|---|
| `shared/src/wireguard.ts` | WireguardProfile、私网/密钥/MTU 校验;当前 MTU 默认 1280、上限 1420 |
| `shared/src/dto.ts` | 当前 TokenDto 与导出 URL,后续拆分设备元数据和私密领取 |
| `server/src/db/schema.ts`、`server/src/db/index.ts` | 当前 token 身份字段与旧库补列;新增表和迁移同时更新 |
| `server/src/routes/tokens.ts` | 当前 `/api/tokens` CRUD、重复身份检查、URL 和 DTO |
| `server/src/routes/sub.ts` | token 校验、`irFor()`、标准 WG 导出、Surge 路由与 selfUrl;新增绑定鉴权的关键位置 |
| `server/src/services/ir.ts` | 原代理 IR 与可选 WG 身份类型,保留节点权限范围 |
| `server/src/services/generators/wireguard.ts` | 标准 WG、Shadowrocket WG、Surge WireGuard 段落生成 |
| `server/src/services/generators/surge.ts` | WG 代理声明、私网规则、自地址 DIRECT、托管 header 与公网组排除 |
| `server/src/routes/wireguard.test.ts` | 当前 WG token、迁移、隔离与配置行为测试,可扩展新绑定测试 |
| `web/src/pages/SubscriptionsPage.vue` | 当前 WG 仍作为 token 卡片额外导出;实现设备下拉与清除操作 |
| `web/src/App.vue`、`web/src/router.ts` | 增加“设备”导航和受鉴权页面 |
| `.claude/skills/edge-node-multi-protocol/scripts/17-install-wireguard.sh` | 既有 njcq 安装编排;目前不是通用设备地址分配器或网关 agent |
管理资源沿用 `/api/tokens`。本地已实现 `POST /api/tokens/:id/wg-bindings`,其余设备、任务、网关与导出 API 见 [实现记录](../../WIREGUARD-DEVICES.md),已发布到生产,设备侧新导入/入站仍须验收。
## njcq 与设备身份基线
最后记录的服务为 Linux 内核 WireGuard,接口 `wg-njcq`,UDP 51820,网关 `10.203.0.1/24`。正式登记为 iPad Pro `10.203.0.2`、Mac Studio `10.203.0.3`,独立 keypair/PSK,MTU 已从临时测试恢复 1280。只提供 `10.203.0.0/24` 设备互访,不做 Internet NAT。
原型中 MacBook Air、iPhone、Windows/Linux 等额外设备、速率、状态、最后活动以及示例地址均不是正式登记清单,不能拿去初始化生产。iPhone 是否已有独立 peer 须在实施前核对,不能复用 iPad 身份。
2026-11-01 已验证:Mac Surge 6.11 入站及经 hub 访问本机 VNC;iPad 官方 WG → njcq → Mac Surge 的 Apple VNC 成功。Shadowrocket 普通 VNC 可用但 Apple VNC 黑屏,具体实现根因尚未确认。MTU 1420 不是已证实修复,临时修改与监测已恢复/结束。
用户后来统一三端为 Surge,因此 Shadowrocket 后续 TUN-only 等排查留作历史材料,不是本次设备管理的实现前提。Surge 三端安装后的自动更新、入站与 Apple VNC 仍需单独记录实际验收,不能用普通握手代替媒体可用性验证。
## 私有资料在哪里
仅列路径,不复制凭据。真实内容仍留在本机 Git 忽略的 `edge-nodes/private/njcq-edge-node-docs/`:
- `README.md`:真实交付入口、订阅地址和备份说明。
- `subscriptions.json`:既有两个设备 token、WG 身份与 URL 的交付记录,实施迁移前应与线上重新比对。
- `import/njcq-ipad.conf`、`import/njcq-ipad-qr.png`:原生客户端的成功对照产物。
- `import/export-summary.json`:导出摘要。
上述真实配置/订阅文件已核对存在、权限 600;父目录应维持 700。不得把真实订阅 URL、私钥、PSK、二维码或完整配置放入公共设计文档、普通日志或 Git。新克隆只携带公共源码和方案,不会自动携带这些私有交付记录。
njcq 远端配置在 `/etc/wireguard/`,备份索引见 [WG 记录](../../WIREGUARD-LAN.md)。需要操作生产时按仓库技能重新读取、备份和验证,不以历史交付数据覆盖最新配置。
## 实现时必须守住的边界
1. 设备凭证不能是裸 ID/IP;普通共享订阅 token 不得通过枚举设备获得私钥。绑定校验订阅、设备、用途和撤销状态,密钥响应 no-store,日志与缓存按敏感数据处理。
2. `#!MANAGED-CONFIG` 使用完整带设备参数的自身 URL,仅保留受支持且已验证的参数。无设备时继续原 URL。
3. 公网节点、策略组、DNS、FFSB 手动分流和其他组选择保持原范围;WG 不被 Automatic/All Nodes 等选成 Internet 出口。
4. 首次握手是连接证据,点击生成/复制/导入不是在线证据。采集过期显示未知,不使用缓存速率冒充实时值。
5. 暂停必须真正移除网关 peer 并持久化;停用分发凭证不能撤销已下载的配置。移除后旧链接拒绝,不静默降级或换身份,设备 ID/IP 不立即复用。
6. 幂等任务确认前不报成功;采用原子配置合并,保留非受管 peer 和现有 Tailscale、Docker 等共机服务。
7. 本仓库另有 [FFSB 原生 WG 试验](../../FFSB-WIREGUARD-SURGE-2026-11-02.md),是第三方服务的不同身份/网段,不能合并进 njcq 私网设备或自动激活试验配置。其当前恢复状态以该文档与 AGENTS.md 为准。
## 实施与验收顺序
先纳管现有两台设备并只读采集;再做新增/暂停/恢复和独立导出;最后实现订阅设备绑定、Surge 配置生成与三端实际导入。迁移保留现有 key、PSK、IP 和旧链接兼容,不为了新 UI 重建所有 peer。
测试应覆盖默认无 WG、各设备私钥隔离、非法/跨订阅凭证、selfUrl 保留设备参数、设备暂停/归档、旧库迁移、失败幂等重试、采集失联、真实撤销以及节点范围/FINAL 不变。含密钥的 fixture 使用占位值,数据库验证在模块加载前设置隔离 DATA_DIR,不能测试真实 server/data。
验证命令依改动执行根目录 `AGENTS.md` 的 typecheck、相关 Vitest 与前端构建。Surge 产物先核对下载成功/结构,再用官方 CLI;CLI 通过仍需 macOS、iPadOS、iOS 的真实连接验收。生产部署和远端写入按后续用户授权执行。
本次交接保留了上一轮原型验证记录:设备选择/清除与配置结构、自更新 URL、独立导出、新增/暂停/移除、320–1024 宽度和深色外观。它们是原型验收,不是待实现功能的生产验收。

View File

@@ -0,0 +1,249 @@
<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>