dsh-bridge-gateway 0.2.2 → 0.3.0

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 (45) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +17 -3
  3. package/README.zh.md +18 -2
  4. package/assets/apple-touch-icon-180.png +0 -0
  5. package/assets/icon-192.png +0 -0
  6. package/assets/icon-512.png +0 -0
  7. package/assets/icon-maskable-192.png +0 -0
  8. package/assets/icon-maskable-512.png +0 -0
  9. package/client/client.js +302 -41
  10. package/client/index.js +438 -42
  11. package/docs/banner.jpg +0 -0
  12. package/docs/custom-tunnel.md +5 -5
  13. package/docs/platform-abstraction-design.md +1 -1
  14. package/docs/qq-usage.md +6 -6
  15. package/docs/screenshots/remote-auth-login-light.jpg +0 -0
  16. package/docs/screenshots/remote-auth-login.jpg +0 -0
  17. package/docs/wechat-usage.md +1 -1
  18. package/lib/auth/brand-assets.js +131 -0
  19. package/lib/auth/login-template.js +503 -361
  20. package/lib/auth/manager.js +47 -12
  21. package/lib/auth/theme-tokens.js +142 -0
  22. package/lib/bridge-rpc.js +2 -2
  23. package/lib/cloudflared-manager.mjs +1 -1
  24. package/lib/feishu/gateway.js +17 -17
  25. package/lib/feishu/index.js +3 -3
  26. package/lib/feishu/lark-bundled.mjs +288 -469
  27. package/lib/feishu/node.js +7 -7
  28. package/lib/gateway/cert.js +1 -1
  29. package/lib/index.js +210 -80
  30. package/lib/paths.js +43 -14
  31. package/lib/platform/base.js +2 -2
  32. package/lib/platform/conversation-bridge.js +17 -17
  33. package/lib/platform/index.js +1 -1
  34. package/lib/platform/manager.js +4 -4
  35. package/lib/qq/gateway.js +10 -10
  36. package/lib/qq/index.js +11 -11
  37. package/lib/qq/node.js +17 -17
  38. package/lib/telegram/gateway.js +10 -10
  39. package/lib/telegram/index.js +3 -3
  40. package/lib/telegram/node.js +5 -5
  41. package/lib/wechat/gateway.js +6 -6
  42. package/lib/wechat/index.js +4 -4
  43. package/lib/wechat/media.js +1 -1
  44. package/lib/wechat/node.js +13 -13
  45. package/package.json +6 -3
@@ -1,382 +1,524 @@
1
1
  // lib/auth/login-template.js
