@walkhi/code-relax 0.1.0-beta.27 → 0.1.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +80 -67
  2. package/dist/bin/codex-remote.mjs +303 -239
  3. package/dist/bin/self-relay-server.mjs +2 -0
  4. package/dist/shared/app-server-events.cjs +102 -16
  5. package/dist/shared/code-browser.cjs +159 -0
  6. package/dist/shared/file-changes.cjs +31 -0
  7. package/dist/shared/file-citations.cjs +117 -82
  8. package/dist/shared/file-mentions.cjs +80 -35
  9. package/dist/shared/message-delivery.cjs +38 -0
  10. package/dist/shared/p2p-file-channel.cjs +74 -0
  11. package/dist/shared/server-requests.cjs +149 -41
  12. package/dist/shared/service-endpoints.cjs +11 -11
  13. package/dist/shared/timeline-sync.cjs +173 -156
  14. package/dist/skills/code-relax/SKILL.md +2 -1
  15. package/dist/src/bounded-process-log.mjs +63 -0
  16. package/dist/src/celsius-client.mjs +107 -0
  17. package/dist/src/code-browser.mjs +101 -0
  18. package/dist/src/desktop-launcher.mjs +30 -30
  19. package/dist/src/dots-approval-timeline.mjs +99 -0
  20. package/dist/src/dots-avatar.mjs +42 -0
  21. package/dist/src/dots-chat-projection.mjs +50 -0
  22. package/dist/src/dots-chat-server.mjs +412 -0
  23. package/dist/src/dots-message-images.mjs +319 -0
  24. package/dist/src/dots-profile.mjs +77 -0
  25. package/dist/src/dots-room-activity.mjs +94 -0
  26. package/dist/src/dots-room-client.mjs +135 -0
  27. package/dist/src/dots-room-projection.mjs +94 -0
  28. package/dist/src/dots-schedules.mjs +66 -0
  29. package/dist/src/dots-summary-tasks.mjs +32 -0
  30. package/dist/src/dots-task-catalog.mjs +411 -0
  31. package/dist/src/file-transfer-range.mjs +22 -0
  32. package/dist/src/file-upload-session.mjs +130 -124
  33. package/dist/src/hosted-history-app-server.mjs +103 -0
  34. package/dist/src/hosted-history-cache.mjs +113 -0
  35. package/dist/src/lan-socket-server.mjs +205 -160
  36. package/dist/src/managed-app-server.mjs +241 -238
  37. package/dist/src/message-upload-files.mjs +74 -0
  38. package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
  39. package/dist/src/platform/windows/codex-runtime.mjs +253 -251
  40. package/dist/src/platform/windows/desktop-host.mjs +38 -32
  41. package/dist/src/platform/windows/desktop-info.ps1 +2 -2
  42. package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
  43. package/dist/src/platform/windows/dots-profile.mjs +49 -0
  44. package/dist/src/platform/windows/launch-worker.mjs +41 -42
  45. package/dist/src/platform/windows/project-locations.mjs +16 -0
  46. package/dist/src/project-create.mjs +119 -0
  47. package/dist/src/project-management.mjs +69 -0
  48. package/dist/src/self-relay/account-connections.mjs +52 -0
  49. package/dist/src/self-relay/admin-page.mjs +80 -80
  50. package/dist/src/self-relay/admin-style.mjs +14 -9
  51. package/dist/src/self-relay/connector.mjs +23 -5
  52. package/dist/src/self-relay/email-accounts.mjs +35 -10
  53. package/dist/src/self-relay/file-wire.mjs +13 -0
  54. package/dist/src/self-relay/lifecycle.mjs +22 -4
  55. package/dist/src/self-relay/long-pairing.mjs +40 -0
  56. package/dist/src/self-relay/p2p-probe.mjs +152 -123
  57. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
  58. package/dist/src/self-relay/server.mjs +922 -849
  59. package/dist/src/server.mjs +474 -199
  60. package/dist/src/service-control.mjs +13 -3
  61. package/dist/src/service-doctor.mjs +72 -61
  62. package/dist/src/service-lifecycle.mjs +51 -43
  63. package/dist/src/shared-app-server.mjs +166 -107
  64. package/dist/src/shared-catalog.mjs +132 -40
  65. package/dist/src/shared-thread-stream.mjs +103 -25
  66. package/dist/src/usage-reset.mjs +17 -0
  67. package/dist/src/workstation-downloads.mjs +120 -0
  68. package/package.json +1 -1
