@taole/dd-playwright 0.0.0-stage → 0.1.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.
@@ -0,0 +1,927 @@
1
+ import { createServer } from "node:http";
2
+ import { fileURLToPath } from "node:url";
3
+ import { AccountStore } from "../account-store.mjs";
4
+ import { DiandianAuthManager } from "../auth/diandian-auth.mjs";
5
+
6
+ function sendJson(res, statusCode, payload) {
7
+ res.writeHead(statusCode, {
8
+ "Content-Type": "application/json; charset=utf-8",
9
+ "Cache-Control": "no-store",
10
+ "Access-Control-Allow-Origin": "*",
11
+ });
12
+ res.end(JSON.stringify(payload));
13
+ }
14
+
15
+ function sendHtml(res, html) {
16
+ res.writeHead(200, {
17
+ "Content-Type": "text/html; charset=utf-8",
18
+ "Cache-Control": "no-cache",
19
+ });
20
+ res.end(html);
21
+ }
22
+
23
+ const DASHBOARD_HTML = `<!DOCTYPE html>
24
+ <html lang="zh-CN">
25
+ <head>
26
+ <meta charset="UTF-8">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
28
+ <title>dd-playwright 控制台 - 点点测试账号管理</title>
29
+ <style>
30
+ :root {
31
+ --bg: #0f172a;
32
+ --card-bg: #1e293b;
33
+ --border: #334155;
34
+ --text: #f8fafc;
35
+ --muted: #94a3b8;
36
+ --accent: #3b82f6;
37
+ --accent-hover: #2563eb;
38
+ --danger: #ef4444;
39
+ --danger-hover: #dc2626;
40
+ --success: #10b981;
41
+ }
42
+ * { box-sizing: border-box; margin: 0; padding: 0; }
43
+ body {
44
+ background: var(--bg);
45
+ color: var(--text);
46
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
47
+ padding: 32px 16px;
48
+ display: flex;
49
+ justify-content: center;
50
+ }
51
+ .container {
52
+ width: 100%;
53
+ max-width: 860px;
54
+ }
55
+ header {
56
+ display: flex;
57
+ justify-content: space-between;
58
+ align-items: center;
59
+ margin-bottom: 24px;
60
+ padding-bottom: 16px;
61
+ border-bottom: 1px solid var(--border);
62
+ }
63
+ h1 { font-size: 20px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
64
+ .btn {
65
+ padding: 8px 16px;
66
+ border-radius: 6px;
67
+ border: none;
68
+ font-size: 14px;
69
+ font-weight: 500;
70
+ cursor: pointer;
71
+ transition: all 0.15s;
72
+ }
73
+ .btn-primary { background: var(--accent); color: #fff; }
74
+ .btn-primary:hover { background: var(--accent-hover); }
75
+ .btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); padding: 4px 10px; font-size: 12px; }
76
+ .btn-danger:hover { background: var(--danger); color: #fff; }
77
+ .btn-active { background: transparent; color: var(--success); border: 1px solid var(--success); padding: 4px 10px; font-size: 12px; }
78
+ .btn-active:hover { background: var(--success); color: #fff; }
79
+ .card {
80
+ background: var(--card-bg);
81
+ border: 1px solid var(--border);
82
+ border-radius: 8px;
83
+ padding: 20px;
84
+ margin-bottom: 20px;
85
+ }
86
+ .card-title {
87
+ font-size: 15px;
88
+ font-weight: 600;
89
+ margin-bottom: 16px;
90
+ color: var(--muted);
91
+ letter-spacing: 0.5px;
92
+ }
93
+ .nav-tabs {
94
+ display: flex;
95
+ gap: 8px;
96
+ margin-bottom: 20px;
97
+ border-bottom: 1px solid var(--border);
98
+ padding-bottom: 12px;
99
+ }
100
+ .tab-btn {
101
+ padding: 8px 16px;
102
+ background: transparent;
103
+ color: var(--muted);
104
+ border: 1px solid transparent;
105
+ border-radius: 6px;
106
+ cursor: pointer;
107
+ font-size: 14px;
108
+ font-weight: 500;
109
+ transition: all 0.15s;
110
+ }
111
+ .tab-btn:hover {
112
+ color: var(--text);
113
+ background: rgba(255, 255, 255, 0.05);
114
+ }
115
+ .tab-btn.active {
116
+ color: #fff;
117
+ background: var(--card-bg);
118
+ border-color: var(--border);
119
+ }
120
+ .tab-pane {
121
+ display: none;
122
+ }
123
+ .tab-pane.active {
124
+ display: block;
125
+ }
126
+ .code-block {
127
+ background: #090d16;
128
+ border: 1px solid var(--border);
129
+ border-radius: 6px;
130
+ padding: 14px 16px;
131
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
132
+ font-size: 13px;
133
+ color: #38bdf8;
134
+ overflow-x: auto;
135
+ white-space: pre;
136
+ position: relative;
137
+ margin-bottom: 16px;
138
+ line-height: 1.5;
139
+ }
140
+ .code-title {
141
+ font-size: 13px;
142
+ font-weight: 600;
143
+ color: #cbd5e1;
144
+ margin: 16px 0 8px 0;
145
+ display: flex;
146
+ justify-content: space-between;
147
+ align-items: center;
148
+ }
149
+ .copy-btn {
150
+ background: #1e293b;
151
+ border: 1px solid var(--border);
152
+ color: var(--muted);
153
+ font-size: 11px;
154
+ padding: 3px 8px;
155
+ border-radius: 4px;
156
+ cursor: pointer;
157
+ }
158
+ .copy-btn:hover { color: #fff; border-color: var(--accent); }
159
+ table.doc-table {
160
+ width: 100%;
161
+ border-collapse: collapse;
162
+ margin-top: 12px;
163
+ font-size: 13px;
164
+ }
165
+ table.doc-table th, table.doc-table td {
166
+ border: 1px solid var(--border);
167
+ padding: 10px 12px;
168
+ text-align: left;
169
+ }
170
+ table.doc-table th {
171
+ background: #0f172a;
172
+ color: var(--muted);
173
+ font-weight: 600;
174
+ }
175
+ table.doc-table td code {
176
+ background: #090d16;
177
+ padding: 2px 6px;
178
+ border-radius: 4px;
179
+ color: #38bdf8;
180
+ font-size: 12px;
181
+ }
182
+ .doc-section {
183
+ margin-bottom: 24px;
184
+ }
185
+ .doc-section h3 {
186
+ font-size: 16px;
187
+ margin-bottom: 10px;
188
+ color: #f1f5f9;
189
+ }
190
+ .doc-section p {
191
+ font-size: 13px;
192
+ color: #94a3b8;
193
+ line-height: 1.6;
194
+ margin-bottom: 8px;
195
+ }
196
+ .account-list { display: flex; flex-direction: column; gap: 12px; }
197
+ .account-item {
198
+ display: flex;
199
+ align-items: center;
200
+ justify-content: space-between;
201
+ background: #0f172a;
202
+ border: 1px solid var(--border);
203
+ padding: 12px 16px;
204
+ border-radius: 6px;
205
+ }
206
+ .account-item.active {
207
+ border-color: var(--success);
208
+ box-shadow: 0 0 0 1px var(--success);
209
+ }
210
+ .account-info { display: flex; align-items: center; gap: 12px; }
211
+ .avatar {
212
+ width: 40px;
213
+ height: 40px;
214
+ border-radius: 50%;
215
+ background: #334155;
216
+ display: flex;
217
+ align-items: center;
218
+ justify-content: center;
219
+ font-size: 14px;
220
+ color: #cbd5e1;
221
+ overflow: hidden;
222
+ }
223
+ .avatar img { width: 100%; height: 100%; object-fit: cover; }
224
+ .account-meta .name { font-size: 14px; font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
225
+ .account-meta .sub { font-size: 12px; color: var(--muted); }
226
+ .badge-active {
227
+ background: rgba(16, 185, 129, 0.15);
228
+ color: var(--success);
229
+ font-size: 11px;
230
+ padding: 2px 6px;
231
+ border-radius: 4px;
232
+ }
233
+ .actions { display: flex; align-items: center; gap: 8px; }
234
+ .empty-state { text-align: center; padding: 32px 0; color: var(--muted); font-size: 14px; }
235
+
236
+ /* Modal */
237
+ .modal-overlay {
238
+ position: fixed;
239
+ inset: 0;
240
+ background: rgba(0, 0, 0, 0.7);
241
+ display: flex;
242
+ align-items: center;
243
+ justify-content: center;
244
+ z-index: 99;
245
+ opacity: 0;
246
+ pointer-events: none;
247
+ transition: opacity 0.2s;
248
+ }
249
+ .modal-overlay.open { opacity: 1; pointer-events: auto; }
250
+ .modal {
251
+ background: var(--card-bg);
252
+ border: 1px solid var(--border);
253
+ border-radius: 12px;
254
+ width: 90%;
255
+ max-width: 520px;
256
+ padding: 24px;
257
+ text-align: center;
258
+ position: relative;
259
+ }
260
+ .modal-close {
261
+ position: absolute;
262
+ top: 14px;
263
+ right: 14px;
264
+ background: transparent;
265
+ border: none;
266
+ color: var(--muted);
267
+ font-size: 18px;
268
+ cursor: pointer;
269
+ }
270
+ .qrcode-box {
271
+ margin: 16px auto;
272
+ width: 360px;
273
+ height: 360px;
274
+ background: #0f172a;
275
+ border: 1px dashed var(--border);
276
+ border-radius: 8px;
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: center;
280
+ overflow: hidden;
281
+ position: relative;
282
+ }
283
+ .qrcode-box img { width: 100%; height: 100%; object-fit: contain; }
284
+ .status-text { font-size: 13px; color: var(--muted); margin-top: 12px; min-height: 18px; }
285
+ .spinner {
286
+ border: 3px solid rgba(255, 255, 255, 0.1);
287
+ border-left-color: var(--accent);
288
+ border-radius: 50%;
289
+ width: 32px;
290
+ height: 32px;
291
+ animation: spin 0.8s linear infinite;
292
+ }
293
+ @keyframes spin { to { transform: rotate(360deg); } }
294
+ </style>
295
+ </head>
296
+ <body>
297
+ <div class="container">
298
+ <header>
299
+ <h1>
300
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M15 9h6"/></svg>
301
+ dd-playwright 账号管理控制台
302
+ </h1>
303
+ <span style="font-size: 13px; color: var(--muted);">v0.1.0</span>
304
+ </header>
305
+
306
+ <div class="nav-tabs">
307
+ <button class="tab-btn active" onclick="switchTab('accounts', this)">点点账号列表</button>
308
+ <button class="tab-btn" onclick="switchTab('mcp', this)">MCP 客户端配置</button>
309
+ <button class="tab-btn" onclick="switchTab('skill', this)">Skill 契约与使用指南</button>
310
+ <button class="tab-btn" onclick="switchTab('settings', this)">全局运行配置</button>
311
+ </div>
312
+
313
+ <!-- TAB 1: 账号管理 -->
314
+ <div id="pane-accounts" class="tab-pane active">
315
+ <div class="card">
316
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
317
+ <div class="card-title" style="margin-bottom: 0;">已绑定点点账号</div>
318
+ <button class="btn btn-primary" onclick="openLoginModal()">+ 添加绑定账号</button>
319
+ </div>
320
+ <div id="accountList" class="account-list">
321
+ <div class="empty-state">加载中...</div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <!-- TAB 2: MCP 配置说明 -->
327
+ <div id="pane-mcp" class="tab-pane">
328
+ <div class="card">
329
+ <div class="card-title">MCP 挂载配置指南</div>
330
+
331
+ <!-- 动态运行环境提示卡片 -->
332
+ <div id="envBanner" style="padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; font-size: 13px; line-height: 1.5; {{BANNER_STYLE}}">
333
+ {{BANNER_CONTENT}}
334
+ </div>
335
+
336
+ <!-- 配置建议区块 -->
337
+ {{CONFIG_SECTIONS}}
338
+
339
+ <div style="font-size: 12px; color: var(--muted); line-height: 1.6; background: #0f172a; padding: 12px; border-radius: 6px; border: 1px solid var(--border); margin-top: 20px;">
340
+ 💡 <b>配置生效小提示:</b><br>
341
+ 1. 挂载后启动 AI 客户端,AI 会自动识别 <code>browser_navigate</code>、<code>browser_screenshot</code> 等 13 个完整 Playwright 工具。<br>
342
+ 2. 在本页面扫码绑定的账号,MCP 会自动读取同一个 SQLite 账号库免登运行。
343
+ </div>
344
+ </div>
345
+ </div>
346
+
347
+ <!-- TAB 3: Skill 规范与工具说明 -->
348
+ <div id="pane-skill" class="tab-pane">
349
+ <div class="card">
350
+ <div class="card-title">Skill 规范说明 (SKILL.md)</div>
351
+
352
+ <div class="doc-section">
353
+ <h3>适用场景</h3>
354
+ <p>进行点点业务页面/活动 H5 自动化测试、免登导航、页面截图、控制台报错排查、本地 dev server 代理映射时由 AI Agent 自动调用。</p>
355
+ </div>
356
+
357
+ <div class="doc-section">
358
+ <h3>工具列表契约</h3>
359
+ <table class="doc-table">
360
+ <thead>
361
+ <tr>
362
+ <th style="width: 24%;">工具名</th>
363
+ <th style="width: 38%;">输入参数</th>
364
+ <th style="width: 38%;">功能与行为契约</th>
365
+ </tr>
366
+ </thead>
367
+ <tbody>
368
+ <tr>
369
+ <td><code>browser_navigate</code></td>
370
+ <td><code>url</code> (必填)<br><code>uid</code> (可选,指定点点账号)<br><code>anonymous</code> (可选,true 为游客态)<br><code>waitFor</code>, <code>timeout</code></td>
371
+ <td>访问 URL。不传 uid 取当前默认账号免登;指定 uid 切换特定身份;传 anonymous: true 模拟未登录状态。切换时自动隔离并重建 Context。</td>
372
+ </tr>
373
+ <tr>
374
+ <td><code>browser_screenshot</code></td>
375
+ <td><code>selector</code> (可选)<br><code>fullPage</code> (可选,默认 false)</td>
376
+ <td>截取指定元素或当前整页,保存至本地图片文件并返回绝对路径。</td>
377
+ </tr>
378
+ <tr>
379
+ <td><code>browser_get_console_errors</code></td>
380
+ <td>(无参数)</td>
381
+ <td>返回自导航以来的 console.error 与未捕获异常列表,方便诊断页面 JS 报错。</td>
382
+ </tr>
383
+ <tr>
384
+ <td><code>browser_click_element</code></td>
385
+ <td><code>selector</code> (必填)<br><code>timeout</code> (可选)</td>
386
+ <td>在页面中按 CSS/XPath 选择器模拟点击元素(按钮、Tab、确认框等)。</td>
387
+ </tr>
388
+ <tr>
389
+ <td><code>browser_fill_text</code></td>
390
+ <td><code>selector</code> (必填)<br><code>text</code> (必填)<br><code>timeout</code> (可选)</td>
391
+ <td>向输入框填入文本,触发 input 与 change 事件。</td>
392
+ </tr>
393
+ <tr>
394
+ <td><code>browser_press_key</code></td>
395
+ <td><code>key</code> (必填, 如 Enter, Tab, Escape)<br><code>selector</code> (可选)</td>
396
+ <td>模拟键盘按键,可聚焦特定元素按键或向全局页面按键。</td>
397
+ </tr>
398
+ <tr>
399
+ <td><code>browser_hover</code></td>
400
+ <td><code>selector</code> (必填)<br><code>timeout</code> (可选)</td>
401
+ <td>鼠标悬停在指定 DOM 元素上,触发 hover 菜单或 Tooltip 浮层。</td>
402
+ </tr>
403
+ <tr>
404
+ <td><code>browser_scroll</code></td>
405
+ <td><code>direction</code> ("down" / "up")<br><code>deltaY</code> (可选)<br><code>selector</code> (可选)</td>
406
+ <td>视口滚动或滚动指定元素进入视图(触底加载等)。</td>
407
+ </tr>
408
+ <tr>
409
+ <td><code>browser_wait_for</code></td>
410
+ <td><code>selector</code> (可选)<br><code>state</code> ("visible" 等)<br><code>timeout</code> (可选)</td>
411
+ <td>等待页面整页加载或特定 DOM 元素出现/隐藏。</td>
412
+ </tr>
413
+ <tr>
414
+ <td><code>browser_get_dom</code></td>
415
+ <td><code>interactiveOnly</code> (默认 true)<br><code>maxDepth</code> (默认 10)</td>
416
+ <td>提取当前页面结构化可交互 DOM 树(按钮/输入框/链接及其选择器),免去盲猜。</td>
417
+ </tr>
418
+ <tr>
419
+ <td><code>browser_eval_js</code></td>
420
+ <td><code>script</code> (必填,JS 字符串)</td>
421
+ <td>在当前页面上下文中执行任意 JavaScript 脚本并返回计算结果。</td>
422
+ </tr>
423
+ <tr>
424
+ <td><code>browser_set_proxy_rule</code></td>
425
+ <td><code>domain</code> (需匹配 *.tuwan.com)<br><code>port</code> (本地服务端口)<br><code>protocol</code> (http/https)</td>
426
+ <td>启动/设置本进程内隔离 Whistle 代理,将本地端口映射到干净点点域名,免改 hosts 携带 Cookie。</td>
427
+ </tr>
428
+ <tr>
429
+ <td><code>browser_status</code></td>
430
+ <td>(无参数)</td>
431
+ <td>查询当前 MCP 状态:版本号、默认账号 UID、storageState 加载情况、代理规则与数据目录。</td>
432
+ </tr>
433
+ </tbody>
434
+ </table>
435
+ </div>
436
+ </div>
437
+ </div>
438
+ <!-- TAB 4: 全局配置 -->
439
+ <div id="pane-settings" class="tab-pane">
440
+ <div class="card">
441
+ <div class="card-title">运行环境偏好设置</div>
442
+
443
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #0f172a; border-radius: 8px; border: 1px solid var(--border);">
444
+ <div>
445
+ <div style="font-size: 14px; font-weight: 600; margin-bottom: 4px;">无头模式 (Headless Mode)</div>
446
+ <div style="font-size: 12px; color: var(--muted); line-height: 1.5;">
447
+ 开启时在后台静默运行浏览器;关闭时将弹出真实的 Chromium 窗口,便于直观观察自动化操作与登录状态。
448
+ </div>
449
+ </div>
450
+ <label style="position: relative; display: inline-block; width: 48px; height: 26px; cursor: pointer;">
451
+ <input type="checkbox" id="headlessToggle" onchange="toggleHeadless(this.checked)" style="opacity: 0; width: 0; height: 0;">
452
+ <span id="headlessSlider" style="position: absolute; cursor: pointer; inset: 0; background-color: #334155; transition: .2s; border-radius: 26px;"></span>
453
+ <span id="headlessKnob" style="position: absolute; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: .2s; border-radius: 50%;"></span>
454
+ </label>
455
+ </div>
456
+
457
+ <div style="margin-top: 16px; font-size: 12px; color: var(--muted); line-height: 1.6; padding: 12px; background: #090d16; border-radius: 6px; border: 1px solid var(--border);">
458
+ 📁 <b>数据存储说明:</b><br>
459
+ 本服务所有数据与账号库已默认固化在用户主目录下的 <code>~/.dd-browser-mcp/runtime.db</code>,无需配置任何复杂环境变量,开箱即用。
460
+ </div>
461
+ </div>
462
+ </div>
463
+
464
+ <div id="modalOverlay" class="modal-overlay">
465
+ <div class="modal">
466
+ <button class="modal-close" onclick="closeLoginModal()">✕</button>
467
+ <h3 style="margin-bottom: 8px;">扫码绑定点点账号</h3>
468
+ <p style="font-size: 12px; color: var(--muted);">使用微信或点点 App 扫描下方二维码</p>
469
+
470
+ <div id="qrBox" class="qrcode-box">
471
+ <div class="spinner"></div>
472
+ </div>
473
+
474
+ <div id="statusText" class="status-text">正在启动扫码会话...</div>
475
+ </div>
476
+ </div>
477
+
478
+ <script>
479
+ let pollTimer = null;
480
+
481
+ async function loadAccounts() {
482
+ try {
483
+ const res = await fetch('/api/accounts');
484
+ const data = await res.json();
485
+ renderAccounts(data.accounts || []);
486
+ } catch (err) {
487
+ document.getElementById('accountList').innerHTML = '<div class="empty-state" style="color:var(--danger)">加载账号失败: ' + err.message + '</div>';
488
+ }
489
+ }
490
+
491
+ function renderAccounts(accounts) {
492
+ const container = document.getElementById('accountList');
493
+ if (!accounts.length) {
494
+ container.innerHTML = '<div class="empty-state">暂未绑定任何点点账号,点击右上角「+ 添加绑定账号」扫码绑定。</div>';
495
+ return;
496
+ }
497
+ container.innerHTML = accounts.map(acc => {
498
+ const avatarHtml = acc.avatar
499
+ ? '<img src="' + acc.avatar + '" alt="">'
500
+ : (acc.nickname || '点').slice(0, 1);
501
+ return \`
502
+ <div class="account-item \${acc.isActive ? 'active' : ''}">
503
+ <div class="account-info">
504
+ <div class="avatar">\${avatarHtml}</div>
505
+ <div class="account-meta">
506
+ <div class="name">
507
+ \${acc.nickname || '点点用户'}
508
+ \${acc.isActive ? '<span class="badge-active">默认账号</span>' : ''}
509
+ </div>
510
+ <div class="sub">UID: \${acc.accountId} · 更新时间: \${new Date(acc.updatedAt).toLocaleString()}</div>
511
+ </div>
512
+ </div>
513
+ <div class="actions">
514
+ \${!acc.isActive ? '<button class="btn btn-active" onclick="switchAccount(\\'' + acc.accountId + '\\')">设为默认</button>' : ''}
515
+ <button class="btn btn-danger" onclick="deleteAccount('\\'' + acc.accountId + '\\')">解绑</button>
516
+ </div>
517
+ </div>
518
+ \`;
519
+ }).join('');
520
+ }
521
+
522
+ async function switchAccount(id) {
523
+ try {
524
+ const res = await fetch('/api/switch', {
525
+ method: 'POST',
526
+ headers: { 'Content-Type': 'application/json' },
527
+ body: JSON.stringify({ accountId: id })
528
+ });
529
+ const data = await res.json();
530
+ if (data.ok) loadAccounts();
531
+ else alert('切换失败: ' + data.error);
532
+ } catch (err) {
533
+ alert('切换异常: ' + err.message);
534
+ }
535
+ }
536
+
537
+ async function deleteAccount(id) {
538
+ if (!confirm('确定解绑该点点账号吗?解绑后本地不再保存该凭证。')) return;
539
+ try {
540
+ const res = await fetch('/api/delete', {
541
+ method: 'POST',
542
+ headers: { 'Content-Type': 'application/json' },
543
+ body: JSON.stringify({ accountId: id })
544
+ });
545
+ const data = await res.json();
546
+ if (data.ok) loadAccounts();
547
+ else alert('解绑失败: ' + data.error);
548
+ } catch (err) {
549
+ alert('解绑异常: ' + err.message);
550
+ }
551
+ }
552
+
553
+ async function openLoginModal() {
554
+ const modal = document.getElementById('modalOverlay');
555
+ modal.classList.add('open');
556
+ document.getElementById('qrBox').innerHTML = '<div class="spinner"></div>';
557
+ document.getElementById('statusText').innerText = '正在启动后台扫码浏览器...';
558
+
559
+ try {
560
+ const res = await fetch('/api/login/start', { method: 'POST' });
561
+ const data = await res.json();
562
+ if (!data.ok) throw new Error(data.error || '启动失败');
563
+
564
+ document.getElementById('statusText').innerText = '等待二维码渲染...';
565
+ // 等待 1 秒后拉取二维码并启动轮询
566
+ setTimeout(() => {
567
+ refreshQr();
568
+ startPolling();
569
+ }, 1200);
570
+ } catch (err) {
571
+ document.getElementById('statusText').innerText = '启动扫码失败: ' + err.message;
572
+ }
573
+ }
574
+
575
+ function refreshQr() {
576
+ const qrBox = document.getElementById('qrBox');
577
+ qrBox.innerHTML = '<img src="/api/login/screenshot?t=' + Date.now() + '" alt="扫码登录">';
578
+ }
579
+ function stopPolling() {
580
+ if (pollTimer) {
581
+ clearInterval(pollTimer);
582
+ pollTimer = null;
583
+ }
584
+ }
585
+
586
+ function startPolling() {
587
+ stopPolling();
588
+ pollTimer = setInterval(async () => {
589
+ try {
590
+ const res = await fetch('/api/login/status');
591
+ const data = await res.json();
592
+ if (data.ok) {
593
+ if (data.status === 'ready') {
594
+ stopPolling();
595
+ document.getElementById('statusText').innerText = '🎉 绑定成功!正在刷新...';
596
+ document.getElementById('statusText').style.color = 'var(--success)';
597
+ await loadAccounts();
598
+ setTimeout(closeLoginModal, 1500);
599
+ } else if (data.status === 'scanning') {
600
+ document.getElementById('statusText').innerText = data.message || '请扫描二维码';
601
+ }
602
+ }
603
+ } catch {}
604
+ }, 1500);
605
+ }
606
+ function closeLoginModal() {
607
+ stopPolling();
608
+ const modal = document.getElementById('modalOverlay');
609
+ modal.classList.remove('open');
610
+ document.getElementById('statusText').style.color = 'var(--muted)';
611
+ fetch('/api/login/cancel', { method: 'POST' }).catch(() => {});
612
+ }
613
+ function switchTab(tabId, btn) {
614
+ document.querySelectorAll('.tab-pane').forEach(el => el.classList.remove('active'));
615
+ document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
616
+ const target = document.getElementById('pane-' + tabId);
617
+ if (target) target.classList.add('active');
618
+ if (btn) btn.classList.add('active');
619
+ }
620
+
621
+ function copyCode(elementId) {
622
+ const text = document.getElementById(elementId)?.innerText;
623
+ if (text) {
624
+ navigator.clipboard.writeText(text).then(() => {
625
+ alert('配置代码已复制到剪贴板!');
626
+ }).catch(() => {
627
+ alert('复制失败,请手动选中文本复制');
628
+ });
629
+ }
630
+ }
631
+
632
+ async function loadSettings() {
633
+ try {
634
+ const res = await fetch('/api/settings');
635
+ const data = await res.json();
636
+ if (data.ok && data.settings) {
637
+ updateHeadlessUI(data.settings.headless);
638
+ }
639
+ } catch {}
640
+ }
641
+
642
+ function updateHeadlessUI(isHeadless) {
643
+ const checkbox = document.getElementById('headlessToggle');
644
+ const slider = document.getElementById('headlessSlider');
645
+ const knob = document.getElementById('headlessKnob');
646
+ if (!checkbox || !slider || !knob) return;
647
+ checkbox.checked = Boolean(isHeadless);
648
+ if (isHeadless) {
649
+ slider.style.backgroundColor = 'var(--accent)';
650
+ knob.style.transform = 'translateX(22px)';
651
+ } else {
652
+ slider.style.backgroundColor = '#334155';
653
+ knob.style.transform = 'translateX(0px)';
654
+ }
655
+ }
656
+
657
+ async function toggleHeadless(checked) {
658
+ updateHeadlessUI(checked);
659
+ try {
660
+ await fetch('/api/settings', {
661
+ method: 'POST',
662
+ headers: { 'Content-Type': 'application/json' },
663
+ body: JSON.stringify({ key: 'headless', value: checked })
664
+ });
665
+ } catch (err) {
666
+ alert('保存设置失败: ' + err.message);
667
+ }
668
+ }
669
+
670
+ // 初次加载账号与配置
671
+ loadAccounts();
672
+ loadSettings();
673
+ </script>
674
+ </body>
675
+ </html>`;
676
+
677
+ export function createWebServer({ accountStore, authManager }) {
678
+ const server = createServer(async (req, res) => {
679
+ const urlObj = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
680
+ const pathname = urlObj.pathname;
681
+ // CORS preflight
682
+ if (req.method === "OPTIONS") {
683
+ res.writeHead(204, {
684
+ "Access-Control-Allow-Origin": "*",
685
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
686
+ "Access-Control-Allow-Headers": "Content-Type",
687
+ });
688
+ res.end();
689
+ return;
690
+ }
691
+
692
+ // 1. 首页控制台
693
+ if (pathname === "/" || pathname === "/index.html") {
694
+ const currentFilePath = fileURLToPath(import.meta.url).replaceAll("\\", "/");
695
+ const isInstalledViaNpm = /\/node_modules\/(@taole\/)?dd-playwright\//i.test(currentFilePath);
696
+ const serverScriptPath = fileURLToPath(
697
+ new URL("../../bin/mcp-server.mjs", import.meta.url),
698
+ ).replaceAll("\\", "/");
699
+
700
+ const globalConfigStr = JSON.stringify(
701
+ {
702
+ mcpServers: {
703
+ "dd-playwright": {
704
+ command: "dd-playwright-mcp",
705
+ },
706
+ },
707
+ },
708
+ null,
709
+ 2,
710
+ );
711
+
712
+ const localConfigStr = JSON.stringify(
713
+ {
714
+ mcpServers: {
715
+ "dd-playwright": {
716
+ command: "node",
717
+ args: [serverScriptPath],
718
+ },
719
+ },
720
+ },
721
+ null,
722
+ 2,
723
+ );
724
+
725
+ let bannerStyle = "";
726
+ let bannerContent = "";
727
+ let configSections = "";
728
+
729
+ if (isInstalledViaNpm) {
730
+ bannerStyle = "background: rgba(16, 185, 129, 0.12); border: 1px solid rgba(16, 185, 129, 0.3); color: #34d399;";
731
+ bannerContent = "📦 <b>环境检测结果:已通过 npm 安装分发包</b><br><span style='color:#cbd5e1; font-size:12px;'>已自动为您推荐「全局命令配置」,直接配置命令即可被系统全局识别并运行。</span>";
732
+
733
+ configSections = `
734
+ <div class="code-title">
735
+ <span>推荐方式:全局命令方式(最推荐)</span>
736
+ <button class="copy-btn" onclick="copyCode('code-mcp-global')">复制代码</button>
737
+ </div>
738
+ <div id="code-mcp-global" class="code-block">${globalConfigStr}</div>
739
+
740
+ <div class="code-title" style="margin-top: 24px;">
741
+ <span style="color: var(--muted);">备用方式:模块绝对路径方式</span>
742
+ <button class="copy-btn" onclick="copyCode('code-mcp-local')">复制代码</button>
743
+ </div>
744
+ <div id="code-mcp-local" class="code-block">${localConfigStr}</div>
745
+ `;
746
+ } else {
747
+ bannerStyle = "background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.3); color: #60a5fa;";
748
+ bannerContent = "💻 <b>环境检测结果:本地源码目录运行(开发/克隆模式)</b><br><span style='color:#cbd5e1; font-size:12px;'>已实时解析您当前的本地绝对路径,无需全局安装即可直接挂载;若希望使用全局命令,可在根目录执行一次 <code>npm link</code>。</span>";
749
+
750
+ configSections = `
751
+ <div class="code-title">
752
+ <span>推荐方式:本地源码绝对路径方式(免全局安装,当前代码实时生效)</span>
753
+ <button class="copy-btn" onclick="copyCode('code-mcp-local')">复制代码</button>
754
+ </div>
755
+ <div id="code-mcp-local" class="code-block">${localConfigStr}</div>
756
+
757
+ <div class="code-title" style="margin-top: 24px;">
758
+ <span style="color: var(--muted);">备用方式:全局命令方式(需在项目根目录下先执行 npm link)</span>
759
+ <button class="copy-btn" onclick="copyCode('code-mcp-global')">复制代码</button>
760
+ </div>
761
+ <div id="code-mcp-global" class="code-block">${globalConfigStr}</div>
762
+ `;
763
+ }
764
+
765
+ const renderedHtml = DASHBOARD_HTML
766
+ .replace("{{BANNER_STYLE}}", bannerStyle)
767
+ .replace("{{BANNER_CONTENT}}", bannerContent)
768
+ .replace("{{CONFIG_SECTIONS}}", configSections);
769
+
770
+ sendHtml(res, renderedHtml);
771
+ return;
772
+ }
773
+
774
+ // 2. 账号列表
775
+ if (pathname === "/api/accounts" && req.method === "GET") {
776
+ try {
777
+ const accounts = accountStore.listAccounts();
778
+ const active = accountStore.getActiveAccount();
779
+ sendJson(res, 200, { ok: true, accounts, active });
780
+ } catch (err) {
781
+ sendJson(res, 500, { ok: false, error: err.message });
782
+ }
783
+ return;
784
+ }
785
+
786
+ // 3. 切换激活账号
787
+ if (pathname === "/api/switch" && req.method === "POST") {
788
+ let body = "";
789
+ req.on("data", (c) => (body += c));
790
+ req.on("end", () => {
791
+ try {
792
+ const { accountId } = JSON.parse(body || "{}");
793
+ accountStore.setActiveAccount(accountId);
794
+ sendJson(res, 200, { ok: true });
795
+ } catch (err) {
796
+ sendJson(res, 500, { ok: false, error: err.message });
797
+ }
798
+ });
799
+ return;
800
+ }
801
+
802
+ // 4. 删除/解绑账号
803
+ if (pathname === "/api/delete" && req.method === "POST") {
804
+ let body = "";
805
+ req.on("data", (c) => (body += c));
806
+ req.on("end", () => {
807
+ try {
808
+ const { accountId } = JSON.parse(body || "{}");
809
+ accountStore.deleteAccount(accountId);
810
+ sendJson(res, 200, { ok: true });
811
+ } catch (err) {
812
+ sendJson(res, 500, { ok: false, error: err.message });
813
+ }
814
+ });
815
+ return;
816
+ }
817
+ // 4.1 获取配置(如 headless)
818
+ if (pathname === "/api/settings" && req.method === "GET") {
819
+ try {
820
+ const headless = accountStore.getSetting("headless", true);
821
+ sendJson(res, 200, { ok: true, settings: { headless } });
822
+ } catch (err) {
823
+ sendJson(res, 500, { ok: false, error: err.message });
824
+ }
825
+ return;
826
+ }
827
+
828
+ // 4.2 保存配置(如 headless)
829
+ if (pathname === "/api/settings" && req.method === "POST") {
830
+ let body = "";
831
+ req.on("data", (c) => (body += c));
832
+ req.on("end", () => {
833
+ try {
834
+ const { key, value } = JSON.parse(body || "{}");
835
+ if (!key) throw new Error("缺少 setting key");
836
+ accountStore.setSetting(key, value);
837
+ sendJson(res, 200, { ok: true, key, value });
838
+ } catch (err) {
839
+ sendJson(res, 500, { ok: false, error: err.message });
840
+ }
841
+ });
842
+ return;
843
+ }
844
+
845
+
846
+ // 5. 启动扫码
847
+ if (pathname === "/api/login/start" && req.method === "POST") {
848
+ try {
849
+ const headless = accountStore.getSetting("headless", true);
850
+ const result = await authManager.startLogin({ headless });
851
+ sendJson(res, 200, { ok: true, ...result });
852
+ } catch (err) {
853
+ sendJson(res, 500, { ok: false, error: err.message });
854
+ }
855
+ return;
856
+ }
857
+
858
+ // 6. 获取扫码二维码截图
859
+ if (pathname === "/api/login/screenshot" && req.method === "GET") {
860
+ try {
861
+ const buffer = await authManager.getScreenshot();
862
+ res.writeHead(200, {
863
+ "Content-Type": "image/png",
864
+ "Cache-Control": "no-store",
865
+ });
866
+ res.end(buffer);
867
+ } catch (err) {
868
+ sendJson(res, 500, { ok: false, error: err.message });
869
+ }
870
+ return;
871
+ }
872
+
873
+ // 7. 查询扫码状态
874
+ if (pathname === "/api/login/status" && req.method === "GET") {
875
+ try {
876
+ const result = await authManager.checkStatus();
877
+ sendJson(res, 200, { ok: true, ...result });
878
+ } catch (err) {
879
+ sendJson(res, 500, { ok: false, error: err.message });
880
+ }
881
+ return;
882
+ }
883
+
884
+ // 8. 取消扫码 / 关闭弹窗
885
+ if (pathname === "/api/login/cancel" && req.method === "POST") {
886
+ try {
887
+ await authManager.close();
888
+ sendJson(res, 200, { ok: true });
889
+ } catch (err) {
890
+ sendJson(res, 500, { ok: false, error: err.message });
891
+ }
892
+ return;
893
+ }
894
+
895
+ sendJson(res, 404, { ok: false, error: "Not Found" });
896
+ });
897
+
898
+ return server;
899
+ }
900
+
901
+ export async function startWebServer({
902
+ port = 12309,
903
+ dbPath,
904
+ dataDir,
905
+ accountStore,
906
+ } = {}) {
907
+ const store = accountStore || new AccountStore(dbPath);
908
+ const authManager = new DiandianAuthManager({ accountStore: store });
909
+ const server = createWebServer({ accountStore: store, authManager });
910
+
911
+ return new Promise((resolve, reject) => {
912
+ server.listen(port, "0.0.0.0", () => {
913
+ resolve({
914
+ server,
915
+ port,
916
+ accountStore: store,
917
+ authManager,
918
+ close: async () => {
919
+ await authManager.close();
920
+ store.close();
921
+ await new Promise((r) => server.close(r));
922
+ },
923
+ });
924
+ });
925
+ server.on("error", reject);
926
+ });
927
+ }