2
- // DeepSeek Harness 官方质感高颜值远程访问认证登录页(零外部 CDN 依赖,响应式适配手机与桌面)
2
+ // ---------------------------------------------------------------------------
3
+ // DSH 远程访问认证登录页(A′ 门厅式)
4
+ // ---------------------------------------------------------------------------
5
+ // 设计目标:让远程访客第一眼就认出这是 DSH 的入口,而不是一个通用安全卡片。
6
+ // 品牌资产来自 DSH 本体(见 brand-assets.js),配色走 DSH 设计系统快照
7
+ // (见 theme-tokens.js)。
8
+ //
9
+ // 三条硬约束(改动前务必先读):
10
+ //
11
+ // 1. 零外部请求。字体、图标、样式、脚本全部内联或同源。登录页是公网第一触面,
12
+ // 任何第三方 CDN 都等于把可用性与隐私外包出去。DSH 官方包里的 Montserrat
13
+ // 品牌字体有意不引(省 60–90KB,且标题用系统字体栈已足够)。
14
+ //
15
+ // 2. 浏览器下限 Chrome 100。依据 client/build.mjs 的 target: ['chrome100'],
16
+ // 以及 2026-10-11 统信 UOS / 360 浏览器(Chrome 108 内核)实测。
17
+ // → 禁用:dvh / :has() / color-mix() / @container / CSS 嵌套 /
18
+ // text-wrap:balance / @starting-style / light-dark() / View Transitions。
19
+ // 动效一律用 SVG SMIL(Chrome 100 原生支持,不需要 polyfill)。
20
+ //
21
+ // 3. 无死参数。renderLoginPage 的每个入参都必须在模板里真正被使用,
22
+ // 否则就是又一个"服务器算好了但页面不理会"的缺陷(历史教训:locked /
23
+ // hasPassword 曾是死参数,锁定态仍渲染完全可用的表单)。
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import { whaleSvg } from './brand-assets.js';
27
+ import { renderTokenCss } from './theme-tokens.js';
28
+
29
+ /** 防护范围 → 副标题文案(scope 是活参数,不是装饰)。 */
30
+ const SCOPE_SUBTITLE = {
31
+ all: '远程安全访问认证',
32
+ public_only: '公网访问认证',
33
+ lan_only: '局域网访问认证',
34
+ };
35
+
36
+ /** 网关仓库地址(页脚外链,同源之外唯一的外链目标,仅为 <a href>)。 */
37
+ const REPO_URL = 'https://github.com/lament-z/dsh-bridge-gateway';
38
+
39
+ function escapeHtml(value) {
40
+ return String(value ?? '')
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&#39;');
46
+ }
47
+
48
+ /**
49
+ * 渲染远程认证登录页。
50
+ *
51
+ * 状态完全由入参推导,不接收自由文本错误 —— 服务端在渲染本页时唯一已知的
52
+ * 异常就是 IP 被限流(由 locked + lockRemainingMs 表达),其余错误都发生在
53
+ * 提交之后,由页面内 JS 填入 #alert。少一个"谁都不传"的参数,就少一处死代码。
54
+ *
55
+ * @param {object} [opts]
56
+ * @param {'token_and_password'|'password_only'|'token_only'} [opts.mode='token_and_password']
57
+ * 认证模式。决定表单区渲染:token_only 不渲染密码框(否则是永远失败的死路)。
58
+ * @param {'all'|'public_only'|'lan_only'} [opts.scope='all'] 防护范围,用于副标题。
59
+ * @param {boolean} [opts.hasPassword=true] 是否已设置访问密码。
60
+ * password_only 且未设密码时改为引导卡,而不是让人对着必然失败的输入框。
61
+ * @param {boolean} [opts.locked=false] 当前来源 IP 是否处于暴力破解锁定期。
62
+ * @param {number} [opts.lockRemainingMs=0] 锁定剩余毫秒,用于倒计时与按钮置灰。
63
+ * 由服务端按 AuthManager 的 LOCKOUT_PERIOD_MS 计算后传入;模板**不再自带
64
+ * 默认时长** —— 手抄一份 60 秒副本,会在上游把锁定时长改大时让页面提前解除置灰。
65
+ * @param {boolean} [opts.isLocal=false] 是否来自本机/局域网。
66
+ * 仅在此情况下展示 `reset-auth` 救急路径 —— 避免向匿名公网访客披露宿主内部路径。
67
+ * @param {string} [opts.version=''] 网关版本号,展示在页脚便于远程报障。
68
+ * @returns {string} 完整 HTML 文档
69
+ */
70
+ export function renderLoginPage({
71
+ mode = 'token_and_password',
72
+ scope = 'all',
73
+ hasPassword = true,
74
+ locked = false,
75
+ lockRemainingMs = 0,
76
+ isLocal = false,
77
+ version = '',
78
+ } = {}) {
79
+ const subtitle = SCOPE_SUBTITLE[scope] || SCOPE_SUBTITLE.all;
80
+
81
+ // ---- 表单区形态判定(消灭两条死路)----
82
+ const isTokenOnly = mode === 'token_only';
83
+ const needsSetup = mode === 'password_only' && !hasPassword;
84
+ const showPasswordForm = !isTokenOnly && !needsSetup;
85
+
86
+ // 锁定态只在"确实还在锁定期"且"有密码表单可提交"时才有意义:
87
+ // - 剩余时长为 0 或非法 = 锁已到期,按未锁定处理(不置灰、不提示);
88
+ // 兜底成一个凭空的 60 秒会让用户白等,且与"服务端才是真源"矛盾。
89
+ // - token_only / 未设密码:压根没有可提交的东西,提示"稍后重试"是误导;
90
+ // 而且这两种形态下倒计时元素不存在,硬渲染会永久显示 `--`。
91
+ const lockMs = Number.isFinite(Number(lockRemainingMs)) && Number(lockRemainingMs) > 0
92
+ ? Math.floor(Number(lockRemainingMs))
93
+ : 0;
94
+ const lockActive = Boolean(locked) && lockMs > 0;
95
+ const showLockNote = lockActive && showPasswordForm;
96
+
97
+ const footVersion = version ? ` · v${escapeHtml(version)}` : '';
98
+
99
+ // #alert 必须无条件存在:倒计时结束后用户仍可能提交失败,届时靠它显示错误。
100
+ // 锁定提示是独立的一条,与 #alert 并列。
101
+ const statusArea = `${showLockNote
102
+ ? `<div class="note note-warn" role="alert">
103
+ <span class="note-icon" aria-hidden="true">&#9203;</span>
104
+ <span>尝试次数过多,请在 <strong id="lockCountdown">--</strong> 秒后重试</span>
105
+ </div>`
106
+ : ''}<div class="note note-error" id="alert" role="alert" hidden>
107
+ <span class="note-icon" aria-hidden="true">&#9888;</span>
108
+ <span id="alertMsg"></span>
109
+ </div>`;
110
+
111
+
112
+ let formArea;
113
+ if (isTokenOnly) {
114
+ formArea = `<div class="guide">
115
+ <div class="guide-title">当前仅允许专属安全 Token 访问</div>
116
+ <p class="guide-body">管理员已关闭密码登录。请使用带 <code>?auth=</code> 参数的专属链接,或在电脑本机的网关控制台扫码获取。</p>
117
+ </div>`;
118
+ } else if (needsSetup) {
119
+ formArea = `<div class="guide">
120
+ <div class="guide-title">管理员尚未设置访问密码</div>
121
+ <p class="guide-body">当前为「仅密码」模式但密码为空,暂时无法通过密码进入。请在运行本网关的电脑本机打开 DSH,于「设置 → 远程访问」中设置访问密码。</p>
122
+ </div>`;
123
+ } else {
124
+ const tokenHint = mode === 'token_and_password'
125
+ ? `<p class="hint">也可使用带 <code>?auth=</code> 参数的专属链接直接进入。</p>`
126
+ : '';
127
+ formArea = `<form id="loginForm" novalidate>
128
+ <div class="field">
129
+ <label for="password">访问密码 / PIN 码</label>
130
+ <div class="input-wrap">
131
+ <input type="password" id="password" name="password" placeholder="请输入管理员设置的访问密码"
132
+ autocomplete="current-password" spellcheck="false" required${lockActive ? ' disabled' : ''}>
133
+ <button type="button" class="eye" id="eyeBtn" aria-label="显示密码" aria-pressed="false" aria-controls="password"${lockActive ? ' disabled' : ''}>
134
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
135
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>
136
+ </svg>
137
+ </button>
138
+ </div>
139
+ </div>
140
+ <button type="submit" id="submitBtn" class="cta"${lockActive ? ' disabled' : ''}>
141
+ <span class="spinner" id="btnSpinner" aria-hidden="true"></span>
142
+ <span id="btnText">解锁并访问</span>
143
+ </button>
144
+ ${tokenHint}
145
+ </form>`;
146
+ }
147
+
148
+ // ---- 帮助区:仅密码表单下才有意义;救急路径仅对本机/局域网可见 ----
149
+ const helpArea = showPasswordForm
150
+ ? `<div class="help">
151
+ <button type="button" class="help-toggle" id="helpToggle" aria-expanded="false" aria-controls="helpPanel">忘记密码怎么办?</button>
152
+ <div class="help-panel" id="helpPanel" hidden>
153
+ <p><strong>电脑本机免密直连</strong>:在运行本网关的电脑上打开 DSH,物理机享有永久免密特权,可直接修改或清除密码。</p>
154
+ ${isLocal
155
+ ? `<p><strong>服务器救急指令</strong>:在宿主电脑终端执行
156
+ <code>touch ~/.dsh/dsh-bridge-gateway/reset-auth</code> 即可恢复初始免密状态。</p>`
157
+ : `<p>远程设备无法自助重置,请联系部署者在本机处理。</p>`}
158
+ </div>
159
+ </div>`
160
+ : '';
161
+
162
+ const bootConfig = JSON.stringify({
163
+ lockActive,
164
+ lockMs,
165
+ showPasswordForm,
166
+ }).replace(/</g, '\\u003c');
3
167
 
4
- export function renderLoginPage({ error = '', hasPassword = true, locked = false } = {}) {
5
168
  return `<!DOCTYPE html>
6
169
  <html lang="zh-CN">
7
170
  <head>
8
- <meta charset="UTF-8">
9
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
10
- <meta name="apple-mobile-web-app-capable" content="yes">
11
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
12
- <meta name="mobile-web-app-capable" content="yes">
13
- <meta name="theme-color" content="#121413">
14
- <title>远程访问认证 - DeepSeek Harness</title>
15
- <style>
16
- :root {
17
- --bg: #121413;
18
- --card-bg: rgba(26, 29, 27, 0.85);
19
- --card-border: rgba(255, 255, 255, 0.08);
20
- --brand: #22c55e;
21
- --brand-hover: #16a34a;
22
- --brand-glow: rgba(34, 197, 94, 0.25);
23
- --text: #f3f4f6;
24
- --text-secondary: #9ca3af;
25
- --input-bg: rgba(18, 20, 19, 0.7);
26
- --input-border: rgba(255, 255, 255, 0.12);
27
- --error-bg: rgba(239, 68, 68, 0.12);
28
- --error-border: rgba(239, 68, 68, 0.3);
29
- --error-text: #f87171;
30
- }
31
-
32
- * {
33
- box-sizing: border-box;
34
- margin: 0;
35
- padding: 0;
36
- -webkit-tap-highlight-color: transparent;
37
- }
38
-
39
- body {
40
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
41
- background-color: var(--bg);
42
- background-image:
43
- radial-gradient(circle at 50% 0%, rgba(34, 197, 94, 0.12) 0%, transparent 50%),
44
- radial-gradient(circle at 100% 100%, rgba(16, 185, 129, 0.05) 0%, transparent 40%);
45
- color: var(--text);
46
- min-height: 100vh;
47
- display: flex;
48
- align-items: center;
49
- justify-content: center;
50
- padding: 20px;
51
- }
52
-
53
- .container {
54
- width: 100%;
55
- max-width: 400px;
56
- perspective: 1000px;
57
- }
58
-
59
- .card {
60
- background: var(--card-bg);
61
- backdrop-filter: blur(20px);
62
- -webkit-backdrop-filter: blur(20px);
63
- border: 1px solid var(--card-border);
64
- border-radius: 20px;
65
- padding: 36px 28px;
66
- box-shadow:
67
- 0 20px 40px -15px rgba(0, 0, 0, 0.5),
68
- 0 0 0 1px rgba(255, 255, 255, 0.05);
69
- animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
70
- }
71
-
72
- @keyframes fadeIn {
73
- from { opacity: 0; transform: translateY(16px) scale(0.98); }
74
- to { opacity: 1; transform: translateY(0) scale(1); }
75
- }
76
-
77
- .logo-area {
78
- text-align: center;
79
- margin-bottom: 28px;
80
- }
81
-
82
- .logo-icon {
83
- width: 56px;
84
- height: 56px;
85
- margin: 0 auto 16px;
86
- border-radius: 16px;
87
- background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.05));
88
- border: 1px solid rgba(34, 197, 94, 0.3);
89
- display: flex;
90
- align-items: center;
91
- justify-content: center;
92
- box-shadow: 0 8px 16px -4px var(--brand-glow);
93
- }
94
-
95
- .logo-icon svg {
96
- width: 32px;
97
- height: 32px;
98
- fill: var(--brand);
99
- }
100
-
101
- h1 {
102
- font-size: 20px;
103
- font-weight: 600;
104
- letter-spacing: -0.01em;
105
- color: #ffffff;
106
- margin-bottom: 6px;
107
- }
108
-
109
- .subtitle {
110
- font-size: 13px;
111
- color: var(--text-secondary);
112
- line-height: 1.5;
113
- }
114
-
115
- .alert {
116
- background: var(--error-bg);
117
- border: 1px solid var(--error-border);
118
- color: var(--error-text);
119
- padding: 10px 14px;
120
- border-radius: 10px;
121
- font-size: 13px;
122
- margin-bottom: 20px;
123
- display: flex;
124
- align-items: center;
125
- gap: 8px;
126
- animation: shake 0.4s ease;
127
- }
128
-
129
- @keyframes shake {
130
- 0%, 100% { transform: translateX(0); }
131
- 20%, 60% { transform: translateX(-6px); }
132
- 40%, 80% { transform: translateX(6px); }
133
- }
134
-
135
- .form-group {
136
- margin-bottom: 20px;
137
- }
138
-
139
- label {
140
- display: block;
141
- font-size: 12px;
142
- font-weight: 500;
143
- color: var(--text-secondary);
144
- margin-bottom: 8px;
145
- text-transform: uppercase;
146
- letter-spacing: 0.05em;
147
- }
148
-
149
- .input-wrapper {
150
- position: relative;
151
- display: flex;
152
- align-items: center;
153
- }
154
-
155
- input[type="password"], input[type="text"] {
156
- width: 100%;
157
- background: var(--input-bg);
158
- border: 1px solid var(--input-border);
159
- border-radius: 12px;
160
- padding: 14px 44px 14px 16px;
161
- font-size: 15px;
162
- color: #ffffff;
163
- outline: none;
164
- transition: all 0.2s ease;
165
- font-family: inherit;
166
- }
167
-
168
- input:focus {
169
- border-color: var(--brand);
170
- box-shadow: 0 0 0 3px var(--brand-glow);
171
- background: rgba(18, 20, 19, 0.9);
172
- }
173
-
174
- .toggle-pwd {
175
- position: absolute;
176
- right: 12px;
177
- background: none;
178
- border: none;
179
- cursor: pointer;
180
- color: var(--text-secondary);
181
- padding: 6px;
182
- display: flex;
183
- align-items: center;
184
- justify-content: center;
185
- border-radius: 6px;
186
- transition: color 0.2s;
187
- }
188
-
189
- .toggle-pwd:hover {
190
- color: #ffffff;
191
- }
192
-
193
- .submit-btn {
194
- width: 100%;
195
- background: var(--brand);
196
- color: #0b150f;
197
- border: none;
198
- border-radius: 12px;
199
- padding: 14px;
200
- font-size: 15px;
201
- font-weight: 600;
202
- cursor: pointer;
203
- display: flex;
204
- align-items: center;
205
- justify-content: center;
206
- gap: 8px;
207
- transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
208
- box-shadow: 0 4px 12px var(--brand-glow);
209
- }
210
-
211
- .submit-btn:hover {
212
- background: var(--brand-hover);
213
- transform: translateY(-1px);
214
- box-shadow: 0 6px 16px rgba(34, 197, 94, 0.35);
215
- }
216
-
217
- .submit-btn:active {
218
- transform: translateY(1px);
219
- }
220
-
221
- .submit-btn:disabled {
222
- opacity: 0.6;
223
- cursor: not-allowed;
224
- transform: none;
225
- }
171
+ <meta charset="UTF-8">
172
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
173
+ <meta name="apple-mobile-web-app-capable" content="yes">
174
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
175
+ <meta name="mobile-web-app-capable" content="yes">
176
+ <meta name="robots" content="noindex, nofollow">
177
+ <meta name="theme-color" content="#f5f6f7" media="(prefers-color-scheme: light)">
178
+ <meta name="theme-color" content="#151517" media="(prefers-color-scheme: dark)">
179
+ <title>远程访问认证 - DeepSeek Harness</title>
180
+ <style>
181
+ ${renderTokenCss()}
182
+ *, *::before, *::after { box-sizing: border-box; }
183
+ html, body { margin: 0; padding: 0; }
184
+ body {
185
+ font-family: var(--gw-font);
186
+ background-color: var(--gw-bg);
187
+ color: var(--gw-label);
188
+ min-height: 100vh;
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
193
+ -webkit-font-smoothing: antialiased;
194
+ -webkit-tap-highlight-color: transparent;
195
+ }
196
+ .gate { width: 100%; max-width: 400px; }
197
+
198
+ /* ---------- 品牌区 ---------- */
199
+ .brand { text-align: center; margin-bottom: 24px; }
200
+ .whale { display: block; margin: 0 auto 14px; color: var(--gw-accent); }
201
+ h1 {
202
+ font-size: 20px; font-weight: 600; line-height: 1.3; margin: 0 0 6px;
203
+ color: var(--gw-label); letter-spacing: -0.01em;
204
+ }
205
+ .sub { font-size: 13px; line-height: 1.5; margin: 0; color: var(--gw-label-2); }
206
+
207
+ /* ---------- 卡片 ---------- */
208
+ .card {
209
+ background: var(--gw-layer-2);
210
+ border: 1px solid var(--gw-border);
211
+ border-radius: var(--gw-radius-xl);
212
+ padding: 24px 22px;
213
+ box-shadow: 0 12px 32px -18px rgba(0, 0, 0, 0.35);
214
+ animation: rise var(--gw-dur-slow) var(--gw-ease);
215
+ }
216
+ @keyframes rise {
217
+ from { opacity: 0; transform: translateY(10px); }
218
+ to { opacity: 1; transform: none; }
219
+ }
226
220
 
227
- .spinner {
228
- width: 18px;
229
- height: 18px;
230
- border: 2px solid rgba(11, 21, 15, 0.3);
231
- border-top-color: #0b150f;
232
- border-radius: 50%;
233
- animation: spin 0.6s linear infinite;
234
- display: none;
235
- }
221
+ /* ---------- 状态条 ---------- */
222
+ .note {
223
+ display: flex; align-items: flex-start; gap: 8px;
224
+ padding: 10px 12px; border-radius: var(--gw-radius-md);
225
+ font-size: 13px; line-height: 1.5; margin-bottom: 16px;
226
+ border: 1px solid transparent;
227
+ }
228
+ .note[hidden] { display: none; }
229
+ .note-icon { flex: none; line-height: 1.5; }
230
+ .note-error { background: var(--gw-danger-bg); border-color: var(--gw-danger-border); color: var(--gw-danger); }
231
+ .note-warn { background: var(--gw-warn-bg); border-color: var(--gw-border); color: var(--gw-warn); }
232
+ .shake { animation: shake var(--gw-dur-slow) ease; }
233
+ @keyframes shake {
234
+ 0%, 100% { transform: translateX(0); }
235
+ 20%, 60% { transform: translateX(-5px); }
236
+ 40%, 80% { transform: translateX(5px); }
237
+ }
236
238
 
237
- @keyframes spin {
238
- to { transform: rotate(360deg); }
239
- }
239
+ /* ---------- 表单 ---------- */
240
+ .field { margin-bottom: 16px; }
241
+ label {
242
+ display: block; font-size: 12px; font-weight: 500; line-height: 1.4;
243
+ color: var(--gw-label-2); margin-bottom: 8px;
244
+ text-transform: uppercase; letter-spacing: 0.05em;
245
+ }
246
+ .input-wrap { position: relative; display: flex; align-items: center; }
247
+ input[type='password'], input[type='text'] {
248
+ width: 100%; font-family: inherit; font-size: 15px; line-height: 1.4;
249
+ color: var(--gw-label); background: var(--gw-input-bg);
250
+ border: 1px solid var(--gw-border); border-radius: var(--gw-radius-md);
251
+ padding: 13px 44px 13px 14px;
252
+ outline: none;
253
+ transition: border-color var(--gw-dur) var(--gw-ease), box-shadow var(--gw-dur) var(--gw-ease);
254
+ }
255
+ input::placeholder { color: var(--gw-label-3); }
256
+ input:focus {
257
+ border-color: var(--gw-accent);
258
+ box-shadow: 0 0 0 3px var(--gw-focus-ring);
259
+ }
260
+ input:disabled { opacity: 0.6; cursor: not-allowed; }
261
+ .eye {
262
+ position: absolute; right: 8px;
263
+ display: flex; align-items: center; justify-content: center;
264
+ width: 30px; height: 30px; padding: 0;
265
+ background: none; border: none; border-radius: var(--gw-radius-sm);
266
+ color: var(--gw-label-2); cursor: pointer;
267
+ transition: color var(--gw-dur) var(--gw-ease), background-color var(--gw-dur) var(--gw-ease);
268
+ }
269
+ .eye:hover:not(:disabled) { color: var(--gw-label); background: var(--gw-hover); }
270
+ .eye:disabled { opacity: 0.5; cursor: not-allowed; }
271
+
272
+ /* ---------- 主按钮 ---------- */
273
+ .cta {
274
+ width: 100%; font-family: inherit; font-size: 15px; font-weight: 600; line-height: 1.4;
275
+ display: flex; align-items: center; justify-content: center; gap: 8px;
276
+ padding: 13px 16px; border: none; border-radius: var(--gw-radius-md);
277
+ background: var(--gw-accent); color: var(--gw-label-fg);
278
+ cursor: pointer;
279
+ transition: background-color var(--gw-dur) var(--gw-ease), transform var(--gw-dur-fast) var(--gw-ease);
280
+ }
281
+ .cta:hover:not(:disabled) { background: var(--gw-accent-hover); }
282
+ .cta:active:not(:disabled) { transform: translateY(1px); }
283
+ .cta:disabled { opacity: 0.55; cursor: not-allowed; }
284
+ .spinner {
285
+ display: none; width: 16px; height: 16px;
286
+ border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%;
287
+ animation: spin 0.6s linear infinite;
288
+ }
289
+ .spinner.on { display: block; }
290
+ @keyframes spin { to { transform: rotate(360deg); } }
291
+
292
+ .hint { font-size: 12px; line-height: 1.5; color: var(--gw-label-3); margin: 12px 0 0; text-align: center; }
293
+ code {
294
+ font-family: var(--gw-font-mono); font-size: 0.92em;
295
+ background: var(--gw-module); border-radius: var(--gw-radius-xs);
296
+ padding: 2px 5px; color: var(--gw-label-2);
297
+ word-break: break-all;
298
+ }
240
299
 
241
- .footer {
242
- text-align: center;
243
- margin-top: 24px;
244
- font-size: 12px;
245
- color: rgba(255, 255, 255, 0.35);
246
- }
300
+ /* ---------- 引导卡(Token 模式 / 未设密码)---------- */
301
+ .guide { text-align: center; padding: 4px 2px; }
302
+ .guide-title { font-size: 14px; font-weight: 600; color: var(--gw-label); margin-bottom: 8px; }
303
+ .guide-body { font-size: 13px; line-height: 1.7; color: var(--gw-label-2); margin: 0; }
304
+
305
+ /* ---------- 帮助区 ---------- */
306
+ .help { margin-top: 16px; text-align: center; }
307
+ .help-toggle {
308
+ font-family: inherit; font-size: 12px; line-height: 1.5;
309
+ background: none; border: none; padding: 4px 6px;
310
+ color: var(--gw-label-2); cursor: pointer;
311
+ border-radius: var(--gw-radius-sm);
312
+ }
313
+ .help-toggle:hover { color: var(--gw-link); }
314
+ .help-panel {
315
+ margin-top: 12px; padding: 12px 14px; text-align: left;
316
+ background: var(--gw-module); border: 1px solid var(--gw-border);
317
+ border-radius: var(--gw-radius-md);
318
+ font-size: 12px; line-height: 1.7; color: var(--gw-label-2);
319
+ }
320
+ .help-panel[hidden] { display: none; }
321
+ .help-panel p { margin: 0 0 8px; }
322
+ .help-panel p:last-child { margin-bottom: 0; }
323
+ .help-panel strong { color: var(--gw-label); font-weight: 600; }
324
+
325
+ /* ---------- 页脚 ---------- */
326
+ .foot { margin-top: 22px; text-align: center; font-size: 12px; line-height: 1.6; color: var(--gw-label-3); }
327
+ .foot a { color: var(--gw-label-2); text-decoration: none; border-bottom: 1px solid var(--gw-border-strong); }
328
+ .foot a:hover { color: var(--gw-link); }
329
+ .foot-note { margin-top: 4px; font-size: 11px; color: var(--gw-label-3); }
330
+
331
+ /* ---------- 无 JS 兜底 ---------- */
332
+ .noscript {
333
+ margin-top: 16px; padding: 10px 12px; font-size: 12px; line-height: 1.6;
334
+ background: var(--gw-warn-bg); border: 1px solid var(--gw-border);
335
+ border-radius: var(--gw-radius-md); color: var(--gw-warn);
336
+ }
247
337
 
248
- .footer a {
249
- color: var(--text-secondary);
250
- text-decoration: none;
251
- }
252
- </style>
338
+ @media (prefers-reduced-motion: reduce) {
339
+ *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
340
+ }
341
+ @media (max-width: 360px) {
342
+ .card { padding: 20px 16px; }
343
+ h1 { font-size: 18px; }
344
+ }
345
+ </style>
253
346
  </head>
254
347
  <body>
255
- <div class="container">
256
- <div class="card">
257
- <div class="logo-area">
258
- <div class="logo-icon">
259
- <svg viewBox="0 0 24 24">
260
- <path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
261
- </svg>
262
- </div>
263
- <h1>DeepSeek Harness</h1>
264
- <div class="subtitle">远程安全访问认证</div>
265
- </div>
266
-
267
- <div id="errorAlert" class="alert" style="${error ? '' : 'display: none;'}">
268
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
269
- <circle cx="12" cy="12" r="10"></circle>
270
- <line x1="12" y1="8" x2="12" y2="12"></line>
271
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
272
- </svg>
273
- <span id="errorMsg">${error || ''}</span>
274
- </div>
275
-
276
- <form id="loginForm" onsubmit="return handleLogin(event)">
277
- <div class="form-group">
278
- <label for="password">访问密码 / PIN 码</label>
279
- <div class="input-wrapper">
280
- <input type="password" id="password" name="password" placeholder="请输入管理员设置的访问密码" autocomplete="current-password" autofocus required>
281
- <button type="button" class="toggle-pwd" onclick="togglePassword()" title="显示/隐藏密码">
282
- <svg id="eyeIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
283
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
284
- <circle cx="12" cy="12" r="3"></circle>
285
- </svg>
286
- </button>
287
- </div>
288
- </div>
289
-
290
- <button type="submit" id="submitBtn" class="submit-btn">
291
- <span class="spinner" id="btnSpinner"></span>
292
- <span id="btnText">解锁并访问</span>
293
- </button>
294
- </form>
295
-
296
- <div style="margin-top: 16px; text-align: center;">
297
- <a href="javascript:void(0)" onclick="toggleForgotGuide()" style="font-size: 12px; color: var(--text-secondary); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; transition: color 0.2s;">
298
- ❓ 忘记密码怎么办?
299
- </a>
300
- </div>
301
-
302
- <div id="forgotGuide" style="display: none; margin-top: 14px; padding: 12px 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; font-size: 12px; line-height: 1.6; color: var(--text-secondary); text-align: left;">
303
- <div style="font-weight: 600; color: #f3f4f6; margin-bottom: 4px;">🛟 找回与重置密码指引:</div>
304
- <div>1. <strong>电脑本机免密直连</strong>:在运行本程序的电脑本机打开本控制台,物理机享有永久免密特权,可直接修改或清除密码。</div>
305
- <div style="margin-top: 4px;">2. <strong>服务器救急指令</strong>:在宿主电脑终端执行 <code style="background: rgba(0,0,0,0.4); padding: 2px 6px; border-radius: 4px; color: #34d399; font-family: monospace;">touch ~/.dsh/dsh-bridge-gateway/reset-auth</code> 即可瞬间恢复初始免密状态。</div>
306
- </div>
307
-
308
- <div class="footer">
309
- 由 <a href="https://github.com/lament-z/dsh-bridge-gateway" target="_blank">dsh-bridge</a> 安全网关守护
310
- </div>
311
- </div>
348
+ <main class="gate">
349
+ <div class="brand" id="brand">
350
+ ${whaleSvg({ size: 52, fill: 'currentColor' })}
351
+ <h1>DeepSeek Harness</h1>
352
+ <p class="sub">${escapeHtml(subtitle)}</p>
312
353
  </div>
313
354
 
314
- <script>
315
- function toggleForgotGuide() {
316
- const g = document.getElementById('forgotGuide');
317
- g.style.display = g.style.display === 'none' ? 'block' : 'none';
318
- }
319
-
320
- function togglePassword() {
321
- const input = document.getElementById('password');
322
- const icon = document.getElementById('eyeIcon');
323
- if (input.type === 'password') {
324
- input.type = 'text';
325
- icon.innerHTML = '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>';
326
- } else {
327
- input.type = 'password';
328
- icon.innerHTML = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>';
355
+ <section class="card">
356
+ ${statusArea}
357
+ ${formArea}
358
+ ${helpArea}
359
+ <noscript><div class="noscript">本页面需要启用 JavaScript 才能完成认证。</div></noscript>
360
+ </section>
361
+
362
+ <footer class="foot">
363
+ <div>由 <a href="${REPO_URL}" target="_blank" rel="noopener noreferrer">dsh-bridge-gateway</a> 安全网关守护${footVersion}</div>
364
+ <div class="foot-note">第三方网关,非 DeepSeek 官方产品</div>
365
+ </footer>
366
+ </main>
367
+
368
+ <script>
369
+ (function () {
370
+ var CFG = ${bootConfig};
371
+
372
+ function $(id) { return document.getElementById(id); }
373
+
374
+ // ---------- 鲸鱼游动(SVG SMIL,Chrome 100 原生可用)----------
375
+ // 仅在用户与页面交互时启动;尊重 prefers-reduced-motion。
376
+ var reduceMotion = false;
377
+ try {
378
+ reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
379
+ } catch (e) { reduceMotion = false; }
380
+
381
+ var swimEl = $('dsh-whale-swim');
382
+ var canSwim = !reduceMotion && swimEl && typeof swimEl.beginElement === 'function';
383
+
384
+ function startSwim() {
385
+ if (!canSwim) return;
386
+ try { swimEl.beginElement(); } catch (e) { /* 旧内核不支持则静默降级 */ }
387
+ }
388
+ function stopSwim() {
389
+ if (!canSwim) return;
390
+ try { if (typeof swimEl.endElement === 'function') swimEl.endElement(); } catch (e) { /* 同上 */ }
391
+ }
392
+
393
+ var brand = $('brand');
394
+ if (brand) {
395
+ brand.addEventListener('mouseenter', startSwim);
396
+ brand.addEventListener('mouseleave', stopSwim);
397
+ }
398
+
399
+ // ---------- 密码可见性 ----------
400
+ function togglePassword() {
401
+ var input = $('password');
402
+ var btn = $('eyeBtn');
403
+ if (!input || !btn) return;
404
+ var toText = input.type === 'password';
405
+ input.type = toText ? 'text' : 'password';
406
+ btn.setAttribute('aria-label', toText ? '隐藏密码' : '显示密码');
407
+ btn.setAttribute('aria-pressed', toText ? 'true' : 'false');
408
+ btn.innerHTML = toText
409
+ ? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>'
410
+ : '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>';
411
+ }
412
+
413
+ var eyeBtn = $('eyeBtn');
414
+ if (eyeBtn) eyeBtn.addEventListener('click', togglePassword);
415
+
416
+ // ---------- 帮助区折叠 ----------
417
+ var helpToggle = $('helpToggle');
418
+ var helpPanel = $('helpPanel');
419
+ if (helpToggle && helpPanel) {
420
+ helpToggle.addEventListener('click', function () {
421
+ var open = helpPanel.hasAttribute('hidden');
422
+ if (open) { helpPanel.removeAttribute('hidden'); } else { helpPanel.setAttribute('hidden', ''); }
423
+ helpToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
424
+ });
425
+ }
426
+
427
+ // ---------- 锁定倒计时 ----------
428
+ var submitBtn = $('submitBtn');
429
+ var passwordEl = $('password');
430
+
431
+ function showError(msg) {
432
+ var alert = $('alert');
433
+ var msgEl = $('alertMsg');
434
+ if (!alert || !msgEl) return;
435
+ msgEl.textContent = msg;
436
+ alert.removeAttribute('hidden');
437
+ alert.classList.remove('shake');
438
+ void alert.offsetWidth; // 强制 reflow 以重放动画
439
+ alert.classList.add('shake');
440
+ }
441
+
442
+ if (CFG.lockActive) {
443
+ var cd = $('lockCountdown');
444
+ // 用绝对截止时间算剩余,而不是每秒做一次 remain 自减:
445
+ // 浏览器会节流后台标签页的 timer,累减法会让切走再切回的用户看到偏慢的倒计时。
446
+ var deadline = Date.now() + CFG.lockMs;
447
+ var tick = function () {
448
+ var remain = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
449
+ if (cd) cd.textContent = String(remain);
450
+ if (remain <= 0) {
451
+ clearInterval(timer);
452
+ // 锁定解除:恢复表单可用性。服务端每次提交仍会复核,这里只是不让人干等。
453
+ if (submitBtn) submitBtn.removeAttribute('disabled');
454
+ if (passwordEl) passwordEl.removeAttribute('disabled');
455
+ if (eyeBtn) eyeBtn.removeAttribute('disabled');
329
456
  }
330
- }
331
-
332
- async function handleLogin(e) {
333
- e.preventDefault();
334
- const pwd = document.getElementById('password').value;
335
- const btn = document.getElementById('submitBtn');
336
- const spinner = document.getElementById('btnSpinner');
337
- const btnText = document.getElementById('btnText');
338
- const alert = document.getElementById('errorAlert');
339
- const errorMsg = document.getElementById('errorMsg');
340
-
457
+ };
458
+ tick();
459
+ var timer = setInterval(tick, 500);
460
+ }
461
+
462
+ // ---------- 提交 ----------
463
+ var form = $('loginForm');
464
+ if (form) {
465
+ form.addEventListener('submit', function (event) {
466
+ event.preventDefault();
467
+ var btn = $('submitBtn');
468
+ var spinner = $('btnSpinner');
469
+ var btnText = $('btnText');
470
+ var alert = $('alert');
471
+ var input = $('password');
472
+ if (!input || !btn) return;
473
+
474
+ var pwd = input.value;
475
+ if (!pwd) { showError('请输入访问密码'); input.focus(); return; }
476
+
477
+ startSwim();
341
478
  btn.disabled = true;
342
- spinner.style.display = 'inline-block';
343
- btnText.textContent = '验证中…';
344
- alert.style.display = 'none';
345
-
346
- try {
347
- const resp = await fetch('/__dsh_bridge__/login', {
348
- method: 'POST',
349
- headers: { 'Content-Type': 'application/json' },
350
- body: JSON.stringify({ password: pwd })
351
- });
352
- const data = await resp.json();
353
- if (data.ok) {
354
- btnText.textContent = '✓ 认证成功,正在跳转…';
355
- setTimeout(() => {
356
- window.location.reload();
357
- }, 300);
358
- } else {
359
- errorMsg.textContent = data.error || '访问密码错误';
360
- alert.style.display = 'flex';
361
- alert.style.animation = 'none';
362
- void alert.offsetWidth; // 触发 reflow
363
- alert.style.animation = 'shake 0.4s ease';
364
- btn.disabled = false;
365
- spinner.style.display = 'none';
366
- btnText.textContent = '解锁并访问';
367
- document.getElementById('password').focus();
368
- document.getElementById('password').select();
479
+ if (spinner) spinner.classList.add('on');
480
+ if (btnText) btnText.textContent = '验证中…';
481
+ if (alert) alert.setAttribute('hidden', '');
482
+
483
+ fetch('/__dsh_bridge__/login', {
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/json' },
486
+ body: JSON.stringify({ password: pwd })
487
+ }).then(function (resp) {
488
+ return resp.json().then(function (data) { return { ok: resp.ok, data: data }; });
489
+ }).then(function (res) {
490
+ if (res.data && res.data.ok) {
491
+ if (btnText) btnText.textContent = '认证成功,正在进入…';
492
+ if (spinner) spinner.classList.remove('on');
493
+ window.location.reload();
494
+ return;
369
495
  }
370
- } catch (err) {
371
- errorMsg.textContent = '网络请求失败,请重试';
372
- alert.style.display = 'flex';
496
+ throw new Error((res.data && res.data.error) || '访问密码错误');
497
+ }).catch(function (err) {
498
+ stopSwim();
499
+ showError(err && err.message ? err.message : '网络请求失败,请重试');
373
500
  btn.disabled = false;
374
- spinner.style.display = 'none';
375
- btnText.textContent = '解锁并访问';
376
- }
377
- return false;
501
+ if (spinner) spinner.classList.remove('on');
502
+ if (btnText) btnText.textContent = '解锁并访问';
503
+ input.focus();
504
+ input.select();
505
+ });
506
+ });
507
+
508
+ if (passwordEl) {
509
+ passwordEl.addEventListener('focus', startSwim);
510
+ passwordEl.addEventListener('blur', stopSwim);
378
511
  }
379
- </script>
512
+
513
+ // 桌面端才自动聚焦:触屏上立刻弹出键盘会把版面顶飞
514
+ try {
515
+ if (window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
516
+ if (passwordEl && !CFG.lockActive) passwordEl.focus();
517
+ }
518
+ } catch (e) { /* 不支持 matchMedia 就不聚焦,不影响使用 */ }
519
+ }
520
+ })();
521
+ </script>
380
522
  </body>
381
523
  </html>`;
382
524
  }