@seasonkoh/webaz 0.1.27 → 0.1.28

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,608 @@
1
+ // WebAZ — admin monitoring pages (classic multi-script split, first slice)
2
+ //
3
+ // Loaded as a CLASSIC script BEFORE app.js (see index.html order):
4
+ // i18n → app-admin → app-contribution → app-ai → app-discover → app-profile → app-account → app-shop → app-listings → app-seller → app.js (source of truth: index.html)
5
+ // These are top-level function declarations → global, callable from app.js's
6
+ // router (render() dispatches #admin/health|errors|events here). They only run
7
+ // on route/click, by which point app.js has finished loading and all shared
8
+ // globals (state, GET/POST, isAdmin, shell, loading$, alert$, escHtml, fmtTime,
9
+ // renderEventRow, pageHeader, t) are defined. No import/export — cross-file
10
+ // access is via the global scope only.
11
+ //
12
+ // Read-only monitoring pages only (no payment/order/wallet/mutation).
13
+
14
+ // A-4: 系统健康监控
15
+ async function renderAdminHealth(app) {
16
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
17
+ app.innerHTML = shell(loading$(), 'admin')
18
+ const r = await GET('/admin/health')
19
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'admin'); return }
20
+ const fmtUptime = (s) => {
21
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
22
+ return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`
23
+ }
24
+ const tableRows = Object.entries(r.db.tables).map(([t, c]) => `
25
+ <tr style="border-bottom:1px solid #f3f4f6;font-size:11px">
26
+ <td style="padding:4px 8px;font-family:monospace">${t}</td>
27
+ <td style="padding:4px 8px;text-align:right;${c === -1 ? 'color:#9ca3af' : ''}">${c === -1 ? '(无表)' : c.toLocaleString()}</td>
28
+ </tr>
29
+ `).join('')
30
+ const rpcColor = !r.rpc.ok ? '#dc2626' : r.rpc.latency_ms > 2000 ? '#dc2626' : r.rpc.latency_ms > 500 ? '#d97706' : '#16a34a'
31
+ app.innerHTML = shell(`
32
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
33
+ <h1 class="page-title">🩺 ${t('系统健康')}</h1>
34
+
35
+ <div class="card" style="padding:14px;margin-bottom:10px">
36
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">📌 ${t('运行时')}</div>
37
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;font-size:12px">
38
+ <div><div style="color:#9ca3af">${t('启动时长')}</div><div style="font-weight:700">${fmtUptime(r.uptime_sec)}</div></div>
39
+ <div><div style="color:#9ca3af">${t('环境')}</div><div style="font-weight:700">${r.node_env} / ${r.network}</div></div>
40
+ <div><div style="color:#9ca3af">${t('RSS 内存')}</div><div style="font-weight:700">${r.memory.rss_mb} MB</div></div>
41
+ <div><div style="color:#9ca3af">${t('Heap 已用')}</div><div style="font-weight:700">${r.memory.heap_used_mb} / ${r.memory.heap_total_mb} MB</div></div>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="card" style="padding:14px;margin-bottom:10px">
46
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">⛓ ${t('链上 RPC')}</div>
47
+ <div style="font-size:11px;color:#9ca3af;font-family:monospace;margin-bottom:4px">${escHtml(r.rpc.url)}</div>
48
+ <div style="font-size:18px;font-weight:700;color:${rpcColor}">${r.rpc.ok ? r.rpc.latency_ms + ' ms' : '✗ ' + t('不可达')}</div>
49
+ </div>
50
+
51
+ <div class="card" style="padding:14px;margin-bottom:10px">
52
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">💾 ${t('数据库')} ${r.db.size_mb ? '· ' + r.db.size_mb + ' MB' : ''}</div>
53
+ <table style="width:100%;border-collapse:collapse">${tableRows}</table>
54
+ </div>
55
+
56
+ <div class="card" style="padding:14px;margin-bottom:10px">
57
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">🧠 ${t('内存缓冲')}</div>
58
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px;font-size:11px">
59
+ ${Object.entries(r.in_memory_buffers).map(([k, v]) => `<div><div style="color:#9ca3af">${k}</div><div style="font-weight:700">${v}</div></div>`).join('')}
60
+ </div>
61
+ </div>
62
+ `, 'admin')
63
+ }
64
+
65
+ // Tier 1 #5: 错误监控聚合 view(24h trend + burst alert + top errors)
66
+ async function renderAdminErrors(app) {
67
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
68
+ app.innerHTML = shell(loading$(), 'admin')
69
+ const [agg, rawRes] = await Promise.all([
70
+ GET('/admin/errors/aggregate').catch(() => ({ totals: { total_24h: 0, total_1h: 0, total_10m: 0 }, by_source: [], top_messages: [], burst: [], thresholds: {} })),
71
+ GET('/admin/errors?limit=20').catch(() => ({ items: [] })),
72
+ ])
73
+ const totals = agg.totals || { total_24h: 0, total_1h: 0, total_10m: 0 }
74
+ const bySource = agg.by_source || []
75
+ const topMsgs = agg.top_messages || []
76
+ const burst = agg.burst || []
77
+ const rawItems = rawRes.items || []
78
+ const fmtTime = (s) => { try { return new Date(s.replace(' ', 'T') + 'Z').toLocaleString() } catch { return s } }
79
+ const sevColor = (n) => n > 100 ? '#dc2626' : n > 10 ? '#d97706' : '#16a34a'
80
+
81
+ app.innerHTML = shell(`
82
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
83
+ <h1 class="page-title">🛑 ${t('错误监控')}</h1>
84
+ <div style="font-size:11px;color:#6b7280;margin-bottom:14px">${t('过去 24 小时所有 server / 客户端错误聚合。burst 阈值:1h > ')}${agg.thresholds?.burst_1h || 50} ${t('或 10min > ')}${agg.thresholds?.burst_10m || 20}</div>
85
+
86
+ ${burst.length > 0 ? `
87
+ <div class="alert" style="background:#fef2f2;border:1px solid #fecaca;color:#991b1b;padding:12px;border-radius:8px;margin-bottom:14px;font-size:13px;line-height:1.6">
88
+ <div style="font-weight:700;margin-bottom:4px">🚨 ${t('Burst 告警')} — ${burst.length} ${t('个 source 异常')}</div>
89
+ ${burst.map(b => `<div>· <strong>${escHtml(b.source)}</strong> · ${escHtml(b.reason)}</div>`).join('')}
90
+ </div>` : ''}
91
+
92
+ <div class="card" style="padding:14px;margin-bottom:10px">
93
+ <div style="font-size:13px;font-weight:600;margin-bottom:10px">📊 ${t('总计(24h 窗口)')}</div>
94
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;font-size:12px">
95
+ <div style="text-align:center"><div style="font-size:22px;font-weight:800;color:${sevColor(totals.total_24h)}">${totals.total_24h || 0}</div><div style="color:#9ca3af;margin-top:2px">${t('24h 总数')}</div></div>
96
+ <div style="text-align:center"><div style="font-size:22px;font-weight:800;color:${sevColor(totals.total_1h)}">${totals.total_1h || 0}</div><div style="color:#9ca3af;margin-top:2px">${t('1h 总数')}</div></div>
97
+ <div style="text-align:center"><div style="font-size:22px;font-weight:800;color:${sevColor(totals.total_10m)}">${totals.total_10m || 0}</div><div style="color:#9ca3af;margin-top:2px">${t('10min 总数')}</div></div>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="card" style="padding:14px;margin-bottom:10px">
102
+ <div style="font-size:13px;font-weight:600;margin-bottom:10px">📡 ${t('按 source 分组')}</div>
103
+ ${bySource.length === 0 ? `<div style="font-size:12px;color:#9ca3af;text-align:center;padding:20px">${t('过去 24h 无错误 🎉')}</div>` : `
104
+ <table style="width:100%;border-collapse:collapse;font-size:12px">
105
+ <thead style="background:#f9fafb">
106
+ <tr>
107
+ <th style="text-align:left;padding:6px 8px;color:#6b7280;font-weight:600">source</th>
108
+ <th style="text-align:right;padding:6px 8px;color:#6b7280;font-weight:600">24h</th>
109
+ <th style="text-align:right;padding:6px 8px;color:#6b7280;font-weight:600">1h</th>
110
+ <th style="text-align:right;padding:6px 8px;color:#6b7280;font-weight:600">10min</th>
111
+ <th style="text-align:left;padding:6px 8px;color:#6b7280;font-weight:600">last_seen</th>
112
+ </tr>
113
+ </thead>
114
+ <tbody>
115
+ ${bySource.map(r => `
116
+ <tr style="border-top:1px solid #f3f4f6">
117
+ <td style="padding:6px 8px;font-family:monospace">${escHtml(r.source)}</td>
118
+ <td style="padding:6px 8px;text-align:right;color:${sevColor(r.cnt_24h)};font-weight:600">${r.cnt_24h}</td>
119
+ <td style="padding:6px 8px;text-align:right;color:${sevColor(r.cnt_1h)}">${r.cnt_1h}</td>
120
+ <td style="padding:6px 8px;text-align:right;color:${sevColor(r.cnt_10m)}">${r.cnt_10m}</td>
121
+ <td style="padding:6px 8px;color:#6b7280;font-size:11px">${fmtTime(r.last_seen)}</td>
122
+ </tr>`).join('')}
123
+ </tbody>
124
+ </table>`}
125
+ </div>
126
+
127
+ ${topMsgs.length > 0 ? `
128
+ <div class="card" style="padding:14px;margin-bottom:10px">
129
+ <div style="font-size:13px;font-weight:600;margin-bottom:10px">🔁 ${t('top 10 高频错误(前 100 字符 hash)')}</div>
130
+ ${topMsgs.map(m => `
131
+ <div style="padding:8px 0;border-top:1px solid #f3f4f6;font-size:12px">
132
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
133
+ <div style="flex:1;min-width:0">
134
+ <div style="font-family:monospace;color:#374151;word-break:break-all;line-height:1.4">${escHtml(m.msg)}</div>
135
+ <div style="font-size:10px;color:#9ca3af;margin-top:3px">${escHtml(m.source)} · ${fmtTime(m.last_seen)}</div>
136
+ </div>
137
+ <div style="font-size:14px;font-weight:800;color:${sevColor(m.cnt)};flex-shrink:0">${m.cnt}</div>
138
+ </div>
139
+ </div>`).join('')}
140
+ </div>` : ''}
141
+
142
+ <div class="card" style="padding:14px">
143
+ <div style="font-size:13px;font-weight:600;margin-bottom:10px">📜 ${t('最近 20 条原始记录')}</div>
144
+ ${rawItems.length === 0 ? `<div style="font-size:12px;color:#9ca3af;text-align:center;padding:20px">${t('暂无')}</div>` : rawItems.map(it => `
145
+ <details style="padding:8px 0;border-top:1px solid #f3f4f6;font-size:12px">
146
+ <summary style="cursor:pointer;display:flex;justify-content:space-between;gap:8px;align-items:flex-start">
147
+ <span style="flex:1;min-width:0"><span style="font-family:monospace;color:#dc2626">${escHtml(it.source)}</span> · <span style="color:#374151">${escHtml((it.message || '').slice(0, 80))}</span></span>
148
+ <span style="font-size:10px;color:#9ca3af;flex-shrink:0">${fmtTime(it.created_at)}</span>
149
+ </summary>
150
+ <div style="padding:8px 0 0;font-size:11px;color:#6b7280;line-height:1.5">
151
+ ${it.stack ? `<div style="margin-bottom:6px"><div style="color:#9ca3af;font-size:10px">stack</div><pre style="font-family:monospace;white-space:pre-wrap;word-break:break-all;background:#f9fafb;padding:6px;border-radius:4px;font-size:10px;margin:2px 0">${escHtml(it.stack)}</pre></div>` : ''}
152
+ ${it.url ? `<div><span style="color:#9ca3af">URL: </span>${escHtml(it.url)}</div>` : ''}
153
+ ${it.user_id ? `<div><span style="color:#9ca3af">user: </span>${escHtml(it.user_id)}</div>` : ''}
154
+ ${it.user_agent ? `<div><span style="color:#9ca3af">UA: </span>${escHtml(it.user_agent)}</div>` : ''}
155
+ </div>
156
+ </details>
157
+ `).join('')}
158
+ </div>
159
+ `, 'admin')
160
+ }
161
+
162
+ // Wave F-5: 实时事件 stream
163
+ let _adminEventSource = null
164
+ async function renderAdminEvents(app) {
165
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
166
+ app.innerHTML = shell(loading$(), 'admin')
167
+ const r = await GET('/admin/events/recent?limit=100')
168
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'admin'); return }
169
+ const items = r?.items || []
170
+ app.innerHTML = shell(`
171
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="closeAdminEvents()">${t('← 返回')}</button>
172
+ <h1 class="page-title">📡 ${t('实时事件 stream')}</h1>
173
+ <div id="evt-status" style="font-size:11px;color:#9ca3af;margin-bottom:8px">${t('已连接')} · ${t('显示最近')} ${items.length} ${t('条')}</div>
174
+ <div id="evt-list" style="background:#fff;border:1px solid #e5e7eb;border-radius:8px;max-height:70vh;overflow-y:auto">
175
+ ${items.map(e => renderEventRow(e)).join('')}
176
+ </div>
177
+ `, 'admin')
178
+ // 启动 SSE — P0-1: 先用 api_key 换一次性 ticket,再用 ticket 建连接
179
+ if (_adminEventSource) { try { _adminEventSource.close() } catch {} }
180
+ const ticketRes = await POST('/admin/events/ticket', {})
181
+ if (ticketRes.error || !ticketRes.ticket) {
182
+ const s = document.getElementById('evt-status')
183
+ if (s) s.innerHTML = `<span style="color:#dc2626">⚠ ${t('鉴权失败')}: ${ticketRes.error || '—'}</span>`
184
+ return
185
+ }
186
+ _adminEventSource = new EventSource(`/api/admin/events/stream?ticket=${encodeURIComponent(ticketRes.ticket)}`)
187
+ _adminEventSource.onmessage = (ev) => {
188
+ try {
189
+ const evt = JSON.parse(ev.data)
190
+ if (evt.type === 'hello') return
191
+ const list = document.getElementById('evt-list')
192
+ if (!list) return
193
+ const wrap = document.createElement('div')
194
+ wrap.innerHTML = renderEventRow(evt)
195
+ list.prepend(wrap.firstElementChild)
196
+ // 限制 DOM 100 条
197
+ while (list.children.length > 100) list.lastElementChild.remove()
198
+ } catch {}
199
+ }
200
+ _adminEventSource.onerror = () => {
201
+ const s = document.getElementById('evt-status')
202
+ if (s) s.innerHTML = `<span style="color:#dc2626">⚠ ${t('连接中断')}</span>`
203
+ }
204
+ }
205
+
206
+ window.closeAdminEvents = () => {
207
+ if (_adminEventSource) { try { _adminEventSource.close() } catch {}; _adminEventSource = null }
208
+ history.back()
209
+ }
210
+
211
+ // ─── admin read-only hubs + helpers (classic split, slice B) ───
212
+ // adminPageHeader/adminLinkCard are shared admin helpers; render* are read-only
213
+ // hub/audit pages. Same global-scope rules as the rest of this file.
214
+
215
+ // 向后兼容 — 老 admin 代码用 adminPageHeader
216
+ function adminPageHeader(icon, title, subtitle) { return pageHeader(icon, title, subtitle, 'admin') }
217
+ // admin 通用卡片网格 helper
218
+ function adminLinkCard(icon, label, sub, hash, badge) {
219
+ return `<div onclick="location.hash='${hash}'" class="card" style="padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px;min-height:64px;position:relative;transition:transform 0.1s" onmouseover="this.style.transform='translateY(-2px)'" onmouseout="this.style.transform=''">
220
+ <div style="font-size:24px;flex-shrink:0">${icon}</div>
221
+ <div style="flex:1;min-width:0">
222
+ <div style="font-weight:600;font-size:14px">${label}</div>
223
+ ${sub ? `<div style="font-size:11px;color:#9ca3af;margin-top:2px">${sub}</div>` : ''}
224
+ </div>
225
+ ${badge != null && badge !== '' ? `<div style="background:#dc2626;color:#fff;border-radius:99px;font-size:10px;padding:2px 7px;min-width:18px;text-align:center;flex-shrink:0;font-weight:600">${badge}</div>` : ''}
226
+ </div>`
227
+ }
228
+
229
+ // === #admin/content 内容管理 hub ===
230
+ async function renderAdminContent(app) {
231
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin-content'); return }
232
+ app.innerHTML = shell(loading$(), 'admin-content')
233
+ const [dash, reportsRes] = await Promise.all([
234
+ GET('/admin/dashboard').catch(() => ({})),
235
+ GET('/admin/wish-reports?status=pending').catch(() => ({ items: [] })),
236
+ ])
237
+ const pendingReports = reportsRes?.items?.length || 0
238
+ app.innerHTML = shell(`
239
+ ${adminPageHeader('📦', t('内容管理'), t('商品 / 订单 / 慈善举报 集中处理'))}
240
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
241
+ ${adminLinkCard('📦', t('商品管理'), t('强制下架 / 批量'), '#admin/products')}
242
+ ${adminLinkCard('🧾', t('订单只读'), t('全平台监控'), '#admin/orders')}
243
+ ${adminLinkCard('🌸', t('慈善举报'), pendingReports > 0 ? t('待处理 ') + pendingReports : t('无待处理'), '#admin/wish-reports', pendingReports || '')}
244
+ ${adminLinkCard('🚫', t('用户黑名单'), t('封号 / 警告记录'), '#admin/users')}
245
+ </div>
246
+ `, 'admin-content')
247
+ }
248
+
249
+ // === #admin/arbitration 仲裁与审核 hub ===
250
+ async function renderAdminArbitration(app) {
251
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin-arbitration'); return }
252
+ app.innerHTML = shell(loading$(), 'admin-arbitration')
253
+ const dash = await GET('/admin/dashboard').catch(() => ({}))
254
+ app.innerHTML = shell(`
255
+ ${adminPageHeader('⚖', t('仲裁与审核'), t('争议案件 / 验证任务 / 仲裁员监管'))}
256
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
257
+ ${adminLinkCard('⚖️', t('争议监控'), t('全平台仲裁案件'), '#admin/disputes', dash.disputes_open || '')}
258
+ ${adminLinkCard('🔎', t('验证任务'), t('claim 任务监控'), '#admin/tasks', dash.verify_tasks_open || '')}
259
+ </div>
260
+ `, 'admin-arbitration')
261
+ }
262
+
263
+ async function renderAdminAudit(app) {
264
+ if (!state.user) { renderLogin(); return }
265
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin-audit'); return }
266
+ app.innerHTML = shell(loading$(), 'admin-audit')
267
+ const data = await GET('/admin/audit-log')
268
+ if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'admin-audit'); return }
269
+ const items = data.entries.length
270
+ ? data.entries.map(e => `
271
+ <div class="card" style="margin-bottom:10px;font-size:13px">
272
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
273
+ <div style="flex:1;min-width:0">
274
+ <div><strong>${e.action}</strong> · <span style="color:#6b7280">${escHtml(e.admin_name || e.admin_id)}</span></div>
275
+ ${e.target_id ? `<div style="font-size:11px;color:#6b7280;margin-top:2px">${e.target_type || ''}: ${e.target_id}</div>` : ''}
276
+ ${e.detail && Object.keys(e.detail).length ? `<pre style="font-size:11px;background:#f9fafb;padding:6px;border-radius:4px;margin-top:6px;overflow:auto;white-space:pre-wrap">${escHtml(JSON.stringify(e.detail))}</pre>` : ''}
277
+ </div>
278
+ <div style="font-size:11px;color:#9ca3af;white-space:nowrap">${fmtTime(e.created_at)}</div>
279
+ </div>
280
+ </div>`).join('')
281
+ : `<div class="empty"><div class="empty-icon">📜</div><div class="empty-text">${t('暂无操作记录')}</div></div>`
282
+ app.innerHTML = shell(`
283
+ <h1 class="page-title">📜 ${t('操作审计')}</h1>
284
+ <div style="font-size:12px;color:#6b7280;margin-bottom:12px">${t('最近 50 条 admin 操作记录')}</div>
285
+ ${items}
286
+ `, 'admin-audit')
287
+ }
288
+
289
+ // ─── admin overview / metrics / security (read-only; classic split, slice C) ───
290
+ // kpiGrid / pageHeader / adminPageHeader stay in app.js / app-admin.js as shared
291
+ // globals; these read-only pages resolve them at call time (route/click).
292
+
293
+ async function renderAdminKPI(app) {
294
+ if (!state.user) { renderLogin(); return }
295
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
296
+ app.innerHTML = shell(loading$(), 'admin')
297
+ const r = await GET('/admin/protocol-kpi')
298
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'admin'); return }
299
+
300
+ const fmt = (n) => Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 })
301
+ const fmt2 = (n) => Number(n || 0).toFixed(2)
302
+ const pct = (n) => (Number(n || 0) * 100).toFixed(2) + '%'
303
+
304
+ // Activity 卡片
305
+ const activityCard = `
306
+ <div class="card" style="padding:14px;margin-bottom:10px;background:linear-gradient(135deg,#eef2ff,#fff)">
307
+ <div style="font-size:14px;font-weight:700;margin-bottom:8px">📊 ${t('活跃度')}</div>
308
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;text-align:center">
309
+ <div><div style="font-size:24px;font-weight:700;color:#4f46e5">${fmt(r.activity.dau_proxy)}</div><div style="font-size:10px;color:#6b7280">${t('DAU')} (${t('近似')})</div></div>
310
+ <div><div style="font-size:24px;font-weight:700;color:#7c3aed">${fmt(r.activity.mau_proxy)}</div><div style="font-size:10px;color:#6b7280">${t('MAU')} (${t('近似')})</div></div>
311
+ </div>
312
+ </div>
313
+ `
314
+
315
+ // 多窗口订单/GMV 表
316
+ const windowRows = r.activity.windows.map(w => `
317
+ <tr style="border-bottom:1px solid #f3f4f6;font-size:12px">
318
+ <td style="padding:8px;font-weight:600">${w.label}</td>
319
+ <td style="padding:8px;text-align:right">${fmt(w.orders)}</td>
320
+ <td style="padding:8px;text-align:right">${fmt(w.completed)}</td>
321
+ <td style="padding:8px;text-align:right;color:#4f46e5;font-weight:600">${fmt2(w.gmv)}</td>
322
+ <td style="padding:8px;text-align:right;color:${w.dispute_rate > 0.05 ? '#dc2626' : '#374151'}">${pct(w.dispute_rate)}</td>
323
+ <td style="padding:8px;text-align:right;color:${w.refund_rate > 0.05 ? '#dc2626' : '#374151'}">${pct(w.refund_rate)}</td>
324
+ <td style="padding:8px;text-align:right;color:#16a34a">${fmt(w.new_users)}</td>
325
+ </tr>
326
+ `).join('')
327
+ const windowsCard = `
328
+ <div class="card" style="padding:0;margin-bottom:10px;overflow-x:auto">
329
+ <div style="padding:12px;font-size:13px;font-weight:600;background:#f9fafb;border-bottom:1px solid #e5e7eb">📈 ${t('多窗口对比')}</div>
330
+ <table style="width:100%;border-collapse:collapse;font-size:11px;min-width:480px">
331
+ <thead><tr style="background:#fff;border-bottom:2px solid #e5e7eb;font-size:10px;color:#6b7280;text-transform:uppercase">
332
+ <th style="padding:8px;text-align:left">${t('窗口')}</th>
333
+ <th style="padding:8px;text-align:right">${t('订单')}</th>
334
+ <th style="padding:8px;text-align:right">${t('完成')}</th>
335
+ <th style="padding:8px;text-align:right">GMV</th>
336
+ <th style="padding:8px;text-align:right">${t('争议率')}</th>
337
+ <th style="padding:8px;text-align:right">${t('退款率')}</th>
338
+ <th style="padding:8px;text-align:right">${t('新用户')}</th>
339
+ </tr></thead>
340
+ <tbody>${windowRows}</tbody>
341
+ </table>
342
+ </div>
343
+ `
344
+
345
+ // 用户分布
346
+ const u = r.users
347
+ const userCard = `
348
+ <div class="card" style="padding:14px;margin-bottom:10px">
349
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">👥 ${t('用户构成')} (${fmt(u.total)})</div>
350
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;font-size:11px;text-align:center">
351
+ <div><div style="font-weight:700;color:#1e40af">${fmt(u.buyers)}</div><div style="color:#9ca3af">${t('买家')}</div></div>
352
+ <div><div style="font-weight:700;color:#9a3412">${fmt(u.sellers)}</div><div style="color:#9ca3af">${t('卖家')}</div></div>
353
+ <div><div style="font-weight:700;color:#166534">${fmt(u.logistics)}</div><div style="color:#9ca3af">${t('物流')}</div></div>
354
+ <div><div style="font-weight:700;color:#365314">${fmt(u.verifiers)}</div><div style="color:#9ca3af">${t('审核员')}</div></div>
355
+ <div><div style="font-weight:700;color:#9d174d">${fmt(u.arbitrators)}</div><div style="color:#9ca3af">${t('仲裁员')}</div></div>
356
+ <div><div style="font-weight:700;color:#991b1b">${fmt(u.admins)}</div><div style="color:#9ca3af">${t('管理员')}</div></div>
357
+ </div>
358
+ </div>
359
+ `
360
+
361
+ // 财务
362
+ const f = r.finance
363
+ const financeCard = `
364
+ <div class="card" style="padding:14px;margin-bottom:10px;background:linear-gradient(135deg,#ecfdf5,#fff)">
365
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">💰 ${t('财务')}</div>
366
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px;font-size:11px">
367
+ <div><div style="color:#9ca3af">${t('sys_protocol 余额')}</div><div style="font-size:14px;font-weight:700;color:${f.sys_protocol_balance < 0 ? '#dc2626' : '#16a34a'}">${fmt2(f.sys_protocol_balance)} WAZ</div></div>
368
+ <div><div style="color:#9ca3af">${t('已托管资金')}</div><div style="font-size:14px;font-weight:700;color:#4f46e5">${fmt2(f.total_escrowed)} WAZ</div></div>
369
+ <div><div style="color:#9ca3af">${t('总质押')}</div><div style="font-size:14px;font-weight:700;color:#7c3aed">${fmt2(f.total_staked)} WAZ</div></div>
370
+ <div><div style="color:#9ca3af">${t('平台拨付累计')}</div><div style="font-size:14px;font-weight:700;color:#d97706">${fmt2(f.platform_rewards_cumulative)} WAZ</div></div>
371
+ </div>
372
+ <div style="font-size:10px;color:#6b7280;margin-top:8px">${t('今日拨付')}: ${fmt2(f.platform_rewards_today)} WAZ</div>
373
+ </div>
374
+ `
375
+
376
+ // 内容
377
+ const c = r.content
378
+ const contentCard = `
379
+ <div class="card" style="padding:14px;margin-bottom:10px">
380
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">📦 ${t('内容生态')}</div>
381
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px;font-size:11px;text-align:center">
382
+ <div><div style="font-size:14px;font-weight:700">${fmt(c.products_active)}/${fmt(c.products_total)}</div><div style="color:#9ca3af">${t('商品 active/total')}</div></div>
383
+ <div><div style="font-size:14px;font-weight:700">${fmt(c.ratings_total)}</div><div style="color:#9ca3af">${t('累计评价')}</div></div>
384
+ <div><div style="font-size:14px;font-weight:700">${fmt(c.push_subscriptions)}</div><div style="color:#9ca3af">${t('推送订阅')}</div></div>
385
+ </div>
386
+ </div>
387
+ `
388
+
389
+ // 信任
390
+ const tr = r.trust_open
391
+ const trustCard = `
392
+ <div class="card" style="padding:14px;margin-bottom:10px;border-left:3px solid ${(tr.disputes_open + tr.feedback_open + tr.returns_pending) > 0 ? '#dc2626' : '#16a34a'}">
393
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">🚨 ${t('待处理事项')}</div>
394
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;font-size:11px;text-align:center">
395
+ <div><div style="font-size:16px;font-weight:700;color:${tr.disputes_open > 0 ? '#dc2626' : '#374151'}">${fmt(tr.disputes_open)}</div><div style="color:#9ca3af">${t('未结争议')}</div></div>
396
+ <div><div style="font-size:16px;font-weight:700;color:${tr.feedback_open > 0 ? '#d97706' : '#374151'}">${fmt(tr.feedback_open)}</div><div style="color:#9ca3af">${t('未受理反馈')}</div></div>
397
+ <div><div style="font-size:16px;font-weight:700;color:${tr.returns_pending > 0 ? '#d97706' : '#374151'}">${fmt(tr.returns_pending)}</div><div style="color:#9ca3af">${t('待处理退货')}</div></div>
398
+ </div>
399
+ </div>
400
+ `
401
+
402
+ app.innerHTML = shell(`
403
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
404
+ <h1 class="page-title">📊 ${t('协议指标看板')}</h1>
405
+ ${activityCard}
406
+ ${windowsCard}
407
+ ${trustCard}
408
+ ${financeCard}
409
+ ${userCard}
410
+ ${contentCard}
411
+ `, 'admin')
412
+ }
413
+
414
+ async function renderAdminDashboard(app) {
415
+ if (!state.user) { renderLogin(); return }
416
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
417
+ app.innerHTML = shell(loading$(), 'admin')
418
+ const data = await GET('/admin/dashboard')
419
+ if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'admin'); return }
420
+ const kpi1 = kpiGrid([
421
+ { label: t('用户总数'), value: data.users },
422
+ { label: t('卖家数'), value: data.sellers },
423
+ { label: t('在售商品'), value: data.products_active },
424
+ ])
425
+ const kpi2 = kpiGrid([
426
+ { label: t('24h 订单'), value: data.orders_24h },
427
+ { label: t('24h GMV'), value: Number(data.gmv_24h || 0).toFixed(2), unit: 'WAZ' },
428
+ { label: t('系统锁仓'), value: Number(data.total_locked || 0).toFixed(2), unit: 'WAZ' },
429
+ ])
430
+ const kpi3 = kpiGrid([
431
+ { label: t('待处理争议'), value: data.disputes_open },
432
+ { label: t('待审验证任务'), value: data.verify_tasks_open },
433
+ { label: t('已暂停账户'), value: data.users_suspended },
434
+ ])
435
+ const kpi4 = kpiGrid([
436
+ { label: t('待审申请'), value: data.verifier_apps_pending ?? 0 },
437
+ { label: t('待审申诉'), value: data.verifier_appeals_pending ?? 0 },
438
+ { label: t('活跃审核员'), value: data.active_verifiers ?? 0 },
439
+ ])
440
+ const kpi5 = kpiGrid([
441
+ { label: t('扩容申请'), value: data.quota_apps_pending ?? 0 },
442
+ { label: t('暂停发新品'), value: data.listing_paused_count ?? 0 },
443
+ { label: '—', value: '—' },
444
+ ])
445
+ const tk = data.tokenomics || {}
446
+ const kpiTokenomics1 = kpiGrid([
447
+ { label: t('累计分享分润'),value: Number(tk.commission_total || 0).toFixed(2), unit: 'WAZ' },
448
+ { label: t('PV 待处理'), value: tk.ledger_pending ?? 0 },
449
+ { label: t('参与记录用户'), value: tk.dirty_users ?? 0 },
450
+ ])
451
+ const kpiTokenomics2 = ''
452
+ // 异常告警 banner — 多条件聚合
453
+ const alerts = []
454
+ if ((data.active_verifiers ?? 0) < 5) alerts.push({ icon: '⚠️', color: '#dc2626', text: t('活跃审核员不足 5 人 — 请尽快批准申请'), href: '#admin/verifier-applications' })
455
+ if ((data.disputes_open ?? 0) > 10) alerts.push({ icon: '⚖️', color: '#dc2626', text: t('待处理争议') + ' > 10:' + data.disputes_open, href: '#admin/disputes' })
456
+ if ((data.verifier_apps_pending ?? 0) > 5) alerts.push({ icon: '📥', color: '#d97706', text: t('待审申请积压') + ': ' + data.verifier_apps_pending, href: '#admin/verifier-applications' })
457
+ if ((data.users_suspended ?? 0) > (data.users ?? 0) * 0.05) alerts.push({ icon: '⛔', color: '#d97706', text: t('暂停账户占比 > 5%') + ': ' + data.users_suspended, href: '#admin/users' })
458
+ const lowVerifierWarn = alerts.length > 0 ? `
459
+ <div style="margin-bottom:14px">
460
+ <div style="font-size:11px;color:#6b7280;margin-bottom:4px;font-weight:600">🚨 ${t('需要关注')} (${alerts.length})</div>
461
+ ${alerts.map(a => `<a href="${a.href}" style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:${a.color === '#dc2626' ? '#fef2f2' : '#fef3c7'};border:1px solid ${a.color === '#dc2626' ? '#fecaca' : '#fde68a'};color:${a.color};border-radius:8px;font-size:12px;margin-bottom:6px;text-decoration:none;font-weight:600"><span>${a.icon} ${a.text}</span><span>→</span></a>`).join('')}
462
+ </div>
463
+ ` : ''
464
+ const quickAction = (href, icon, label) =>
465
+ `<a href="${href}" style="text-decoration:none;color:inherit">
466
+ <div class="card" style="text-align:center;cursor:pointer;padding:18px 8px">
467
+ <div style="font-size:28px;margin-bottom:6px">${icon}</div>
468
+ <div style="font-size:13px;font-weight:600">${label}</div>
469
+ </div>
470
+ </a>`
471
+ const quickGrid = `
472
+ <div style="font-size:13px;color:#6b7280;margin:16px 0 8px">${t('数据查看')}</div>
473
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:16px">
474
+ ${quickAction('#admin/products', '📦', t('商品管理'))}
475
+ ${quickAction('#admin/orders', '🧾', t('订单查看'))}
476
+ ${quickAction('#admin/disputes', '⚖️', t('争议查看'))}
477
+ ${quickAction('#admin/tasks', '🔍', t('验证任务'))}
478
+ </div>
479
+ <div style="font-size:13px;color:#6b7280;margin:16px 0 8px">${t('审核员管理')}</div>
480
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:16px">
481
+ ${quickAction('#admin/verifier-applications', '📥', t('待审申请') + (data.verifier_apps_pending > 0 ? ` (${data.verifier_apps_pending})` : ''))}
482
+ ${quickAction('#admin/verifier-appeals', '📩', t('待审申诉') + (data.verifier_appeals_pending > 0 ? ` (${data.verifier_appeals_pending})` : ''))}
483
+ </div>
484
+ <div style="font-size:13px;color:#6b7280;margin:16px 0 8px">${t('卖家配额')}</div>
485
+ <div style="display:grid;grid-template-columns:repeat(1,1fr);gap:10px;margin-bottom:16px">
486
+ ${quickAction('#admin/quota-applications', '📥', t('扩容申请') + (data.quota_apps_pending > 0 ? ` (${data.quota_apps_pending})` : ''))}
487
+ </div>
488
+ <div style="font-size:13px;color:#6b7280;margin:16px 0 8px">⚛ ${t('Tokenomics')}</div>
489
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:16px">
490
+ ${quickAction('#admin/tokenomics', '⚙', t('协议运营 / 注册门控 / 佣金榜'))}
491
+ </div>
492
+ <div style="font-size:13px;color:#6b7280;margin:16px 0 8px">🔐 ${t('安全与审计')}</div>
493
+ <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:16px">
494
+ ${quickAction('#admin/security', '🪪', t('我的管理身份与权限'))}
495
+ ${quickAction('#admin/audit', '📜', t('审计日志'))}
496
+ </div>`
497
+
498
+ // A5 重设:渐变标题 + 分区标题 + 颜色块分组
499
+ const sectionTitle = (icon, title, color) => `
500
+ <div style="display:flex;align-items:center;gap:6px;margin:16px 0 6px">
501
+ <div style="width:3px;height:14px;background:${color};border-radius:2px"></div>
502
+ <div style="font-size:12px;color:#374151;font-weight:600">${icon} ${title}</div>
503
+ </div>
504
+ `
505
+ app.innerHTML = shell(`
506
+ ${adminPageHeader('🛡', t('管理员概览'), t('全平台 KPI · 异常告警 · 快捷操作'))}
507
+ ${lowVerifierWarn}
508
+ ${sectionTitle('👥', t('用户与商品'), '#3b82f6')}
509
+ ${kpi1}
510
+ ${sectionTitle('💰', t('交易与资金'), '#16a34a')}
511
+ ${kpi2}
512
+ ${sectionTitle('🚨', t('运营关注项'), '#dc2626')}
513
+ ${kpi3}
514
+ ${sectionTitle('🔍', t('审核员系统'), '#0891b2')}
515
+ ${kpi4}
516
+ ${sectionTitle('📥', t('卖家配额'), '#d97706')}
517
+ ${kpi5}
518
+ ${sectionTitle('⚙', t('协议运营'), '#9333ea')}
519
+ ${kpiTokenomics1}
520
+ ${kpiTokenomics2}
521
+ ${sectionTitle('⚡', t('快捷操作'), '#4f46e5')}
522
+ ${quickGrid}
523
+ `, 'admin')
524
+ }
525
+
526
+ // 管理身份与权限自查面板(只读)。回答"我正在以什么身份/级别/权限操作?",
527
+ // Passkey 责任绑定状态 + GitHub 关联 + 普通 admin vs root/破玻璃 + 经济操作审计须知。
528
+ // 纯前端:数据来自 /me(state.user)+ 只读 /contribution-identity/github/me;无新后端、无经济动作。
529
+ async function renderAdminSecurity(app) {
530
+ if (!state.user) { renderLogin(); return }
531
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
532
+ app.innerHTML = shell(loading$(), 'admin')
533
+ const u = state.user
534
+ const gid = await GET('/contribution-identity/github/me').catch(() => null)
535
+ const bindings = (gid && !gid.error && Array.isArray(gid.bindings)) ? gid.bindings : []
536
+
537
+ const adminType = u.admin_type || 'root'
538
+ const isRoot = adminType === 'root'
539
+ const scope = u.admin_scope || 'global'
540
+ let perms = []
541
+ try { perms = isRoot ? ['all'] : JSON.parse(u.admin_permissions || '[]') } catch { perms = [] }
542
+ const hasPasskey = !!u.has_passkey
543
+
544
+ const PERM_LABEL = () => ({ all: t('全部'), users: t('用户'), content: t('内容'), arbitration: t('仲裁'), protocol: t('协议 / 经济'), verifier_mgmt: t('审核员管理'), support: t('支持') })
545
+ const permChips = (perms.length === 0)
546
+ ? `<span style="font-size:12px;color:#dc2626">${t('无任何权限(请联系 root 配置)')}</span>`
547
+ : perms.map(p => `<span style="display:inline-block;background:#eef2ff;color:#3730a3;font-size:11px;padding:2px 8px;border-radius:99px;margin:0 4px 4px 0">${PERM_LABEL()[p] || p}</span>`).join('')
548
+
549
+ const row = (label, value) => `<div style="display:flex;justify-content:space-between;gap:10px;padding:7px 0;border-bottom:1px solid #f3f4f6"><span style="font-size:12px;color:#6b7280">${label}</span><span style="font-size:12px;color:#111827;text-align:right;word-break:break-all">${value}</span></div>`
550
+
551
+ const passkeyRow = hasPasskey
552
+ ? `<span style="color:#16a34a;font-weight:600">✓ ${t('已绑定')}</span>`
553
+ : `<span style="color:#dc2626;font-weight:600">⚠ ${t('未绑定')}</span> <a href="#me/settings" style="color:#6366f1;font-size:11px">${t('去绑定')} →</a>`
554
+ const githubRow = bindings.length > 0
555
+ ? bindings.map(b => `<code style="font-size:11px">github:${escHtml(String(b.github_actor_id))}</code>`).join(' ')
556
+ : `<span style="color:#9ca3af">${t('未关联')}</span> <a href="#my-contributions" style="color:#6366f1;font-size:11px">${t('去认领')} →</a>`
557
+
558
+ app.innerHTML = shell(`
559
+ ${adminPageHeader('🪪', t('我的管理身份与权限'), t('你正在以此身份操作 · 只读自查'))}
560
+
561
+ ${isRoot ? `
562
+ <div class="card" style="padding:12px;background:#fffbeb;border:1px solid #fcd34d;margin-bottom:10px">
563
+ <div style="font-size:13px;font-weight:700;color:#92400e">🚧 ${t('创始人 / 引导管理员(Founder Admin · Bootstrap Operator)')}</div>
564
+ <div style="font-size:12px;color:#78350f;margin-top:4px;line-height:1.6">${t('这是 pre-launch 引导期的【临时治理模式】:更广的只读可见性 + 有限的应急写权限 —— 不是日常全能账号。')}</div>
565
+ <div style="font-size:11px;color:#78350f;margin-top:6px;line-height:1.6">${t('设计目标:launch 后把创始人权力拆成更窄的角色 —— maintainer / support operator / arbitrator / finance reviewer / security admin(用 regional admin + 权限位逐步收窄)。')}</div>
566
+ </div>` : ''}
567
+
568
+ <div class="card" style="padding:14px">
569
+ <div style="font-size:13px;font-weight:700;margin-bottom:8px">👤 ${t('账户')}</div>
570
+ ${row(t('名称'), escHtml(u.name || ''))}
571
+ ${row(t('用户名'), '@' + escHtml(u.handle || ''))}
572
+ ${row(t('账户 ID'), `<code style="font-size:11px">${escHtml(u.id || '')}</code>`)}
573
+ </div>
574
+
575
+ <div class="card" style="padding:14px">
576
+ <div style="font-size:13px;font-weight:700;margin-bottom:8px">🛡 ${t('角色与级别')}</div>
577
+ ${row(t('角色'), t('管理员'))}
578
+ ${row(t('级别'), isRoot
579
+ ? `<span style="color:#b91c1c;font-weight:700">ROOT</span> · <span style="font-size:11px;color:#6b7280">${t('破玻璃 / 系统操作员')}</span>`
580
+ : `<span style="color:#0369a1;font-weight:700">REGIONAL</span>`)}
581
+ ${row(t('范围'), `<code style="font-size:11px">${escHtml(scope)}</code>`)}
582
+ <div style="font-size:12px;color:#6b7280;margin-top:8px;margin-bottom:4px">${t('有效权限')}</div>
583
+ <div>${permChips}</div>
584
+ </div>
585
+
586
+ <div class="card" style="padding:14px">
587
+ <div style="font-size:13px;font-weight:700;margin-bottom:8px">🔐 ${t('问责绑定')}</div>
588
+ ${row('Passkey', passkeyRow)}
589
+ ${row(t('GitHub 关联'), githubRow)}
590
+ <div style="font-size:11px;color:#92400e;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;padding:8px 10px;margin-top:8px;line-height:1.6">
591
+ ${t('管理身份应绑定 Passkey(真人问责)。个人 GitHub 账号用于提交 PR;仓库所有权 / 设置由组织/管理身份治理 —— 独立审阅不应由同一人用另一账号假冒。')}
592
+ </div>
593
+ </div>
594
+
595
+ <div class="card" style="padding:14px">
596
+ <div style="font-size:13px;font-weight:700;margin-bottom:8px">⚠️ ${t('操作安全须知')}</div>
597
+ <div style="font-size:12px;color:#374151;line-height:1.8">
598
+ • ${t('普通 admin 与 root / 破玻璃 不同:经济 / 协议级操作需 protocol 权限;按治理铁律须记入审计日志 —— 部分手动结算 / 评估入口的审计仍在补齐中。')}<br>
599
+ • ${t('危险操作(封禁 / 角色 / 资金 / 协议参数)须带原因,且不可绕过争议 / 仲裁规则。')}<br>
600
+ • ${t('不要在公共设备暴露 API Key;管理操作均可追溯到你的账户。')}
601
+ </div>
602
+ <div style="display:flex;gap:8px;margin-top:10px">
603
+ <a href="#admin/audit" style="text-decoration:none"><button class="btn btn-outline btn-sm" style="font-size:12px">📜 ${t('查看审计日志')}</button></a>
604
+ ${isRoot ? `<a href="#admin/manage-admins" style="text-decoration:none"><button class="btn btn-outline btn-sm" style="font-size:12px">👥 ${t('管理管理员')}</button></a>` : ''}
605
+ </div>
606
+ </div>
607
+ `, 'admin')
608
+ }