@taole/dd-playwright 0.0.0-stage → 0.1.1

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,930 @@
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.1</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 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
330
+ <div class="card-title" style="margin-bottom: 0;">MCP 挂载配置指南</div>
331
+ <button class="btn" onclick="installToCursor()" style="background: #0284c7; color: white; display: flex; align-items: center; gap: 7px; font-weight: 600; padding: 7px 14px;">
332
+ <svg width="14" height="16" viewBox="0 0 467 533" fill="currentColor"><path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/></svg>
333
+ 一键安装到 Cursor
334
+ </button>
335
+ </div>
336
+
337
+ <!-- 动态运行环境提示卡片 -->
338
+ <div id="envBanner" style="padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; font-size: 13px; line-height: 1.5; {{BANNER_STYLE}}">
339
+ {{BANNER_CONTENT}}
340
+ </div>
341
+ <!-- 配置建议区块 -->
342
+ {{CONFIG_SECTIONS}}
343
+
344
+ <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;">
345
+ 💡 <b>配置生效小提示:</b><br>
346
+ 1. 挂载后启动 AI 客户端,AI 会自动识别 <code>browser_navigate</code>、<code>browser_screenshot</code> 等 13 个完整 Playwright 工具。<br>
347
+ 2. 在本页面扫码绑定的账号,MCP 会自动读取同一个 SQLite 账号库免登运行。
348
+ </div>
349
+ </div>
350
+ </div>
351
+
352
+ <!-- TAB 3: Skill 规范与工具说明 -->
353
+ <div id="pane-skill" class="tab-pane">
354
+ <div class="card">
355
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
356
+ <div class="card-title" style="margin-bottom: 0;">Skill 规范说明 (SKILL.md)</div>
357
+ <div style="display: flex; gap: 8px;">
358
+ <a href="/api/skill/download" download="SKILL.md" class="btn" style="background: #334155; color: white; display: flex; align-items: center; gap: 6px; font-weight: 500; padding: 7px 12px; text-decoration: none;">
359
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
360
+ 下载 SKILL.md
361
+ </a>
362
+ <button class="btn" onclick="installSkillToCursor()" style="background: #0284c7; color: white; display: flex; align-items: center; gap: 7px; font-weight: 600; padding: 7px 14px;">
363
+ <svg width="14" height="16" viewBox="0 0 467 533" fill="currentColor"><path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/></svg>
364
+ 一键安装到 Cursor
365
+ </button>
366
+ </div>
367
+ </div>
368
+ <div class="doc-section">
369
+ <h3>适用场景</h3>
370
+ <p>适用于点点业务页面/活动 H5 自动化测试、免登导航、页面截图、控制台报错排查、本地 dev server 代理映射等任务。</p>
371
+ </div>
372
+
373
+ <div class="doc-section">
374
+ <h3>自动化执行指导准则</h3>
375
+ <p>本技能必须通过 <b><code>dd-playwright</code> MCP 服务</b>(提供 <code>browser_*</code> 工具集)执行:</p>
376
+
377
+ <div style="background: #0f172a; border: 1px solid var(--border); border-radius: 6px; padding: 14px; margin-top: 10px; font-size: 13px; line-height: 1.8; color: #cbd5e1;">
378
+ <b>1. 登录态与身份切换:</b><br>
379
+ • 访问点点业务页面必须调用本 MCP 的 <code>browser_navigate</code> 工具,自动注入当前激活账号的 storageState(含 <code>Tuwan_Passport</code> 鉴权 Cookie)免登。<br>
380
+ • 多角色或指定账号测试时传入特定 <code>uid</code>,自动隔离并重建独立浏览器上下文。<br><br>
381
+ <b>2. 本地开发服务联调(Dev Server 代理映射):</b><br>
382
+ • 本地服务启动后严禁改动系统 hosts,必须先调用 <code>browser_set_proxy_rule</code> 映射到点点子域名。<br>
383
+ • <b>示例</b>:本地 dev server 运行在 <code>127.0.0.1:9527</code> 时,可映射为自定义域名 <code>p9527-test.tuwan.com</code>(域名无需线上真实存在):<br>
384
+ <code style="color: #38bdf8;">browser_set_proxy_rule({ domain: "p9527-test.tuwan.com", port: 9527 })</code><br>
385
+ 随后访问该域名,内部隔离 Whistle 代理会自动转发流量并正确携带线上 Cookie。<br><br>
386
+ <b>3. 页面交互规范:</b><br>
387
+ • 复杂或动态页面定位困难时,优先调用 <code>browser_get_dom</code> 获取可见元素清单,严禁盲猜选择器。<br>
388
+ • 表单填写使用 <code>browser_fill_text</code>,按键使用 <code>browser_press_key</code>。
389
+ </div>
390
+ </div>
391
+ </div>
392
+ <!-- TAB 4: 全局配置 -->
393
+ <div id="pane-settings" class="tab-pane">
394
+ <div class="card">
395
+ <div class="card-title">运行环境偏好设置</div>
396
+
397
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #0f172a; border-radius: 8px; border: 1px solid var(--border);">
398
+ <div>
399
+ <div style="font-size: 14px; font-weight: 600; margin-bottom: 4px;">无头模式 (Headless Mode)</div>
400
+ <div style="font-size: 12px; color: var(--muted); line-height: 1.5;">
401
+ 开启时在后台静默运行浏览器;关闭时将弹出真实的 Chromium 窗口,便于直观观察自动化操作与登录状态。
402
+ </div>
403
+ </div>
404
+ <label style="position: relative; display: inline-block; width: 48px; height: 26px; cursor: pointer;">
405
+ <input type="checkbox" id="headlessToggle" onchange="toggleHeadless(this.checked)" style="opacity: 0; width: 0; height: 0;">
406
+ <span id="headlessSlider" style="position: absolute; cursor: pointer; inset: 0; background-color: #334155; transition: .2s; border-radius: 26px;"></span>
407
+ <span id="headlessKnob" style="position: absolute; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: .2s; border-radius: 50%;"></span>
408
+ </label>
409
+ </div>
410
+
411
+ <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);">
412
+ 📁 <b>数据存储说明:</b><br>
413
+ 本服务所有数据与账号库已默认固化在用户主目录下的 <code>~/.dd-browser-mcp/runtime.db</code>,无需配置任何复杂环境变量,开箱即用。
414
+ </div>
415
+ </div>
416
+ </div>
417
+
418
+ <div id="modalOverlay" class="modal-overlay">
419
+ <div class="modal">
420
+ <button class="modal-close" onclick="closeLoginModal()">✕</button>
421
+ <h3 style="margin-bottom: 8px;">扫码绑定点点账号</h3>
422
+ <p style="font-size: 12px; color: var(--muted);">使用微信或点点 App 扫描下方二维码</p>
423
+
424
+ <div id="qrBox" class="qrcode-box">
425
+ <div class="spinner"></div>
426
+ </div>
427
+
428
+ <div id="statusText" class="status-text">正在启动扫码会话...</div>
429
+ </div>
430
+ </div>
431
+
432
+ <script>
433
+ let pollTimer = null;
434
+
435
+ async function loadAccounts() {
436
+ try {
437
+ const res = await fetch('/api/accounts');
438
+ const data = await res.json();
439
+ renderAccounts(data.accounts || []);
440
+ } catch (err) {
441
+ document.getElementById('accountList').innerHTML = '<div class="empty-state" style="color:var(--danger)">加载账号失败: ' + err.message + '</div>';
442
+ }
443
+ }
444
+
445
+ function renderAccounts(accounts) {
446
+ const container = document.getElementById('accountList');
447
+ if (!accounts.length) {
448
+ container.innerHTML = '<div class="empty-state">暂未绑定任何点点账号,点击右上角「+ 添加绑定账号」扫码绑定。</div>';
449
+ return;
450
+ }
451
+ container.innerHTML = accounts.map(acc => {
452
+ const avatarHtml = acc.avatar
453
+ ? '<img src="' + acc.avatar + '" alt="">'
454
+ : (acc.nickname || '点').slice(0, 1);
455
+ return \`
456
+ <div class="account-item \${acc.isActive ? 'active' : ''}">
457
+ <div class="account-info">
458
+ <div class="avatar">\${avatarHtml}</div>
459
+ <div class="account-meta">
460
+ <div class="name">
461
+ \${acc.nickname || '点点用户'}
462
+ \${acc.isActive ? '<span class="badge-active">默认账号</span>' : ''}
463
+ </div>
464
+ <div class="sub">UID: \${acc.accountId} · 更新时间: \${new Date(acc.updatedAt).toLocaleString()}</div>
465
+ </div>
466
+ </div>
467
+ <div class="actions">
468
+ \${!acc.isActive ? '<button class="btn btn-active" onclick="switchAccount(\\'' + acc.accountId + '\\')">设为默认</button>' : ''}
469
+ <button class="btn btn-danger" onclick="deleteAccount('\\'' + acc.accountId + '\\')">解绑</button>
470
+ </div>
471
+ </div>
472
+ \`;
473
+ }).join('');
474
+ }
475
+
476
+ async function switchAccount(id) {
477
+ try {
478
+ const res = await fetch('/api/switch', {
479
+ method: 'POST',
480
+ headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({ accountId: id })
482
+ });
483
+ const data = await res.json();
484
+ if (data.ok) loadAccounts();
485
+ else alert('切换失败: ' + data.error);
486
+ } catch (err) {
487
+ alert('切换异常: ' + err.message);
488
+ }
489
+ }
490
+
491
+ async function deleteAccount(id) {
492
+ if (!confirm('确定解绑该点点账号吗?解绑后本地不再保存该凭证。')) return;
493
+ try {
494
+ const res = await fetch('/api/delete', {
495
+ method: 'POST',
496
+ headers: { 'Content-Type': 'application/json' },
497
+ body: JSON.stringify({ accountId: id })
498
+ });
499
+ const data = await res.json();
500
+ if (data.ok) loadAccounts();
501
+ else alert('解绑失败: ' + data.error);
502
+ } catch (err) {
503
+ alert('解绑异常: ' + err.message);
504
+ }
505
+ }
506
+
507
+ async function openLoginModal() {
508
+ const modal = document.getElementById('modalOverlay');
509
+ modal.classList.add('open');
510
+ document.getElementById('qrBox').innerHTML = '<div class="spinner"></div>';
511
+ document.getElementById('statusText').innerText = '正在启动后台扫码浏览器...';
512
+
513
+ try {
514
+ const res = await fetch('/api/login/start', { method: 'POST' });
515
+ const data = await res.json();
516
+ if (!data.ok) throw new Error(data.error || '启动失败');
517
+
518
+ document.getElementById('statusText').innerText = '等待二维码渲染...';
519
+ // 等待 1 秒后拉取二维码并启动轮询
520
+ setTimeout(() => {
521
+ refreshQr();
522
+ startPolling();
523
+ }, 1200);
524
+ } catch (err) {
525
+ document.getElementById('statusText').innerText = '启动扫码失败: ' + err.message;
526
+ }
527
+ }
528
+
529
+ function refreshQr() {
530
+ const qrBox = document.getElementById('qrBox');
531
+ qrBox.innerHTML = '<img src="/api/login/screenshot?t=' + Date.now() + '" alt="扫码登录">';
532
+ }
533
+ function stopPolling() {
534
+ if (pollTimer) {
535
+ clearInterval(pollTimer);
536
+ pollTimer = null;
537
+ }
538
+ }
539
+
540
+ function startPolling() {
541
+ stopPolling();
542
+ pollTimer = setInterval(async () => {
543
+ try {
544
+ const res = await fetch('/api/login/status');
545
+ const data = await res.json();
546
+ if (data.ok) {
547
+ if (data.status === 'ready') {
548
+ stopPolling();
549
+ document.getElementById('statusText').innerText = '🎉 绑定成功!正在刷新...';
550
+ document.getElementById('statusText').style.color = 'var(--success)';
551
+ await loadAccounts();
552
+ setTimeout(closeLoginModal, 1500);
553
+ } else if (data.status === 'scanning') {
554
+ document.getElementById('statusText').innerText = data.message || '请扫描二维码';
555
+ }
556
+ }
557
+ } catch {}
558
+ }, 1500);
559
+ }
560
+ function closeLoginModal() {
561
+ stopPolling();
562
+ const modal = document.getElementById('modalOverlay');
563
+ modal.classList.remove('open');
564
+ document.getElementById('statusText').style.color = 'var(--muted)';
565
+ fetch('/api/login/cancel', { method: 'POST' }).catch(() => {});
566
+ }
567
+ function switchTab(tabId, btn) {
568
+ document.querySelectorAll('.tab-pane').forEach(el => el.classList.remove('active'));
569
+ document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
570
+ const target = document.getElementById('pane-' + tabId);
571
+ if (target) target.classList.add('active');
572
+ if (btn) btn.classList.add('active');
573
+ }
574
+
575
+ function copyCode(elementId) {
576
+ const text = document.getElementById(elementId)?.innerText;
577
+ if (text) {
578
+ navigator.clipboard.writeText(text).then(() => {
579
+ alert('配置代码已复制到剪贴板!');
580
+ }).catch(() => {
581
+ alert('复制失败,请手动选中文本复制');
582
+ });
583
+ }
584
+ }
585
+ function installToCursor() {
586
+ const serverName = 'dd-playwright';
587
+ let mcpConfigObj = {
588
+ command: 'node',
589
+ args: []
590
+ };
591
+ const localCode = document.getElementById('code-mcp-local')?.innerText;
592
+ if (localCode) {
593
+ try {
594
+ const parsed = JSON.parse(localCode);
595
+ const cfg = parsed.mcpServers?.[serverName];
596
+ if (cfg) {
597
+ mcpConfigObj = cfg;
598
+ }
599
+ } catch {}
600
+ }
601
+
602
+ // Cursor 官方标准格式:
603
+ // 路由为 anysphere.cursor-deeplink/mcp/install
604
+ // config 参数需为 Base64 编码的 JSON 字符串
605
+ const jsonStr = JSON.stringify(mcpConfigObj);
606
+ // 处理 UTF-8 字符的 Base64 编码
607
+ const base64Config = btoa(encodeURIComponent(jsonStr).replace(/%([0-9A-F]{2})/g, (match, p1) => {
608
+ return String.fromCharCode('0x' + p1);
609
+ }));
610
+ const cursorUrl = 'cursor://anysphere.cursor-deeplink/mcp/install?name=' + encodeURIComponent(serverName) + '&config=' + encodeURIComponent(base64Config);
611
+ // 唤起 Cursor
612
+ window.location.href = cursorUrl;
613
+ }
614
+ function installSkillToCursor() {
615
+ const ruleName = 'dd-playwright';
616
+ const compactRule = [
617
+ '---',
618
+ 'description: 通过 dd-playwright MCP 服务(提供 browser_* 工具集)进行点点前端业务自动化测试、免登调试、页面截图排查与 dev server 代理映射',
619
+ 'globs: *',
620
+ '---',
621
+ '# dd-playwright 自动化指南',
622
+ '本技能必须通过 dd-playwright MCP 服务提供的 browser_* 系列工具执行:',
623
+ '1. 登录态与身份切换:访问点点页面调用本 MCP 的 browser_navigate 自动注入激活账号免登;排查游客态或未登录拦截必须传 anonymous: true;指定测试账号传特定 uid。',
624
+ '2. 本地 Dev Server 联调:严禁修改系统 hosts。先调用本 MCP 的 browser_set_proxy_rule 将本地端口映射到点点子域名(如 127.0.0.1:9527 映射为 p9527-test.tuwan.com),免改 hosts 携带线上 Cookie。',
625
+ '3. 页面交互规范:定位困难时优先调用 browser_get_dom 提取可见元素结构,严禁盲猜选择器;表单填写使用 browser_fill_text,按键使用 browser_press_key。'
626
+ ].join('\\n');
627
+
628
+ const cursorUrl = 'cursor://anysphere.cursor-deeplink/rule?name=' + encodeURIComponent(ruleName) + '&text=' + encodeURIComponent(compactRule);
629
+ window.location.href = cursorUrl;
630
+ }
631
+
632
+
633
+ async function loadSettings() {
634
+ try {
635
+ const res = await fetch('/api/settings');
636
+ const data = await res.json();
637
+ if (data.ok && data.settings) {
638
+ updateHeadlessUI(data.settings.headless);
639
+ }
640
+ } catch {}
641
+ }
642
+
643
+ function updateHeadlessUI(isHeadless) {
644
+ const checkbox = document.getElementById('headlessToggle');
645
+ const slider = document.getElementById('headlessSlider');
646
+ const knob = document.getElementById('headlessKnob');
647
+ if (!checkbox || !slider || !knob) return;
648
+ checkbox.checked = Boolean(isHeadless);
649
+ if (isHeadless) {
650
+ slider.style.backgroundColor = 'var(--accent)';
651
+ knob.style.transform = 'translateX(22px)';
652
+ } else {
653
+ slider.style.backgroundColor = '#334155';
654
+ knob.style.transform = 'translateX(0px)';
655
+ }
656
+ }
657
+
658
+ async function toggleHeadless(checked) {
659
+ updateHeadlessUI(checked);
660
+ try {
661
+ await fetch('/api/settings', {
662
+ method: 'POST',
663
+ headers: { 'Content-Type': 'application/json' },
664
+ body: JSON.stringify({ key: 'headless', value: checked })
665
+ });
666
+ } catch (err) {
667
+ alert('保存设置失败: ' + err.message);
668
+ }
669
+ }
670
+
671
+ // 初次加载账号与配置
672
+ loadAccounts();
673
+ loadSettings();
674
+ </script>
675
+ </body>
676
+ </html>`;
677
+
678
+ export function createWebServer({ accountStore, authManager }) {
679
+ const server = createServer(async (req, res) => {
680
+ const urlObj = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
681
+ const pathname = urlObj.pathname;
682
+ // CORS preflight
683
+ if (req.method === "OPTIONS") {
684
+ res.writeHead(204, {
685
+ "Access-Control-Allow-Origin": "*",
686
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
687
+ "Access-Control-Allow-Headers": "Content-Type",
688
+ });
689
+ res.end();
690
+ return;
691
+ }
692
+
693
+ // 1. 首页控制台
694
+ if (pathname === "/" || pathname === "/index.html") {
695
+ const currentFilePath = fileURLToPath(import.meta.url).replaceAll("\\", "/");
696
+ const isInstalledViaNpm = /\/node_modules\/(@taole\/)?dd-playwright\//i.test(currentFilePath);
697
+ const serverScriptPath = fileURLToPath(
698
+ new URL("../../bin/mcp-server.mjs", import.meta.url),
699
+ ).replaceAll("\\", "/");
700
+
701
+ const globalConfigStr = JSON.stringify(
702
+ {
703
+ mcpServers: {
704
+ "dd-playwright": {
705
+ command: "dd-playwright-mcp",
706
+ },
707
+ },
708
+ },
709
+ null,
710
+ 2,
711
+ );
712
+
713
+ const localConfigStr = JSON.stringify(
714
+ {
715
+ mcpServers: {
716
+ "dd-playwright": {
717
+ command: "node",
718
+ args: [serverScriptPath],
719
+ },
720
+ },
721
+ },
722
+ null,
723
+ 2,
724
+ );
725
+
726
+ let bannerStyle = "background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.3); color: #60a5fa;";
727
+ let bannerContent = `💻 <b>当前代码位置:</b><code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; color: #93c5fd;">${serverScriptPath}</code><br><span style="color:#cbd5e1; font-size:12px; margin-top: 4px; display: inline-block;">使用 Node 绝对路径方式挂载 MCP,不受系统 PATH 或环境切换影响,100% 稳定运行。</span>`;
728
+
729
+ let configSections = `
730
+ <div class="code-title">
731
+ <span>本地绝对路径配置(推荐,开箱即用)</span>
732
+ <button class="copy-btn" onclick="copyCode('code-mcp-local')">复制代码</button>
733
+ </div>
734
+ <div id="code-mcp-local" class="code-block">${localConfigStr}</div>
735
+ `;
736
+
737
+ const renderedHtml = DASHBOARD_HTML
738
+ .replace("{{BANNER_STYLE}}", bannerStyle)
739
+ .replace("{{BANNER_CONTENT}}", bannerContent)
740
+ .replace("{{CONFIG_SECTIONS}}", configSections);
741
+
742
+ sendHtml(res, renderedHtml);
743
+ return;
744
+ }
745
+
746
+ // 2. 账号列表
747
+ if (pathname === "/api/accounts" && req.method === "GET") {
748
+ try {
749
+ const accounts = accountStore.listAccounts();
750
+ const active = accountStore.getActiveAccount();
751
+ sendJson(res, 200, { ok: true, accounts, active });
752
+ } catch (err) {
753
+ sendJson(res, 500, { ok: false, error: err.message });
754
+ }
755
+ return;
756
+ }
757
+ // 2.1 获取 SKILL.md 原始内容
758
+ if (pathname === "/api/skill" && req.method === "GET") {
759
+ try {
760
+ const { readFileSync } = await import("node:fs");
761
+ const skillPath = fileURLToPath(new URL("../../SKILL.md", import.meta.url));
762
+ const content = readFileSync(skillPath, "utf8");
763
+ sendJson(res, 200, { ok: true, content });
764
+ } catch (err) {
765
+ sendJson(res, 500, { ok: false, error: err.message });
766
+ }
767
+ return;
768
+ }
769
+
770
+ // 2.2 下载 SKILL.md 附件
771
+ if (pathname === "/api/skill/download" && req.method === "GET") {
772
+ try {
773
+ const { readFileSync } = await import("node:fs");
774
+ const skillPath = fileURLToPath(new URL("../../SKILL.md", import.meta.url));
775
+ const content = readFileSync(skillPath, "utf8");
776
+ res.writeHead(200, {
777
+ "Content-Type": "text/markdown; charset=utf-8",
778
+ "Content-Disposition": 'attachment; filename="SKILL.md"',
779
+ "Cache-Control": "no-cache",
780
+ });
781
+ res.end(content);
782
+ } catch (err) {
783
+ sendJson(res, 500, { ok: false, error: err.message });
784
+ }
785
+ return;
786
+ }
787
+
788
+
789
+ // 3. 切换激活账号
790
+ if (pathname === "/api/switch" && req.method === "POST") {
791
+ let body = "";
792
+ req.on("data", (c) => (body += c));
793
+ req.on("end", () => {
794
+ try {
795
+ const { accountId } = JSON.parse(body || "{}");
796
+ accountStore.setActiveAccount(accountId);
797
+ sendJson(res, 200, { ok: true });
798
+ } catch (err) {
799
+ sendJson(res, 500, { ok: false, error: err.message });
800
+ }
801
+ });
802
+ return;
803
+ }
804
+
805
+ // 4. 删除/解绑账号
806
+ if (pathname === "/api/delete" && req.method === "POST") {
807
+ let body = "";
808
+ req.on("data", (c) => (body += c));
809
+ req.on("end", () => {
810
+ try {
811
+ const { accountId } = JSON.parse(body || "{}");
812
+ accountStore.deleteAccount(accountId);
813
+ sendJson(res, 200, { ok: true });
814
+ } catch (err) {
815
+ sendJson(res, 500, { ok: false, error: err.message });
816
+ }
817
+ });
818
+ return;
819
+ }
820
+ // 4.1 获取配置(如 headless)
821
+ if (pathname === "/api/settings" && req.method === "GET") {
822
+ try {
823
+ const headless = accountStore.getSetting("headless", true);
824
+ sendJson(res, 200, { ok: true, settings: { headless } });
825
+ } catch (err) {
826
+ sendJson(res, 500, { ok: false, error: err.message });
827
+ }
828
+ return;
829
+ }
830
+
831
+ // 4.2 保存配置(如 headless)
832
+ if (pathname === "/api/settings" && req.method === "POST") {
833
+ let body = "";
834
+ req.on("data", (c) => (body += c));
835
+ req.on("end", () => {
836
+ try {
837
+ const { key, value } = JSON.parse(body || "{}");
838
+ if (!key) throw new Error("缺少 setting key");
839
+ accountStore.setSetting(key, value);
840
+ sendJson(res, 200, { ok: true, key, value });
841
+ } catch (err) {
842
+ sendJson(res, 500, { ok: false, error: err.message });
843
+ }
844
+ });
845
+ return;
846
+ }
847
+
848
+
849
+ // 5. 启动扫码
850
+ if (pathname === "/api/login/start" && req.method === "POST") {
851
+ try {
852
+ const headless = accountStore.getSetting("headless", true);
853
+ const result = await authManager.startLogin({ headless });
854
+ sendJson(res, 200, { ok: true, ...result });
855
+ } catch (err) {
856
+ sendJson(res, 500, { ok: false, error: err.message });
857
+ }
858
+ return;
859
+ }
860
+
861
+ // 6. 获取扫码二维码截图
862
+ if (pathname === "/api/login/screenshot" && req.method === "GET") {
863
+ try {
864
+ const buffer = await authManager.getScreenshot();
865
+ res.writeHead(200, {
866
+ "Content-Type": "image/png",
867
+ "Cache-Control": "no-store",
868
+ });
869
+ res.end(buffer);
870
+ } catch (err) {
871
+ sendJson(res, 500, { ok: false, error: err.message });
872
+ }
873
+ return;
874
+ }
875
+
876
+ // 7. 查询扫码状态
877
+ if (pathname === "/api/login/status" && req.method === "GET") {
878
+ try {
879
+ const result = await authManager.checkStatus();
880
+ sendJson(res, 200, { ok: true, ...result });
881
+ } catch (err) {
882
+ sendJson(res, 500, { ok: false, error: err.message });
883
+ }
884
+ return;
885
+ }
886
+
887
+ // 8. 取消扫码 / 关闭弹窗
888
+ if (pathname === "/api/login/cancel" && req.method === "POST") {
889
+ try {
890
+ await authManager.close();
891
+ sendJson(res, 200, { ok: true });
892
+ } catch (err) {
893
+ sendJson(res, 500, { ok: false, error: err.message });
894
+ }
895
+ return;
896
+ }
897
+
898
+ sendJson(res, 404, { ok: false, error: "Not Found" });
899
+ });
900
+
901
+ return server;
902
+ }
903
+
904
+ export async function startWebServer({
905
+ port = 12309,
906
+ dbPath,
907
+ dataDir,
908
+ accountStore,
909
+ } = {}) {
910
+ const store = accountStore || new AccountStore(dbPath);
911
+ const authManager = new DiandianAuthManager({ accountStore: store });
912
+ const server = createWebServer({ accountStore: store, authManager });
913
+
914
+ return new Promise((resolve, reject) => {
915
+ server.listen(port, "0.0.0.0", () => {
916
+ resolve({
917
+ server,
918
+ port,
919
+ accountStore: store,
920
+ authManager,
921
+ close: async () => {
922
+ await authManager.close();
923
+ store.close();
924
+ await new Promise((r) => server.close(r));
925
+ },
926
+ });
927
+ });
928
+ server.on("error", reject);
929
+ });
930
+ }