@@ -0,0 +1,52 @@
1
+ import { digest } from './wire.mjs';
2
+
3
+ // Register before the asynchronous database check so logout also invalidates
4
+ // handshakes whose successful authentication response has not arrived yet.
5
+ export function accountConnections() {
6
+ const sessions = new Map();
7
+ return {
8
+ track(token, onInvalid) {
9
+ const key = digest(token);
10
+ const entries = sessions.get(key) || new Set();
11
+ sessions.set(key, entries);
12
+ let active = true, expires = Infinity, timer;
13
+ function dispose() {
14
+ active = false;
15
+ clearTimeout(timer);
16
+ entries.delete(entry);
17
+ if (!entries.size && sessions.get(key) === entries) sessions.delete(key);
18
+ }
19
+ function invalidate() {
20
+ if (!active) return;
21
+ dispose();
22
+ onInvalid();
23
+ }
24
+ function valid() {
25
+ if (active && Date.now() >= expires) invalidate();
26
+ return active;
27
+ }
28
+ function schedule() {
29
+ if (!valid()) return;
30
+ // Login lasts 30 days, longer than Node's maximum timer delay.
31
+ timer = setTimeout(schedule, Math.min(expires - Date.now(), 0x7fffffff));
32
+ timer.unref();
33
+ }
34
+ const entry = {
35
+ activate(expiresAt) {
36
+ if (!active) return false;
37
+ expires = Date.parse(expiresAt);
38
+ if (!Number.isFinite(expires)) { invalidate(); return false; }
39
+ schedule();
40
+ return active;
41
+ },
42
+ valid, dispose, invalidate,
43
+ };
44
+ entries.add(entry);
45
+ return entry;
46
+ },
47
+ revoke(token) {
48
+ const entries = sessions.get(digest(token));
49
+ if (entries) for (const entry of [...entries]) entry.invalidate();
50
+ },
51
+ };
52
+ }
@@ -1,80 +1,80 @@
1
- import { adminUiTokens } from './admin-style.mjs';
2
-
3
- export function adminPage(basePath = '/admin') {
4
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title><style>${adminUiTokens}</style>
5
- <style>@media(max-width:600px){body main{padding:12px 12px 40px}header.page-head{align-items:center;gap:8px;margin-bottom:8px}header.page-head h1{font-size:20px;line-height:1.2;margin:0}main .nav{gap:0;margin-bottom:12px}main .nav button{padding:8px 11px}main .stack,main .grid{gap:10px}main .card{padding:12px}main .hero strong{font-size:20px}main .throughput{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px}main .metrics{gap:6px}main .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:6px;min-width:0;padding:9px 10px}main .metric strong{font-size:17px;margin:0;white-space:nowrap}main .metric span{font-size:11px}main .card-head{margin-bottom:10px}}</style>
6
- <style>@media(max-width:600px){main .throughput .metric strong{font-size:17px}}</style>
7
- <style>.metric.warn{border-color:#946d38;background:#352a1b}.metric.warn strong{color:#ffd192}.metric.critical{border-color:#a65b58;background:#38211f}.metric.critical strong{color:#ffb1a9}.state.critical{background:#72342e;color:#ffe1dc}#trafficTimeline.critical .quota-fill{background:#d46a60}</style>
8
- <style>.quota-timeline{display:grid;gap:9px;margin-top:18px}.quota-timeline[hidden]{display:none}.quota-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#b7c8ba;font-size:12px;font-variant-numeric:tabular-nums}.quota-heading strong{font-size:13px}.quota-pace{font-weight:700;color:#7bc993}.quota-pace.ahead{color:#e9b46f}.quota-track{position:relative;height:13px;border:1px solid #526154;border-radius:7px;background:#5c6e6030}.quota-fill{position:absolute;inset:0 auto 0 0;border-radius:6px;background:#48ae78}.quota-timeline.ahead .quota-fill{background:#d39b4c}.quota-now{position:absolute;top:-5px;bottom:-5px;width:2px;background:#d8e3db;box-shadow:0 0 0 1px #162019;transform:translateX(-1px)}.quota-now:before{position:absolute;top:-1px;left:50%;width:7px;height:7px;background:#d8e3db;transform:translate(-50%,-50%) rotate(45deg);content:""}.quota-period{display:flex;justify-content:space-between;gap:12px;color:#91a394;font-size:11px}.quota-period span:last-child{text-align:right}</style>
9
- <style>
10
- :root{color-scheme:only light;font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif;background:#f5f7fa;color:#17212b}*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid transparent;border-radius:9px;padding:9px 14px;background:#397e58;color:white;font-weight:600}button:hover{background:#46966b}button:disabled{opacity:.45;cursor:default}button.secondary{background:#26352c;color:#c8d7cd;border-color:#405548}button.danger{background:transparent;color:#eaa29c;border-color:#734947}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid #8ad2a0;outline-offset:2px}
11
- main{max-width:1180px;margin:auto;padding:28px 24px 70px}h1{font-size:29px;line-height:1.2;margin:6px 0 8px}h2{font-size:18px;margin:0}h3{font-size:15px;margin:0}p{margin:0}.eyebrow{color:#83b598;letter-spacing:.12em;font-size:11px;font-weight:700}.subtle,.hint,.note{color:#a3b4a8}.hint,.note{font-size:12px}.note{margin-top:15px}.page-head{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-bottom:22px}.page-head .subtle{margin-top:6px}.nav{display:flex;gap:7px;border-bottom:1px solid #34463a;margin-bottom:22px;overflow:auto}.nav button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:#b6c9bb;padding:12px 17px;white-space:nowrap}.nav button[aria-current=page]{color:#b9efca;border-bottom-color:#8ad2a0;background:#1a2b20}.page[hidden]{display:none}.stack{display:grid;gap:18px}.grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(290px,.75fr);gap:18px;align-items:start}.card{background:#19241d;border:1px solid #34463a;border-radius:16px;padding:22px;box-shadow:0 12px 30px #080e0a24}.card-head{margin-bottom:18px}.hero{display:flex;align-items:start;justify-content:space-between;gap:18px;flex-wrap:wrap}.hero strong{font-size:26px}.state{border-radius:100px;padding:5px 10px;font-size:12px;font-weight:700;color:#c9f2d5;background:#28583b}.state.warn{background:#62491c;color:#ffe4a8}.state.down{background:#63302c;color:#ffd1c8}.throughput,.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.throughput{margin-top:20px}.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{background:#111b15;border:1px solid #2b3c30;border-radius:11px;padding:14px;color:#9eafa3;font-size:12px}.metric strong{display:block;color:#f0f6f0;font-size:25px;line-height:1.2;margin-bottom:5px;font-variant-numeric:tabular-nums}.metric small{display:block;color:#80b893}.throughput .metric strong{font-size:32px}.detail{display:grid;gap:10px}.detail div{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #2b3c30;padding-bottom:10px}.detail div:last-child{border:0;padding:0}.field{display:grid;gap:7px;margin:15px 0}.field label{font-size:13px;font-weight:600;color:#c5d7c9}input,textarea{width:100%;background:#101913;border:1px solid #45614d;border-radius:9px;color:#f1f7f1;padding:10px 12px}textarea{min-height:145px;resize:vertical}.actions{display:flex;flex-wrap:wrap;gap:9px;align-items:center}.current{border:1px solid #385841;background:#112017;border-radius:11px;padding:15px;margin-bottom:18px}.current.empty{border-color:#34463a;background:#111b15}.badge{display:inline-block;background:#28583b;color:#c9f2d5;border-radius:100px;padding:2px 8px;font-size:11px;font-weight:700}.current h3,.history h3{margin:8px 0 4px;overflow-wrap:anywhere}.current p,.history p{white-space:pre-wrap;overflow-wrap:anywhere;color:#c1d1c5}.date{font-size:12px;color:#94a99a}.history{display:grid;gap:10px}.history-item{border:1px solid #34463a;background:#141e17;border-radius:11px;padding:15px}.history-item.active{border-color:#4d865e}.history-item p{margin:8px 0 12px;max-height:4.5em;overflow:hidden}.history-item .actions button{padding:6px 10px;font-size:12px}.empty-note{padding:24px;border:1px dashed #415748;border-radius:10px;color:#a3b4a8;text-align:center}.status{min-height:24px;margin-top:16px;color:#96d4a9}.status.error{color:#f0aaa2}@media(max-width:800px){main{padding:24px 16px 50px}.grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:500px){.page-head{align-items:start}.card{padding:17px}.throughput{grid-template-columns:1fr}.metric{padding:10px}.metric strong{font-size:21px}}
12
- </style><style>
13
- .announcement-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:18px}.announcement-toolbar h2{font-size:20px}.announcement-controls{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.announcement-controls input{width:min(230px,100%)}.announcement-controls select{width:auto;min-width:108px}select{font:inherit;background:#101913;border:1px solid #45614d;border-radius:9px;color:#f1f7f1;padding:10px 12px}.announcement-list{display:grid;gap:9px}.announcement-row{display:grid;grid-template-columns:minmax(0,1fr) 90px 145px minmax(220px,auto);align-items:center;gap:14px;border:1px solid #34463a;border-radius:11px;padding:14px;background:#141e17}.announcement-row.active{border-color:#4d865e}.announcement-row h3{overflow-wrap:anywhere}.announcement-row p{color:#a3b4a8;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.announcement-row .actions{justify-content:flex-end;gap:6px}.announcement-row .actions button{padding:6px 9px;font-size:12px}.announcement-row .date{font-variant-numeric:tabular-nums}.announcement-status{display:inline-block;width:max-content;padding:3px 8px;border-radius:100px;font-size:11px;font-weight:700;color:#cad7ce;background:#334038}.announcement-status.active{background:#28583b;color:#c9f2d5}.announcement-status.draft{background:#4d462c;color:#ffe4a8}.announcement-empty{padding:32px;text-align:center;color:#a3b4a8}dialog{width:min(570px,calc(100% - 28px));max-height:calc(100vh - 36px);overflow:auto;background:#19241d;color:#edf4ee;border:1px solid #45614d;border-radius:16px;padding:22px;box-shadow:0 24px 70px #050905bb}dialog::backdrop{background:#050905bb}.editor-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.editor-head button{padding:4px 10px}.editor-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:18px}.editor-actions[hidden]{display:none}dialog textarea{min-height:200px}dialog input:disabled,dialog textarea:disabled{opacity:.8}.hero-meta{display:flex;align-items:center;gap:10px}.hero-meta .hint{white-space:nowrap}.quota-card .card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.quota-card .card-head .hint{white-space:nowrap}@media(max-width:800px){.announcement-row{grid-template-columns:minmax(0,1fr) auto;gap:8px}.announcement-row .date{grid-column:1}.announcement-row .actions{grid-column:2;grid-row:2;align-self:end}.announcement-row .announcement-status{grid-column:2;grid-row:1}.announcement-toolbar{align-items:stretch}.announcement-controls{width:100%}.announcement-controls input{flex:1;min-width:110px}}@media(max-width:500px){.announcement-row{padding:11px}.announcement-row .actions button{padding:5px 7px;font-size:11px}.announcement-controls select{min-width:96px}.hero{align-items:center;flex-wrap:nowrap}.hero strong{font-size:20px}.hero-meta{gap:6px}.hero-meta .hint{font-size:11px}.hero-meta .state{padding:3px 7px}}
14
- </style><style>
15
- :root{color-scheme:only light;background:#f5f7fa;color:#17212b}body{min-height:100vh;background:radial-gradient(circle at 16% 15%,#cfe6d7aa 0,transparent 28%),radial-gradient(circle at 86% 76%,#dbece0aa 0,transparent 26%),linear-gradient(#1f4a2d0b 1px,transparent 1px),linear-gradient(90deg,#1f4a2d0b 1px,transparent 1px),#f5f7fa;background-size:auto,auto,32px 32px,32px 32px,auto}
16
- button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{background:#cce5d4;color:#194d31;border-color:#84b995}button.secondary{background:#f4f8f5;color:#315f42;border-color:#c8daca}button.secondary:hover{background:#e8f2eb;color:#235d3a;border-color:#a9cbb4}button.danger{background:#fff8f6;color:#a34c43;border-color:#e6c5c0}button.danger:hover{background:#fdece8;color:#993b32;border-color:#dba7a0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline-color:#4b9b66}
17
- .subtle,.hint,.note{color:#607168}.page-head{justify-content:flex-start;align-items:center;gap:26px;margin-bottom:18px}.page-head h1{flex:none;color:#26332b}.page-head .nav{min-width:0;gap:4px;margin:0;border:0}.page-head .nav button{flex:none}.nav button{color:#607168}.nav button:hover{background:#eef5f0;color:#286e43}.nav button[aria-current=page]{color:#286e43;border-bottom-color:#68b582;background:#e4f1e8}.nav button[aria-current=page]:hover{background:#d8ebdd;color:#235d3a}.card{background:#ffffffee;border-color:#d5ddd8;box-shadow:0 10px 24px #263a2d0a}.metric{background:#f8faf8;border-color:#dce5df;color:#607168}.metric strong{color:#26332b}.metric small{color:#43865b}.metric.warn{background:#fff6e7;border-color:#e8c890}.metric.warn strong{color:#985d16}.metric.critical{background:#fff0ec;border-color:#dfaaa1}.metric.critical strong{color:#a5443b}
18
- .state,.badge,.announcement-status.active{background:#e4f1e8;color:#286e43}.state.warn,.announcement-status.draft{background:#fff1d8;color:#895711}.state.down,.state.critical{background:#fbe6e2;color:#a13e35}.traffic-usage{color:#52645a;font-size:13px;font-variant-numeric:tabular-nums}.quota-card .quota-timeline{margin-top:12px}.quota-heading{color:#52645a}.quota-heading .warn{color:#985d16}.quota-heading .critical{color:#a5443b}.quota-pace{color:#28764a}.quota-pace.ahead{color:#a87326}.quota-track{border-color:#cfdbd2;background:#e9f0eb}.quota-fill{background:#68b582}.quota-timeline.ahead .quota-fill,.quota-timeline.warn .quota-fill{background:#d39b4c}.quota-now{background:#52645a;box-shadow:0 0 0 1px #fff}.quota-now:before{background:#52645a}.quota-period{color:#77857d}
19
- .field label{color:#3b5142}input,textarea,select,.announcement-controls select{background:#fff;border-color:#c6d4cb;color:#26332b}input::placeholder,textarea::placeholder{color:#89988d}.date{color:#77857d}.announcement-row{background:#fff;border-color:#d5ddd8}.announcement-row.active{border-color:#a9c6b3;box-shadow:inset 3px 0 #68b582}.announcement-row p{color:#607168}.announcement-status{background:#edf1ee;color:#52645a}.announcement-empty{color:#607168}dialog{background:#fff;color:#26332b;border-color:#c6d4cb;box-shadow:0 24px 70px #263a2d33}dialog::backdrop{background:#172c20a8}.status{color:#28764a}.status.error{color:#a5443b}.relay-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}.overview-sample{display:flex;justify-content:flex-end}.throughput{margin-top:12px}.throughput .metric{padding:10px 12px}.throughput .metric strong{font-size:20px}@media(max-width:600px){header.page-head{gap:8px}header.page-head h1{font-size:18px}.page-head .nav{gap:2px}.page-head .nav button{padding:7px 6px;font-size:12px}main .throughput{margin-top:8px}main .throughput .metric{padding:7px 8px}main .throughput .metric strong{font-size:14px}}
20
- </style><style>
21
- #metrics{grid-template-columns:repeat(5,minmax(0,1fr))}.relay-heading{margin-bottom:14px}.resource-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.resource-head .hint{white-space:nowrap}.resource-card .throughput{border-top:1px solid #dce5df;padding-top:10px;margin-top:12px}.resource-card .throughput .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px}.resource-card .throughput .metric strong{font-size:16px;margin:0;white-space:nowrap}
22
- @media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
23
- @media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
24
- </style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.usage-row-head span{min-width:0;overflow-wrap:anywhere}.usage-row-head small{flex-shrink:0;white-space:nowrap}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}</style></head><body><main class="authenticated">
25
- <header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><button type="button" data-page="usage">使用统计</button></nav></header>
26
- <section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
27
- <section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
28
- <dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
29
- <section id="settings" class="page stack" hidden><section class="card"><div class="card-head"><h2>服务器转发容量</h2><p class="hint">只限制经 WSS 转发的普通会话,WebRTC 直连与白名单转发不占用此额度</p></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button><p class="note">当前上限是人工配置值,不随 CPU 或带宽自动改变。</p></section></section>
30
- <section id="usage" class="page" hidden><section class="card"><div class="usage-toolbar"><div><h2>今日使用统计</h2><p id="usageCount" class="hint">正在读取</p></div><div class="usage-views"><button id="usageUsers" type="button" class="secondary" aria-pressed="true">用户</button><button id="usageComputers" type="button" class="secondary" aria-pressed="false">工作站</button><button id="usageRefresh" type="button" class="secondary">刷新</button></div></div><div id="usageRows" class="usage-rows"></div><div class="usage-pages"><button id="usagePrev" type="button" class="secondary">上一页</button><span id="usagePageText" class="hint"></span><button id="usageNext" type="button" class="secondary">下一页</button></div><p id="usageError" class="status error" role="alert"></p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
31
- </main><script>
32
- const el=id=>document.getElementById(id);let snapshot=null;
33
- const request=async(path,options={})=>{const response=await fetch(path,{...options,referrerPolicy:'no-referrer'});const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
34
- function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
35
- function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
36
- function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
37
- function toneAbove(value,warn,critical){return typeof value==='number'&&Number.isFinite(value)?value>=critical?'critical':value>=warn?'warn':'':''}
38
- function toneBelow(value,warn,critical){return typeof value==='number'&&Number.isFinite(value)?value<=critical?'critical':value<=warn?'warn':'':''}
39
- function number(value,digits=0){return typeof value==='number'&&Number.isFinite(value)?value.toFixed(digits):'—'}
40
- function date(value){return new Date(value).toLocaleString('zh-CN')}
41
- function renderTrafficTimeline(cloud,available){const start=Date.parse(cloud.trafficStartAt);const end=Date.parse(cloud.trafficEndAt);const total=Number(cloud.trafficTotalBytes);const remaining=Number(cloud.trafficRemainingBytes);const valid=available&&Number.isFinite(start)&&Number.isFinite(end)&&end>start&&cloud.trafficTotalBytes!=null&&cloud.trafficRemainingBytes!=null&&total>0&&Number.isFinite(remaining);const timeline=el('trafficTimeline');timeline.hidden=!valid;if(!valid)return;
42
- const clamp=value=>Math.max(0,Math.min(100,value));const quotaLeft=clamp(remaining/total*100);const timeLeft=clamp((end-Date.now())/(end-start)*100);const used=100-quotaLeft;const elapsed=100-timeLeft;const pace=elapsed>0?used/elapsed:null;
43
- timeline.classList.toggle('ahead',pace!==null&&pace>1);timeline.classList.toggle('warn',quotaLeft<=20);timeline.classList.toggle('critical',quotaLeft<=10);el('trafficRemaining').textContent='流量剩余 '+number(quotaLeft,1)+'%';el('trafficRemaining').className=toneBelow(quotaLeft,20,10);el('timeRemaining').textContent='时间剩余 '+number(timeLeft,1)+'%';el('trafficPace').textContent=pace===null?'—×':number(pace,2)+'×';el('trafficPace').classList.toggle('ahead',pace!==null&&pace>1);
44
- el('quotaFill').style.width=quotaLeft+'%';el('quotaNow').style.left=timeLeft+'%';el('quotaTrack').setAttribute('aria-label','流量剩余 '+number(quotaLeft,1)+'%,时间剩余 '+number(timeLeft,1)+'%');el('trafficStart').textContent='本期开始 '+date(cloud.trafficStartAt);el('trafficEnd').textContent='到期 '+date(cloud.trafficEndAt)}
45
- function renderOverview(state){const cloud=state.cloud||{};const relay=state.relayAvailable!==false;const cloudFresh=cloud.metricAvailable&&Date.now()-Date.parse(cloud.metricSampledAt)<180000;const trafficFresh=cloud.trafficAvailable&&Date.now()-Date.parse(cloud.trafficSampledAt)<900000;const occupancy=state.maxRelaySessions?state.activeRelaySessions/state.maxRelaySessions:0;const high=occupancy>=.9||(cloudFresh&&(cloud.cpuPercent>=85||cloud.memoryPercent>=90||cloud.diskPercent>=90||cloud.outRatioPercent>=90));const caution=occupancy>=.75||(cloudFresh&&(cloud.cpuPercent>=65||cloud.memoryPercent>=80||cloud.diskPercent>=80||cloud.outRatioPercent>=70));const label=!relay?'中继不可用':high?'压力较高':!cloudFresh?'云监控数据过期':caution?'需要关注':'压力正常';
46
- el('healthText').textContent=label;el('healthText').className='state'+(!relay||!cloudFresh&&!high?' down':high?' critical':caution?' warn':'');el('sampleTime').textContent=cloudFresh?'采样时间 '+new Date(cloud.metricSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'等待采样';
47
- el('throughput').replaceChildren(metric(cloudFresh?number(cloud.outMbps,2)+' Mbps':'—','公网发送'),metric(cloudFresh?number(cloud.inMbps,2)+' Mbps':'—','公网接收'));
48
- el('serverMetrics').replaceChildren(metric(cloudFresh?number(cloud.cpuPercent,1)+'%':'—','CPU 使用率',null,cloudFresh?toneAbove(cloud.cpuPercent,65,85):''),metric(cloudFresh?number(cloud.memoryPercent,1)+'%':'—','内存使用率',null,cloudFresh?toneAbove(cloud.memoryPercent,80,90):''),metric(cloudFresh?number(cloud.diskPercent,1)+'%':'—','系统盘使用率',null,cloudFresh?toneAbove(cloud.diskPercent,80,90):''),metric(cloudFresh?number(cloud.cpuLoad1,2):'—','1 分钟负载'),metric(cloudFresh?number(cloud.outRatioPercent,1)+'%':'—','出口带宽使用率',null,cloudFresh?toneAbove(cloud.outRatioPercent,70,90):''));
49
- const gb=value=>value==null?'—':number(Number(value)/1073741824,2)+' GB';
50
- el('trafficUsage').textContent=trafficFresh?'已用 '+gb(cloud.trafficUsedBytes)+' / '+gb(cloud.trafficTotalBytes):'流量暂不可用';
51
- el('trafficNote').textContent=trafficFresh?'更新时间 '+new Date(cloud.trafficSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'暂不可用';
52
- renderTrafficTimeline(cloud,trafficFresh);
53
- el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'活动移动端'))}
54
- let editorItem=null,editorMode='new',writing=false;
55
- function openEditor(item=null,duplicate=false){editorItem=duplicate?null:item;editorMode=duplicate?'new':item?.status==='draft'?'draft':item?'view':'new';el('editorHeading').textContent=duplicate?'复制为新公告':editorMode==='draft'?'编辑草稿':editorMode==='view'?'查看公告':'新建公告';el('title').value=item?.title||'';el('message').value=item?.message||'';el('title').disabled=editorMode==='view';el('message').disabled=editorMode==='view';el('editorActions').hidden=editorMode==='view';el('editorNote').textContent=editorMode==='view'?'已发布内容只读;可复制为新公告再修改。':'发布后会立即替换当前公告并通知在线设备。';el('editorError').hidden=true;el('announcementEditor').showModal();if(editorMode!=='view')el('title').focus()}
56
- function action(label,className,handler,disabled=false){const button=node('button',className,label);button.type='button';button.disabled=disabled;button.addEventListener('click',handler);return button}
57
- function renderAnnouncements(state){const available=state.relayAvailable!==false;const items=state.announcements||[];const filter=el('announcementFilter').value;const query=el('announcementSearch').value.trim().toLocaleLowerCase();const visible=items.filter(item=>(filter==='all'||item.status===filter)&&(!query||(item.title+' '+item.message).toLocaleLowerCase().includes(query)));el('newAnnouncement').disabled=!available;el('announcementCount').textContent=available?'共 '+items.length+' 条 · 发布中 '+items.filter(item=>item.status==='active').length+' 条':'中继暂不可用';const list=el('announcementList');list.replaceChildren();if(!visible.length){list.append(node('div','announcement-empty',available?items.length?'没有符合条件的公告':'暂无公告,点击“新建公告”开始':'暂时无法读取公告'));return}
58
- for(const item of visible){const row=node('article','announcement-row'+(item.status==='active'?' active':''));const summary=node('div');summary.append(node('h3','',item.title),node('p','',item.message));const status=node('span','announcement-status '+item.status,{active:'发布中',draft:'草稿',withdrawn:'已撤下'}[item.status]||'未知');const when=node('span','date',(item.status==='draft'?'修改 ':'发布 ')+date(item.status==='draft'?item.updatedAt:item.publishedAt));const actions=node('div','actions');const path='/admin/api/announcements/'+encodeURIComponent(item.id);
59
- if(item.status==='draft'){actions.append(action('编辑','secondary',()=>openEditor(item)),action('发布','',()=>{if(!snapshot.announcement||confirm('发布后将替换当前公告,确定发布?'))write('PUT',path+'/publish',{},'公告已发布')},!available),action('删除','danger',()=>{if(confirm('确定删除这条草稿?'))write('DELETE',path,{},'草稿已删除')},!available))}
60
- else if(item.status==='active'){actions.append(action('查看','secondary',()=>openEditor(item)),action('撤下','danger',()=>{if(confirm('确定撤下当前公告?'))write('PUT',path+'/withdraw',{},'公告已撤下')},!available))}
61
- else{actions.append(action('查看','secondary',()=>openEditor(item)),action('复制','secondary',()=>openEditor(item,true),!available),action('删除','danger',()=>{if(confirm('确定删除这条已撤下的公告?'))write('DELETE',path,{},'公告记录已删除')},!available))}
62
- row.append(summary,status,when,actions);list.append(row)}}
63
- function render(state){snapshot=state;renderOverview(state);renderAnnouncements(state);el('saveLimit').disabled=state.relayAvailable===false;if(document.activeElement!==el('limit'))el('limit').value=state.maxRelaySessions??''}
64
- async function load(){try{render(await request('/admin/api/status'));setResult('')}catch(error){snapshot=null;el('healthText').textContent='状态暂不可用';el('healthText').className='state down';for(const id of ['metrics','serverMetrics','throughput','announcementList'])el(id).replaceChildren();el('trafficTimeline').hidden=true;el('trafficUsage').textContent='—';el('announcementCount').textContent='暂时无法读取';el('saveLimit').disabled=true;setResult(error.message,true)}}
65
- async function write(method,path,body,message){if(writing)return false;writing=true;try{await request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)});await load();setResult(message);return true}catch(error){setResult(error.message,true);if(el('announcementEditor').open){el('editorError').textContent=error.message;el('editorError').hidden=false}return false}finally{writing=false}}
66
- let usageView='user',usagePage=1,usageTotal=0;
67
- function usageDuration(value){const seconds=Math.floor(Number(value)||0),hours=Math.floor(seconds/3600),minutes=Math.floor(seconds%3600/60),rest=seconds%60;return hours?hours+' 小时 '+minutes+' 分钟':minutes?minutes+' 分钟 '+rest+' 秒':rest+' 秒'}
68
- function usageTime(value){return value?new Date(value.replace(' ','T')+'Z').toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}):'—'}
69
- async function loadUsage(){const view=usageView,page=usagePage;el('usageError').textContent='';el('usageCount').textContent='正在读取';try{const result=await request('/admin/api/usage?view='+view+'&page='+page);if(view!==usageView||page!==usagePage||el('usage').hidden)return;usageTotal=result.total;el('usageCount').textContent='今日有记录:'+usageTotal+(view==='user'?' 个用户':' 台工作站')+' · 北京时间';el('usagePageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(usageTotal/30))+' 页';el('usagePrev').disabled=page<=1;el('usageNext').disabled=page*30>=usageTotal;const rows=el('usageRows');rows.replaceChildren();if(!result.rows.length){rows.append(node('div','empty-note','今日暂无计时记录'));return}for(const item of result.rows){const row=node('div','usage-row');const counts=' · 消息 '+Number(item.message_count||0)+' · 轮次 '+Number(item.turn_count||0);const heading=node('div','usage-row-head');const title=node('span','',view==='user'?item.email:'工作站 …'+item.id.slice(-8));title.title=view==='user'?item.email:item.id;heading.append(title,node('small','hint','最后使用 '+usageTime(item.last_used_at)));row.append(heading,node('strong','',view==='user'?'任务 '+usageDuration(item.today_task_seconds)+counts:'任务 '+usageDuration(item.today_task_seconds)+' · 免费账号 '+usageDuration(item.today_free_task_seconds)+counts));rows.append(row)}}catch(error){if(view===usageView&&page===usagePage){usageTotal=0;el('usageCount').textContent='统计暂不可用';el('usageRows').replaceChildren();el('usagePageText').textContent='';el('usagePrev').disabled=true;el('usageNext').disabled=true;el('usageError').textContent=error.message}}}
70
- function setUsageView(view){if(usageView===view)return;usageView=view;usagePage=1;el('usageUsers').setAttribute('aria-pressed',String(view==='user'));el('usageComputers').setAttribute('aria-pressed',String(view==='computer'));void loadUsage()}
71
- el('usageUsers').onclick=()=>setUsageView('user');el('usageComputers').onclick=()=>setUsageView('computer');el('usageRefresh').onclick=()=>loadUsage();el('usagePrev').onclick=()=>{if(usagePage>1){usagePage--;void loadUsage()}};el('usageNext').onclick=()=>{if(usagePage*30<usageTotal){usagePage++;void loadUsage()}};
72
- function showPage(page){for(const button of document.querySelectorAll('[data-page]'))button.setAttribute('aria-current',button.dataset.page===page?'page':'false');for(const section of document.querySelectorAll('.page'))section.hidden=section.id!==page;history.replaceState(null,'','#'+page);if(page==='usage')void loadUsage();else void load()}
73
- for(const button of document.querySelectorAll('[data-page]'))button.addEventListener('click',()=>showPage(button.dataset.page));
74
- el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{maxRelaySessions:Number(el('limit').value)},'转发容量已保存'));
75
- el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
76
- async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
77
- el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
78
- showPage(['overview','announcements','settings','usage'].includes(location.hash.slice(1))?location.hash.slice(1):'overview');setInterval(()=>{if(!document.hidden&&!el('overview').hidden)load()},5000);
79
- </script></body></html>`.replaceAll('/admin/api/', basePath + '/api/');
80
- }
1
+ import { adminUiTokens, adminFooterStyles } from './admin-style.mjs';
2
+
3
+ export function adminPage(basePath = '/admin') {
4
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title><style>${adminUiTokens}${adminFooterStyles}</style>
5
+ <style>@media(max-width:600px){body main{padding:12px 12px 40px}header.page-head{align-items:center;gap:8px;margin-bottom:8px}header.page-head h1{font-size:20px;line-height:1.2;margin:0}main .nav{gap:0;margin-bottom:12px}main .nav button{padding:8px 11px}main .stack,main .grid{gap:10px}main .card{padding:12px}main .hero strong{font-size:20px}main .throughput{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px}main .metrics{gap:6px}main .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:6px;min-width:0;padding:9px 10px}main .metric strong{font-size:17px;margin:0;white-space:nowrap}main .metric span{font-size:11px}main .card-head{margin-bottom:10px}}</style>
6
+ <style>@media(max-width:600px){main .throughput .metric strong{font-size:17px}}</style>
7
+ <style>.metric.warn{border-color:#946d38;background:#352a1b}.metric.warn strong{color:#ffd192}.metric.critical{border-color:#a65b58;background:#38211f}.metric.critical strong{color:#ffb1a9}.state.critical{background:#72342e;color:#ffe1dc}#trafficTimeline.critical .quota-fill{background:#d46a60}</style>
8
+ <style>.quota-timeline{display:grid;gap:9px;margin-top:18px}.quota-timeline[hidden]{display:none}.quota-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#b7c8ba;font-size:12px;font-variant-numeric:tabular-nums}.quota-heading strong{font-size:13px}.quota-pace{font-weight:700;color:#7bc993}.quota-pace.ahead{color:#e9b46f}.quota-track{position:relative;height:13px;border:1px solid #526154;border-radius:7px;background:#5c6e6030}.quota-fill{position:absolute;inset:0 auto 0 0;border-radius:6px;background:#48ae78}.quota-timeline.ahead .quota-fill{background:#d39b4c}.quota-now{position:absolute;top:-5px;bottom:-5px;width:2px;background:#d8e3db;box-shadow:0 0 0 1px #162019;transform:translateX(-1px)}.quota-now:before{position:absolute;top:-1px;left:50%;width:7px;height:7px;background:#d8e3db;transform:translate(-50%,-50%) rotate(45deg);content:""}.quota-period{display:flex;justify-content:space-between;gap:12px;color:#91a394;font-size:11px}.quota-period span:last-child{text-align:right}</style>
9
+ <style>
10
+ :root{color-scheme:only light;font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif;background:#f5f7fa;color:#17212b}*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid transparent;border-radius:9px;padding:9px 14px;background:#397e58;color:white;font-weight:600}button:hover{background:#46966b}button:disabled{opacity:.45;cursor:default}button.secondary{background:#26352c;color:#c8d7cd;border-color:#405548}button.danger{background:transparent;color:#eaa29c;border-color:#734947}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid #8ad2a0;outline-offset:2px}
11
+ main{max-width:1180px;margin:auto;padding:28px 24px 70px}h1{font-size:29px;line-height:1.2;margin:6px 0 8px}h2{font-size:18px;margin:0}h3{font-size:15px;margin:0}p{margin:0}.eyebrow{color:#83b598;letter-spacing:.12em;font-size:11px;font-weight:700}.subtle,.hint,.note{color:#a3b4a8}.hint,.note{font-size:12px}.note{margin-top:15px}.page-head{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-bottom:22px}.page-head .subtle{margin-top:6px}.nav{display:flex;gap:7px;border-bottom:1px solid #34463a;margin-bottom:22px;overflow:auto}.nav button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:#b6c9bb;padding:12px 17px;white-space:nowrap}.nav button[aria-current=page]{color:#b9efca;border-bottom-color:#8ad2a0;background:#1a2b20}.page[hidden]{display:none}.stack{display:grid;gap:18px}.grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(290px,.75fr);gap:18px;align-items:start}.card{background:#19241d;border:1px solid #34463a;border-radius:16px;padding:22px;box-shadow:0 12px 30px #080e0a24}.card-head{margin-bottom:18px}.hero{display:flex;align-items:start;justify-content:space-between;gap:18px;flex-wrap:wrap}.hero strong{font-size:26px}.state{border-radius:100px;padding:5px 10px;font-size:12px;font-weight:700;color:#c9f2d5;background:#28583b}.state.warn{background:#62491c;color:#ffe4a8}.state.down{background:#63302c;color:#ffd1c8}.throughput,.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.throughput{margin-top:20px}.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{background:#111b15;border:1px solid #2b3c30;border-radius:11px;padding:14px;color:#9eafa3;font-size:12px}.metric strong{display:block;color:#f0f6f0;font-size:25px;line-height:1.2;margin-bottom:5px;font-variant-numeric:tabular-nums}.metric small{display:block;color:#80b893}.throughput .metric strong{font-size:32px}.detail{display:grid;gap:10px}.detail div{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #2b3c30;padding-bottom:10px}.detail div:last-child{border:0;padding:0}.field{display:grid;gap:7px;margin:15px 0}.field label{font-size:13px;font-weight:600;color:#c5d7c9}input,textarea{width:100%;background:#101913;border:1px solid #45614d;border-radius:9px;color:#f1f7f1;padding:10px 12px}textarea{min-height:145px;resize:vertical}.actions{display:flex;flex-wrap:wrap;gap:9px;align-items:center}.current{border:1px solid #385841;background:#112017;border-radius:11px;padding:15px;margin-bottom:18px}.current.empty{border-color:#34463a;background:#111b15}.badge{display:inline-block;background:#28583b;color:#c9f2d5;border-radius:100px;padding:2px 8px;font-size:11px;font-weight:700}.current h3,.history h3{margin:8px 0 4px;overflow-wrap:anywhere}.current p,.history p{white-space:pre-wrap;overflow-wrap:anywhere;color:#c1d1c5}.date{font-size:12px;color:#94a99a}.history{display:grid;gap:10px}.history-item{border:1px solid #34463a;background:#141e17;border-radius:11px;padding:15px}.history-item.active{border-color:#4d865e}.history-item p{margin:8px 0 12px;max-height:4.5em;overflow:hidden}.history-item .actions button{padding:6px 10px;font-size:12px}.empty-note{padding:24px;border:1px dashed #415748;border-radius:10px;color:#a3b4a8;text-align:center}.status{min-height:24px;margin-top:16px;color:#96d4a9}.status.error{color:#f0aaa2}@media(max-width:800px){main{padding:24px 16px 50px}.grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:500px){.page-head{align-items:start}.card{padding:17px}.throughput{grid-template-columns:1fr}.metric{padding:10px}.metric strong{font-size:21px}}
12
+ </style><style>
13
+ .announcement-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:18px}.announcement-toolbar h2{font-size:20px}.announcement-controls{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.announcement-controls input{width:min(230px,100%)}.announcement-controls select{width:auto;min-width:108px}select{font:inherit;background:#101913;border:1px solid #45614d;border-radius:9px;color:#f1f7f1;padding:10px 12px}.announcement-list{display:grid;gap:9px}.announcement-row{display:grid;grid-template-columns:minmax(0,1fr) 90px 145px minmax(220px,auto);align-items:center;gap:14px;border:1px solid #34463a;border-radius:11px;padding:14px;background:#141e17}.announcement-row.active{border-color:#4d865e}.announcement-row h3{overflow-wrap:anywhere}.announcement-row p{color:#a3b4a8;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.announcement-row .actions{justify-content:flex-end;gap:6px}.announcement-row .actions button{padding:6px 9px;font-size:12px}.announcement-row .date{font-variant-numeric:tabular-nums}.announcement-status{display:inline-block;width:max-content;padding:3px 8px;border-radius:100px;font-size:11px;font-weight:700;color:#cad7ce;background:#334038}.announcement-status.active{background:#28583b;color:#c9f2d5}.announcement-status.draft{background:#4d462c;color:#ffe4a8}.announcement-empty{padding:32px;text-align:center;color:#a3b4a8}dialog{width:min(570px,calc(100% - 28px));max-height:calc(100vh - 36px);overflow:auto;background:#19241d;color:#edf4ee;border:1px solid #45614d;border-radius:16px;padding:22px;box-shadow:0 24px 70px #050905bb}dialog::backdrop{background:#050905bb}.editor-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.editor-head button{padding:4px 10px}.editor-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:18px}.editor-actions[hidden]{display:none}dialog textarea{min-height:200px}dialog input:disabled,dialog textarea:disabled{opacity:.8}.hero-meta{display:flex;align-items:center;gap:10px}.hero-meta .hint{white-space:nowrap}.quota-card .card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.quota-card .card-head .hint{white-space:nowrap}@media(max-width:800px){.announcement-row{grid-template-columns:minmax(0,1fr) auto;gap:8px}.announcement-row .date{grid-column:1}.announcement-row .actions{grid-column:2;grid-row:2;align-self:end}.announcement-row .announcement-status{grid-column:2;grid-row:1}.announcement-toolbar{align-items:stretch}.announcement-controls{width:100%}.announcement-controls input{flex:1;min-width:110px}}@media(max-width:500px){.announcement-row{padding:11px}.announcement-row .actions button{padding:5px 7px;font-size:11px}.announcement-controls select{min-width:96px}.hero{align-items:center;flex-wrap:nowrap}.hero strong{font-size:20px}.hero-meta{gap:6px}.hero-meta .hint{font-size:11px}.hero-meta .state{padding:3px 7px}}
14
+ </style><style>
15
+ :root{color-scheme:only light;background:#f5f7fa;color:#17212b}body{min-height:100vh;background:radial-gradient(circle at 16% 15%,#cfe6d7aa 0,transparent 28%),radial-gradient(circle at 86% 76%,#dbece0aa 0,transparent 26%),linear-gradient(#1f4a2d0b 1px,transparent 1px),linear-gradient(90deg,#1f4a2d0b 1px,transparent 1px),#f5f7fa;background-size:auto,auto,32px 32px,32px 32px,auto}
16
+ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{background:#cce5d4;color:#194d31;border-color:#84b995}button.secondary{background:#f4f8f5;color:#315f42;border-color:#c8daca}button.secondary:hover{background:#e8f2eb;color:#235d3a;border-color:#a9cbb4}button.danger{background:#fff8f6;color:#a34c43;border-color:#e6c5c0}button.danger:hover{background:#fdece8;color:#993b32;border-color:#dba7a0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline-color:#4b9b66}
17
+ .subtle,.hint,.note{color:#607168}.page-head{justify-content:flex-start;align-items:center;gap:26px;margin-bottom:18px}.page-head h1{flex:none;color:#26332b}.page-head .nav{min-width:0;gap:4px;margin:0;border:0}.page-head .nav button{flex:none}.nav button{color:#607168}.nav button:hover{background:#eef5f0;color:#286e43}.nav button[aria-current=page]{color:#286e43;border-bottom-color:#68b582;background:#e4f1e8}.nav button[aria-current=page]:hover{background:#d8ebdd;color:#235d3a}.card{background:#ffffffee;border-color:#d5ddd8;box-shadow:0 10px 24px #263a2d0a}.metric{background:#f8faf8;border-color:#dce5df;color:#607168}.metric strong{color:#26332b}.metric small{color:#43865b}.metric.warn{background:#fff6e7;border-color:#e8c890}.metric.warn strong{color:#985d16}.metric.critical{background:#fff0ec;border-color:#dfaaa1}.metric.critical strong{color:#a5443b}
18
+ .state,.badge,.announcement-status.active{background:#e4f1e8;color:#286e43}.state.warn,.announcement-status.draft{background:#fff1d8;color:#895711}.state.down,.state.critical{background:#fbe6e2;color:#a13e35}.traffic-usage{color:#52645a;font-size:13px;font-variant-numeric:tabular-nums}.quota-card .quota-timeline{margin-top:12px}.quota-heading{color:#52645a}.quota-heading .warn{color:#985d16}.quota-heading .critical{color:#a5443b}.quota-pace{color:#28764a}.quota-pace.ahead{color:#a87326}.quota-track{border-color:#cfdbd2;background:#e9f0eb}.quota-fill{background:#68b582}.quota-timeline.ahead .quota-fill,.quota-timeline.warn .quota-fill{background:#d39b4c}.quota-now{background:#52645a;box-shadow:0 0 0 1px #fff}.quota-now:before{background:#52645a}.quota-period{color:#77857d}
19
+ .field label{color:#3b5142}input,textarea,select,.announcement-controls select{background:#fff;border-color:#c6d4cb;color:#26332b}input::placeholder,textarea::placeholder{color:#89988d}.date{color:#77857d}.announcement-row{background:#fff;border-color:#d5ddd8}.announcement-row.active{border-color:#a9c6b3;box-shadow:inset 3px 0 #68b582}.announcement-row p{color:#607168}.announcement-status{background:#edf1ee;color:#52645a}.announcement-empty{color:#607168}dialog{background:#fff;color:#26332b;border-color:#c6d4cb;box-shadow:0 24px 70px #263a2d33}dialog::backdrop{background:#172c20a8}.status{color:#28764a}.status.error{color:#a5443b}.relay-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}.overview-sample{display:flex;justify-content:flex-end}.throughput{margin-top:12px}.throughput .metric{padding:10px 12px}.throughput .metric strong{font-size:20px}@media(max-width:600px){header.page-head{gap:8px}header.page-head h1{font-size:18px}.page-head .nav{gap:2px}.page-head .nav button{padding:7px 6px;font-size:12px}main .throughput{margin-top:8px}main .throughput .metric{padding:7px 8px}main .throughput .metric strong{font-size:14px}}
20
+ </style><style>
21
+ #metrics{grid-template-columns:repeat(5,minmax(0,1fr))}.relay-heading{margin-bottom:14px}.resource-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.resource-head .hint{white-space:nowrap}.resource-card .throughput{border-top:1px solid #dce5df;padding-top:10px;margin-top:12px}.resource-card .throughput .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px}.resource-card .throughput .metric strong{font-size:16px;margin:0;white-space:nowrap}
22
+ @media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
23
+ @media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
24
+ </style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.usage-row-head span{min-width:0;overflow-wrap:anywhere}.usage-row-head small{flex-shrink:0;white-space:nowrap}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}</style></head><body><main class="authenticated">
25
+ <header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><button type="button" data-page="usage">使用统计</button></nav></header>
26
+ <section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
27
+ <section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
28
+ <dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
29
+ <section id="settings" class="page stack" hidden><section class="card"><div class="card-head"><h2>服务器转发容量</h2><p class="hint">只限制经 WSS 转发的普通会话,WebRTC 直连与白名单转发不占用此额度</p></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button><p class="note">当前上限是人工配置值,不随 CPU 或带宽自动改变。</p></section></section>
30
+ <section id="usage" class="page" hidden><section class="card"><div class="usage-toolbar"><div><h2>今日使用统计</h2><p id="usageCount" class="hint">正在读取</p></div><div class="usage-views"><button id="usageUsers" type="button" class="secondary" aria-pressed="true">用户</button><button id="usageComputers" type="button" class="secondary" aria-pressed="false">工作站</button><button id="usageRefresh" type="button" class="secondary">刷新</button></div></div><div id="usageRows" class="usage-rows"></div><div class="usage-pages"><button id="usagePrev" type="button" class="secondary">上一页</button><span id="usagePageText" class="hint"></span><button id="usageNext" type="button" class="secondary">下一页</button></div><p id="usageError" class="status error" role="alert"></p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
31
+ </main><footer class="admin-footer">网站备案号:京ICP备2026027643号-2</footer><script>
32
+ const el=id=>document.getElementById(id);let snapshot=null;
33
+ const request=async(path,options={})=>{const response=await fetch(path,{...options,referrerPolicy:'no-referrer'});const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
34
+ function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
35
+ function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
36
+ function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
37
+ function toneAbove(value,warn,critical){return typeof value==='number'&&Number.isFinite(value)?value>=critical?'critical':value>=warn?'warn':'':''}
38
+ function toneBelow(value,warn,critical){return typeof value==='number'&&Number.isFinite(value)?value<=critical?'critical':value<=warn?'warn':'':''}
39
+ function number(value,digits=0){return typeof value==='number'&&Number.isFinite(value)?value.toFixed(digits):'—'}
40
+ function date(value){return new Date(value).toLocaleString('zh-CN')}
41
+ function renderTrafficTimeline(cloud,available){const start=Date.parse(cloud.trafficStartAt);const end=Date.parse(cloud.trafficEndAt);const total=Number(cloud.trafficTotalBytes);const remaining=Number(cloud.trafficRemainingBytes);const valid=available&&Number.isFinite(start)&&Number.isFinite(end)&&end>start&&cloud.trafficTotalBytes!=null&&cloud.trafficRemainingBytes!=null&&total>0&&Number.isFinite(remaining);const timeline=el('trafficTimeline');timeline.hidden=!valid;if(!valid)return;
42
+ const clamp=value=>Math.max(0,Math.min(100,value));const quotaLeft=clamp(remaining/total*100);const timeLeft=clamp((end-Date.now())/(end-start)*100);const used=100-quotaLeft;const elapsed=100-timeLeft;const pace=elapsed>0?used/elapsed:null;
43
+ timeline.classList.toggle('ahead',pace!==null&&pace>1);timeline.classList.toggle('warn',quotaLeft<=20);timeline.classList.toggle('critical',quotaLeft<=10);el('trafficRemaining').textContent='流量剩余 '+number(quotaLeft,1)+'%';el('trafficRemaining').className=toneBelow(quotaLeft,20,10);el('timeRemaining').textContent='时间剩余 '+number(timeLeft,1)+'%';el('trafficPace').textContent=pace===null?'—×':number(pace,2)+'×';el('trafficPace').classList.toggle('ahead',pace!==null&&pace>1);
44
+ el('quotaFill').style.width=quotaLeft+'%';el('quotaNow').style.left=timeLeft+'%';el('quotaTrack').setAttribute('aria-label','流量剩余 '+number(quotaLeft,1)+'%,时间剩余 '+number(timeLeft,1)+'%');el('trafficStart').textContent='本期开始 '+date(cloud.trafficStartAt);el('trafficEnd').textContent='到期 '+date(cloud.trafficEndAt)}
45
+ function renderOverview(state){const cloud=state.cloud||{};const relay=state.relayAvailable!==false;const cloudFresh=cloud.metricAvailable&&Date.now()-Date.parse(cloud.metricSampledAt)<180000;const trafficFresh=cloud.trafficAvailable&&Date.now()-Date.parse(cloud.trafficSampledAt)<900000;const occupancy=state.maxRelaySessions?state.activeRelaySessions/state.maxRelaySessions:0;const high=occupancy>=.9||(cloudFresh&&(cloud.cpuPercent>=85||cloud.memoryPercent>=90||cloud.diskPercent>=90||cloud.outRatioPercent>=90));const caution=occupancy>=.75||(cloudFresh&&(cloud.cpuPercent>=65||cloud.memoryPercent>=80||cloud.diskPercent>=80||cloud.outRatioPercent>=70));const label=!relay?'中继不可用':high?'压力较高':!cloudFresh?'云监控数据过期':caution?'需要关注':'压力正常';
46
+ el('healthText').textContent=label;el('healthText').className='state'+(!relay||!cloudFresh&&!high?' down':high?' critical':caution?' warn':'');el('sampleTime').textContent=cloudFresh?'采样时间 '+new Date(cloud.metricSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'等待采样';
47
+ el('throughput').replaceChildren(metric(cloudFresh?number(cloud.outMbps,2)+' Mbps':'—','公网发送'),metric(cloudFresh?number(cloud.inMbps,2)+' Mbps':'—','公网接收'));
48
+ el('serverMetrics').replaceChildren(metric(cloudFresh?number(cloud.cpuPercent,1)+'%':'—','CPU 使用率',null,cloudFresh?toneAbove(cloud.cpuPercent,65,85):''),metric(cloudFresh?number(cloud.memoryPercent,1)+'%':'—','内存使用率',null,cloudFresh?toneAbove(cloud.memoryPercent,80,90):''),metric(cloudFresh?number(cloud.diskPercent,1)+'%':'—','系统盘使用率',null,cloudFresh?toneAbove(cloud.diskPercent,80,90):''),metric(cloudFresh?number(cloud.cpuLoad1,2):'—','1 分钟负载'),metric(cloudFresh?number(cloud.outRatioPercent,1)+'%':'—','出口带宽使用率',null,cloudFresh?toneAbove(cloud.outRatioPercent,70,90):''));
49
+ const gb=value=>value==null?'—':number(Number(value)/1073741824,2)+' GB';
50
+ el('trafficUsage').textContent=trafficFresh?'已用 '+gb(cloud.trafficUsedBytes)+' / '+gb(cloud.trafficTotalBytes):'流量暂不可用';
51
+ el('trafficNote').textContent=trafficFresh?'更新时间 '+new Date(cloud.trafficSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'暂不可用';
52
+ renderTrafficTimeline(cloud,trafficFresh);
53
+ el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'活动移动端'))}
54
+ let editorItem=null,editorMode='new',writing=false;
55
+ function openEditor(item=null,duplicate=false){editorItem=duplicate?null:item;editorMode=duplicate?'new':item?.status==='draft'?'draft':item?'view':'new';el('editorHeading').textContent=duplicate?'复制为新公告':editorMode==='draft'?'编辑草稿':editorMode==='view'?'查看公告':'新建公告';el('title').value=item?.title||'';el('message').value=item?.message||'';el('title').disabled=editorMode==='view';el('message').disabled=editorMode==='view';el('editorActions').hidden=editorMode==='view';el('editorNote').textContent=editorMode==='view'?'已发布内容只读;可复制为新公告再修改。':'发布后会立即替换当前公告并通知在线设备。';el('editorError').hidden=true;el('announcementEditor').showModal();if(editorMode!=='view')el('title').focus()}
56
+ function action(label,className,handler,disabled=false){const button=node('button',className,label);button.type='button';button.disabled=disabled;button.addEventListener('click',handler);return button}
57
+ function renderAnnouncements(state){const available=state.relayAvailable!==false;const items=state.announcements||[];const filter=el('announcementFilter').value;const query=el('announcementSearch').value.trim().toLocaleLowerCase();const visible=items.filter(item=>(filter==='all'||item.status===filter)&&(!query||(item.title+' '+item.message).toLocaleLowerCase().includes(query)));el('newAnnouncement').disabled=!available;el('announcementCount').textContent=available?'共 '+items.length+' 条 · 发布中 '+items.filter(item=>item.status==='active').length+' 条':'中继暂不可用';const list=el('announcementList');list.replaceChildren();if(!visible.length){list.append(node('div','announcement-empty',available?items.length?'没有符合条件的公告':'暂无公告,点击“新建公告”开始':'暂时无法读取公告'));return}
58
+ for(const item of visible){const row=node('article','announcement-row'+(item.status==='active'?' active':''));const summary=node('div');summary.append(node('h3','',item.title),node('p','',item.message));const status=node('span','announcement-status '+item.status,{active:'发布中',draft:'草稿',withdrawn:'已撤下'}[item.status]||'未知');const when=node('span','date',(item.status==='draft'?'修改 ':'发布 ')+date(item.status==='draft'?item.updatedAt:item.publishedAt));const actions=node('div','actions');const path='/admin/api/announcements/'+encodeURIComponent(item.id);
59
+ if(item.status==='draft'){actions.append(action('编辑','secondary',()=>openEditor(item)),action('发布','',()=>{if(!snapshot.announcement||confirm('发布后将替换当前公告,确定发布?'))write('PUT',path+'/publish',{},'公告已发布')},!available),action('删除','danger',()=>{if(confirm('确定删除这条草稿?'))write('DELETE',path,{},'草稿已删除')},!available))}
60
+ else if(item.status==='active'){actions.append(action('查看','secondary',()=>openEditor(item)),action('撤下','danger',()=>{if(confirm('确定撤下当前公告?'))write('PUT',path+'/withdraw',{},'公告已撤下')},!available))}
61
+ else{actions.append(action('查看','secondary',()=>openEditor(item)),action('复制','secondary',()=>openEditor(item,true),!available),action('删除','danger',()=>{if(confirm('确定删除这条已撤下的公告?'))write('DELETE',path,{},'公告记录已删除')},!available))}
62
+ row.append(summary,status,when,actions);list.append(row)}}
63
+ function render(state){snapshot=state;renderOverview(state);renderAnnouncements(state);el('saveLimit').disabled=state.relayAvailable===false;if(document.activeElement!==el('limit'))el('limit').value=state.maxRelaySessions??''}
64
+ async function load(){try{render(await request('/admin/api/status'));setResult('')}catch(error){snapshot=null;el('healthText').textContent='状态暂不可用';el('healthText').className='state down';for(const id of ['metrics','serverMetrics','throughput','announcementList'])el(id).replaceChildren();el('trafficTimeline').hidden=true;el('trafficUsage').textContent='—';el('announcementCount').textContent='暂时无法读取';el('saveLimit').disabled=true;setResult(error.message,true)}}
65
+ async function write(method,path,body,message){if(writing)return false;writing=true;try{await request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)});await load();setResult(message);return true}catch(error){setResult(error.message,true);if(el('announcementEditor').open){el('editorError').textContent=error.message;el('editorError').hidden=false}return false}finally{writing=false}}
66
+ let usageView='user',usagePage=1,usageTotal=0;
67
+ function usageDuration(value){const seconds=Math.floor(Number(value)||0),hours=Math.floor(seconds/3600),minutes=Math.floor(seconds%3600/60),rest=seconds%60;return hours?hours+' 小时 '+minutes+' 分钟':minutes?minutes+' 分钟 '+rest+' 秒':rest+' 秒'}
68
+ function usageTime(value){return value?new Date(value.replace(' ','T')+'Z').toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}):'—'}
69
+ async function loadUsage(){const view=usageView,page=usagePage;el('usageError').textContent='';el('usageCount').textContent='正在读取';try{const result=await request('/admin/api/usage?view='+view+'&page='+page);if(view!==usageView||page!==usagePage||el('usage').hidden)return;usageTotal=result.total;el('usageCount').textContent='今日有记录:'+usageTotal+(view==='user'?' 个用户':' 台工作站')+' · 北京时间';el('usagePageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(usageTotal/30))+' 页';el('usagePrev').disabled=page<=1;el('usageNext').disabled=page*30>=usageTotal;const rows=el('usageRows');rows.replaceChildren();if(!result.rows.length){rows.append(node('div','empty-note','今日暂无计时记录'));return}for(const item of result.rows){const row=node('div','usage-row');const counts=' · 消息 '+Number(item.message_count||0)+' · 轮次 '+Number(item.turn_count||0);const heading=node('div','usage-row-head');const title=node('span','',view==='user'?item.email:'工作站 …'+item.id.slice(-8));title.title=view==='user'?item.email:item.id;heading.append(title,node('small','hint','最后使用 '+usageTime(item.last_used_at)));row.append(heading,node('strong','',view==='user'?'任务 '+usageDuration(item.today_task_seconds)+counts:'任务 '+usageDuration(item.today_task_seconds)+' · 免费账号 '+usageDuration(item.today_free_task_seconds)+counts));rows.append(row)}}catch(error){if(view===usageView&&page===usagePage){usageTotal=0;el('usageCount').textContent='统计暂不可用';el('usageRows').replaceChildren();el('usagePageText').textContent='';el('usagePrev').disabled=true;el('usageNext').disabled=true;el('usageError').textContent=error.message}}}
70
+ function setUsageView(view){if(usageView===view)return;usageView=view;usagePage=1;el('usageUsers').setAttribute('aria-pressed',String(view==='user'));el('usageComputers').setAttribute('aria-pressed',String(view==='computer'));void loadUsage()}
71
+ el('usageUsers').onclick=()=>setUsageView('user');el('usageComputers').onclick=()=>setUsageView('computer');el('usageRefresh').onclick=()=>loadUsage();el('usagePrev').onclick=()=>{if(usagePage>1){usagePage--;void loadUsage()}};el('usageNext').onclick=()=>{if(usagePage*30<usageTotal){usagePage++;void loadUsage()}};
72
+ function showPage(page){for(const button of document.querySelectorAll('[data-page]'))button.setAttribute('aria-current',button.dataset.page===page?'page':'false');for(const section of document.querySelectorAll('.page'))section.hidden=section.id!==page;history.replaceState(null,'','#'+page);if(page==='usage')void loadUsage();else void load()}
73
+ for(const button of document.querySelectorAll('[data-page]'))button.addEventListener('click',()=>showPage(button.dataset.page));
74
+ el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{maxRelaySessions:Number(el('limit').value)},'转发容量已保存'));
75
+ el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
76
+ async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
77
+ el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
78
+ showPage(['overview','announcements','settings','usage'].includes(location.hash.slice(1))?location.hash.slice(1):'overview');setInterval(()=>{if(!document.hidden&&!el('overview').hidden)load()},5000);
79
+ </script></body></html>`.replaceAll('/admin/api/', basePath + '/api/');
80
+ }
@@ -1,9 +1,14 @@
1
- // Shared semantic resources for the standalone relay management pages.
2
- export const adminUiTokens = `:root{
3
- --admin-page:#f5f7fa;--admin-text:#26332b;--admin-muted:#607168;
4
- --admin-surface:#ffffffee;--admin-row:#f8faf8;
5
- --admin-border:#d5ddd8;--admin-row-border:#dce5df;
6
- --admin-accent:#286e43;--admin-accent-surface:#e0f0e5;
7
- --admin-error:#a5443b;--admin-card-radius:16px;--admin-control-radius:9px;
8
- --admin-card-padding:22px;
9
- }`;
1
+ // Shared semantic resources for the standalone relay management pages.
2
+ export const adminUiTokens = `:root{
3
+ --admin-page:#f5f7fa;--admin-text:#26332b;--admin-muted:#607168;
4
+ --admin-surface:#ffffffee;--admin-row:#f8faf8;
5
+ --admin-border:#d5ddd8;--admin-row-border:#dce5df;
6
+ --admin-accent:#286e43;--admin-accent-surface:#e0f0e5;
7
+ --admin-error:#a5443b;--admin-card-radius:16px;--admin-control-radius:9px;
8
+ --admin-card-padding:22px;
9
+ --admin-footer-caption:12px;--admin-footer-opacity:0.75;--admin-footer-space:24px;
10
+ }`;
11
+
12
+ export const adminFooterStyles = `body{display:flex;flex-direction:column}
13
+ body>main{width:100%;flex:1;margin:0 auto}
14
+ .admin-footer{flex-shrink:0;padding:0 var(--admin-footer-space) var(--admin-footer-space);text-align:center;color:var(--admin-muted);font-size:var(--admin-footer-caption);opacity:var(--admin-footer-opacity)}`;
@@ -3,6 +3,8 @@ import { EventEmitter } from 'node:events';
3
3
  import fs from 'node:fs';
4
4
  import WebSocket from 'ws';
5
5
  import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
6
+ import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
7
+ import { attachRtcFile } from './rtc-file-bridge.mjs';
6
8
  import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
7
9
  import { createWindowsRouteHelper } from './windows-route-helper.mjs';
8
10
 
@@ -176,6 +178,15 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
176
178
  });
177
179
  receiver.on('message', (bytes, binary) => {
178
180
  try {
181
+ if (binary) {
182
+ const { session, data } = parseFileBytes(bytes);
183
+ const socket = fileLocals.get(session);
184
+ if (socket?.readyState === WebSocket.OPEN) {
185
+ if (socket.bufferedAmount > 1024 * 1024) { socket.close(1013, 'File receiver too slow'); return; }
186
+ socket.send(data);
187
+ }
188
+ return;
189
+ }
179
190
  const message = decode(bytes, binary);
180
191
  if (message.type === 'file-host-ready') return;
181
192
  if (message.type === 'file-open') {
@@ -184,7 +195,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
184
195
  loopbackUrl(url.href, '/api/file-transfer');
185
196
  const socket = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
186
197
  fileLocals.set(message.session, socket);
187
- socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
198
+ socket.on('open', () => send(socket, { type: 'auth', token: connection.token, purpose: message.purpose }));
188
199
  socket.on('error', error => events.emit('diagnostic', `Local file receiver: ${error.message}`));
189
200
  socket.on('close', () => {
190
201
  if (fileLocals.get(message.session) !== socket) return;
@@ -192,12 +203,17 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
192
203
  send(receiver, { type: 'file-close', session: message.session });
193
204
  });
194
205
  socket.on('message', (data, isBinary) => {
195
- if (isBinary || fileLocals.get(message.session) !== socket) return socket.terminate();
196
- send(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
206
+ if (fileLocals.get(message.session) !== socket) return socket.terminate();
207
+ if (receiver.readyState !== WebSocket.OPEN || receiver.bufferedAmount > 1024 * 1024) return socket.close(1013, 'File receiver too slow');
208
+ if (isBinary) receiver.send(routedFileBytes(message.session, data));
209
+ else send(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
197
210
  });
198
211
  } else if (message.type === 'file-frame') {
199
212
  const socket = fileLocals.get(message.session);
200
- if (socket?.readyState === WebSocket.OPEN) socket.send(message.payload);
213
+ if (socket?.readyState === WebSocket.OPEN) {
214
+ if (socket.bufferedAmount > 1024 * 1024) { socket.close(1013, 'File receiver too slow'); return; }
215
+ socket.send(message.payload);
216
+ }
201
217
  } else if (message.type === 'file-close') fileLocals.get(message.session)?.terminate();
202
218
  else throw new Error('Invalid file receiver frame');
203
219
  } catch (error) { events.emit('diagnostic', error.message); receiver.close(1008, 'Invalid file frame'); }
@@ -227,7 +243,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
227
243
  resolve(message); return;
228
244
  }
229
245
  if (!registered) throw new Error('Relay not registered');
230
- if (message.type === 'pair-code' || message.type === 'revoked' || message.type === 'status') {
246
+ if (['pair-code', 'revoked', 'status', 'invite-create', 'invite-list', 'invite-revoke'].includes(message.type)) {
231
247
  if (operation && message.commandId === operation.id && message.type === operation.responseType) {
232
248
  clearTimeout(operation.timer); const pending = operation; operation = undefined;
233
249
  message.error ? pending.reject(new Error(message.error)) : pending.resolve(message);
@@ -360,6 +376,8 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
360
376
  version: 1, probeId, ...signal });
361
377
  },
362
378
  onData: payload => current.forwardToBridge(payload),
379
+ onFileChannel: channel => attachRtcFile(channel, bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken },
380
+ () => locals.get(message.session) === current && current.ready && current.probeId === probeId && remote.readyState === WebSocket.OPEN),
363
381
  report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
364
382
  })).catch(() => {
365
383
  events.emit('diagnostic', p2pWindowsRouteHelper ? 'Windows RTC 路由辅助不可用' : 'WebRTC probe initialization failed');
@@ -27,6 +27,12 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
27
27
  const provider = providerOverride || tencentAccountProvider(config);
28
28
  const codeHash = (email, code) => createHmac('sha256', config.codePepper).update(`${email}\0${code}`).digest('hex');
29
29
  const scopeHash = value => createHmac('sha256', config.codePepper).update(value).digest('hex');
30
+ const review = config.reviewLogin?.enabled === true ? config.reviewLogin : null;
31
+ if (review && (review.email !== normalizeEmail(review.email) || !/^[a-f0-9]{64}$/.test(review.codeHash || '')
32
+ || !Number.isFinite(Date.parse(review.startsAt)) || !Number.isFinite(Date.parse(review.expiresAt))
33
+ || Date.parse(review.startsAt) >= Date.parse(review.expiresAt))) {
34
+ throw new Error('Invalid review login configuration');
35
+ }
30
36
 
31
37
  async function requestCode({ email: rawEmail, ticket, randstr }, ip) {
32
38
  const email = normalizeEmail(rawEmail);
@@ -78,17 +84,33 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
78
84
  const db = await pool.getConnection();
79
85
  try {
80
86
  await db.beginTransaction();
81
- const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
82
- const challenge = challenges[0];
83
- if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
84
- const supplied = Buffer.from(codeHash(email, code), 'hex');
85
- if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
86
- if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
87
- else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
87
+ const reviewActive = review?.email === email && Date.now() >= Date.parse(review.startsAt)
88
+ && Date.now() < Date.parse(review.expiresAt);
89
+ let reviewLogin = false;
90
+ if (reviewActive) {
91
+ // Reuse the persisted rate-limit table so restarts cannot reset guesses.
92
+ const windowStart = sqlTime(new Date(Math.floor(Date.now() / 3600000) * 3600000).toISOString()).slice(0, 19);
93
+ const key = scopeHash(`review-login:${email}`);
94
+ await db.execute('INSERT INTO email_send_limits (scope_hash,window_start,request_count) VALUES (?,?,1) ON DUPLICATE KEY UPDATE request_count=request_count+1', [key, windowStart]);
95
+ const [attempts] = await db.execute('SELECT request_count FROM email_send_limits WHERE scope_hash=? AND window_start=? FOR UPDATE', [key, windowStart]);
88
96
  await db.commit();
89
- throw error(401, 'Code expired or incorrect');
97
+ if (attempts[0].request_count > 10) throw error(429, 'Too many login attempts');
98
+ await db.beginTransaction();
99
+ reviewLogin = timingSafeEqual(Buffer.from(codeHash(email, code), 'hex'), Buffer.from(review.codeHash, 'hex'));
100
+ }
101
+ if (!reviewLogin) {
102
+ const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
103
+ const challenge = challenges[0];
104
+ if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
105
+ const supplied = Buffer.from(codeHash(email, code), 'hex');
106
+ if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
107
+ if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
108
+ else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
109
+ await db.commit();
110
+ throw error(401, 'Code expired or incorrect');
90
111
  }
91
112
  await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
113
+ }
92
114
  const [identities] = await db.execute(`SELECT u.id,u.status FROM user_login_identities i
93
115
  JOIN users u ON u.id=i.user_id WHERE i.kind='email' AND i.normalized_value=?`, [email]);
94
116
  let userId = identities[0]?.id;
@@ -101,10 +123,13 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
101
123
  VALUES (?,'email',?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))`, [userId, email]);
102
124
  }
103
125
  const token = randomBytes(32).toString('base64url');
126
+ const expiresAt = Math.min(Date.now() + 30 * 86400000, reviewLogin ? Date.parse(review.expiresAt) : Infinity);
127
+ if (expiresAt <= Date.now()) throw error(401, 'Code expired or incorrect');
104
128
  await db.execute(`INSERT INTO user_sessions (token_hash,user_id,created_at,expires_at)
105
- VALUES (?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3) + INTERVAL 30 DAY)`, [tokenHash(token), userId]);
129
+ VALUES (?,?,UTC_TIMESTAMP(3),?)`, [tokenHash(token), userId, sqlTime(new Date(expiresAt).toISOString())]);
106
130
  await db.commit();
107
- return { token, user: { id: userId, email }, expiresInSeconds: 30 * 86400 };
131
+ if (reviewLogin) console.info(JSON.stringify({ event: 'review-login', userId, expiresAt: new Date(expiresAt).toISOString() }));
132
+ return { token, user: { id: userId, email }, expiresInSeconds: Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) };
108
133
  } catch (failure) { await db.rollback(); throw failure; }
109
134
  finally { db.release(); }
110
135
  }
@@ -0,0 +1,13 @@
1
+ // The relay adds the authenticated session only on its workstation-facing leg.
2
+ // Phones never select another client's session.
3
+ const MAX_BYTES = 256 * 1024;
4
+ export function routedFileBytes(session, bytes) {
5
+ if (!/^[a-f0-9-]{36}$/.test(session) || !bytes.length || bytes.length > MAX_BYTES) throw new Error('Invalid file frame');
6
+ return Buffer.concat([Buffer.from(session, 'ascii'), bytes]);
7
+ }
8
+ export function parseFileBytes(bytes) {
9
+ if (bytes.length <= 36 || bytes.length > MAX_BYTES + 36) throw new Error('Invalid file frame');
10
+ const session = bytes.subarray(0, 36).toString('ascii');
11
+ if (!/^[a-f0-9-]{36}$/.test(session)) throw new Error('Invalid file session');
12
+ return { session, data: bytes.subarray(36) };
13
+ }