@seasonkoh/webaz 0.1.7 → 0.1.8

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,3062 @@
1
+ // WebAZ — Vanilla JS SPA
2
+ // 路由:hash-based (#shop, #orders, #seller, #order/ord_xxx)
3
+
4
+ // ─── 状态 ────────────────────────────────────────────────────
5
+
6
+ const state = {
7
+ user: null,
8
+ apiKey: localStorage.getItem('webaz_key') || null,
9
+ unread: 0,
10
+ sse: null,
11
+ }
12
+
13
+ function toggleLang() {
14
+ setLang(window._lang === 'zh' ? 'en' : 'zh')
15
+ document.getElementById('html-root')?.setAttribute('lang', window._lang === 'en' ? 'en' : 'zh-CN')
16
+ route()
17
+ }
18
+
19
+ // ─── API ─────────────────────────────────────────────────────
20
+
21
+ async function api(method, path, body) {
22
+ const opts = {
23
+ method,
24
+ headers: { 'Content-Type': 'application/json', ...(state.apiKey ? { Authorization: `Bearer ${state.apiKey}` } : {}) },
25
+ }
26
+ if (body) opts.body = JSON.stringify(body)
27
+ const res = await fetch('/api' + path, opts)
28
+ return res.json()
29
+ }
30
+
31
+ const GET = (path) => api('GET', path)
32
+ const POST = (path, body) => api('POST', path, body)
33
+
34
+ // ─── 路由 ─────────────────────────────────────────────────────
35
+
36
+ function route() {
37
+ const hash = location.hash.slice(1) || '/'
38
+ const [page, ...params] = hash.split('/')
39
+ render(page || 'shop', params)
40
+ }
41
+
42
+ window.addEventListener('popstate', route)
43
+
44
+ function navigate(hash) { location.hash = hash }
45
+
46
+ // ─── 渲染入口 ─────────────────────────────────────────────────
47
+
48
+ async function render(page, params) {
49
+ // 未登录时只允许看登录页和商品
50
+ if (!state.apiKey && page !== 'login' && page !== 'shop' && page !== '') {
51
+ return renderLogin()
52
+ }
53
+
54
+ // 加载当前用户
55
+ if (state.apiKey && !state.user) {
56
+ state.user = await GET('/me')
57
+ if (state.user?.error) { state.apiKey = null; localStorage.removeItem('webaz_key'); state.user = null }
58
+ else connectSSE()
59
+ }
60
+
61
+ // 物流/仲裁员进入商店页时自动跳转到角色首页
62
+ const noShopRoles = ['logistics', 'arbitrator']
63
+ if (noShopRoles.includes(state.user?.role) && (page === '' || page === 'shop')) {
64
+ return navigate(roleHome(state.user.role))
65
+ }
66
+
67
+ const app = document.getElementById('app')
68
+
69
+ switch (page) {
70
+ case '':
71
+ case 'shop': return renderShop(app)
72
+ case 'orders': return renderOrders(app)
73
+ case 'order': return renderOrderDetail(app, params[0])
74
+ case 'seller':
75
+ if (state.user?.role === 'logistics') return renderLogistics(app)
76
+ if (state.user?.role === 'arbitrator') return renderDisputeList(app)
77
+ return renderSeller(app)
78
+ case 'edit-product': return renderEditProduct(app, params[0])
79
+ case 'wallet': return renderWallet(app)
80
+ case 'agent-buy': return renderAgentBuy(app)
81
+ case 'verify-tasks': return renderVerifyTasks(app)
82
+ case 'verify-admin': return renderVerifyAdmin(app)
83
+ case 'claim-url': return renderClaimUrl(app, params[0])
84
+ case 'notifications': return renderNotifications(app)
85
+ case 'skills': return renderSkills(app)
86
+ case 'disputes': return renderDisputeList(app)
87
+ case 'dispute': return renderDisputeDetail(app, params[0])
88
+ case 'profile': return renderProfile(app)
89
+ case 'login': return renderLogin()
90
+ default: return renderShop(app)
91
+ }
92
+ }
93
+
94
+ // ─── 工具 ─────────────────────────────────────────────────────
95
+
96
+ function statusBadge(status) {
97
+ const map = {
98
+ created: ['gray', t('待付款')],
99
+ paid: ['blue', t('待接单')],
100
+ accepted: ['yellow', t('待发货')],
101
+ shipped: ['yellow', t('已发货')],
102
+ picked_up: ['yellow', t('已揽收')],
103
+ in_transit: ['yellow', t('运输中')],
104
+ delivered: ['blue', t('待确认')],
105
+ confirmed: ['green', t('已确认')],
106
+ completed: ['green', t('已完成')],
107
+ disputed: ['red', t('争议中')],
108
+ cancelled: ['gray', t('已取消')],
109
+ fault_seller: ['red', t('卖家违约')],
110
+ fault_buyer: ['red', t('买家违约')],
111
+ fault_logistics: ['red', t('物流违约')],
112
+ }
113
+ const [color, label] = map[status] || ['gray', status]
114
+ return `<span class="badge badge-${color}">${label}</span>`
115
+ }
116
+
117
+ function escHtml(s) {
118
+ return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')
119
+ }
120
+
121
+ function fmtTime(iso) {
122
+ if (!iso) return ''
123
+ const locale = window._lang === 'en' ? 'en-US' : 'zh-CN'
124
+ return new Date(iso).toLocaleString(locale, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
125
+ }
126
+
127
+ function alert$(type, msg) {
128
+ return `<div class="alert alert-${type}">${msg}</div>`
129
+ }
130
+
131
+ // 外部链接验证任务结果卡片(统一展示)
132
+ function linkTaskCard(res) {
133
+ if (!res || (!res.task_id && !res.code)) return ''
134
+ const bg = res.conflict ? '#fffbeb' : '#f0f9ff'
135
+ const border = res.conflict ? '#f59e0b' : '#60a5fa'
136
+ const icon = res.conflict ? '⚠️' : '🔑'
137
+ const title = res.conflict ? t('链接冲突 — 需要认领验证') : t('链接验证任务已创建')
138
+ const alreadyNote = res.already_pending ? `<div style="font-size:11px;color:#f59e0b;margin-bottom:6px">${t('此链接已有进行中的任务')}</div>` : ''
139
+ return `
140
+ <div style="background:${bg};border:1px solid ${border};border-radius:8px;padding:12px;margin-top:8px;font-size:13px">
141
+ ${alreadyNote}
142
+ <div style="font-weight:600;margin-bottom:6px">${icon} ${title}</div>
143
+ <div style="margin-bottom:8px;color:#374151;line-height:1.5">${res.instructions || ''}</div>
144
+ <div style="background:#fff;border:2px dashed ${border};border-radius:6px;padding:8px;font-family:monospace;font-size:16px;font-weight:800;letter-spacing:3px;text-align:center;margin-bottom:8px;color:#1e40af">${res.code || ''}</div>
145
+ <div style="font-size:11px;color:#6b7280">${t('截止')} ${res.expires_at ? new Date(res.expires_at).toLocaleString() : ''} · <a href="#verify-tasks" style="color:#4f46e5">${t('查看任务进度')}</a></div>
146
+ </div>`
147
+ }
148
+
149
+ function loading$() {
150
+ return `<div class="loading"><span class="spinner"></span>${t('加载中...')}</div>`
151
+ }
152
+
153
+ // 角色首页
154
+ function roleHome(role) {
155
+ if (role === 'logistics' || role === 'arbitrator' || role === 'seller') return '#seller'
156
+ return '#shop'
157
+ }
158
+
159
+ function shell(content, activeTab) {
160
+ const role = state.user?.role
161
+ let tabs
162
+
163
+ if (role === 'logistics') {
164
+ tabs = [
165
+ { id: 'seller', icon: '🚚', label: t('配送任务') },
166
+ { id: 'orders', icon: '📋', label: t('历史记录') },
167
+ { id: 'notifications', icon: '🔔', label: t('通知'), badge: true },
168
+ { id: 'wallet', icon: '💰', label: t('钱包') },
169
+ ]
170
+ } else if (role === 'arbitrator') {
171
+ tabs = [
172
+ { id: 'seller', icon: '⚖️', label: t('仲裁台') },
173
+ { id: 'orders', icon: '📋', label: t('记录') },
174
+ { id: 'notifications', icon: '🔔', label: t('通知'), badge: true },
175
+ { id: 'wallet', icon: '💰', label: t('钱包') },
176
+ ]
177
+ } else {
178
+ tabs = [
179
+ { id: 'shop', icon: '🛍️', label: t('商店') },
180
+ { id: 'orders', icon: '📦', label: t('订单') },
181
+ { id: 'seller', icon: '🏪', label: role === 'seller' ? t('卖家') : t('后台') },
182
+ { id: 'notifications', icon: '🔔', label: t('通知'), badge: true },
183
+ { id: 'wallet', icon: '💰', label: t('钱包') },
184
+ ]
185
+ }
186
+ return `
187
+ <nav class="navbar">
188
+ <a class="navbar-brand" href="#shop">🦞 WebAZ</a>
189
+ <div class="navbar-actions">
190
+ <button onclick="toggleLang()" style="background:none;border:1px solid #e5e7eb;cursor:pointer;padding:3px 8px;border-radius:6px;font-size:12px;color:#6b7280;margin-right:4px">${window._lang === 'en' ? '中文' : 'EN'}</button>
191
+ ${state.user
192
+ ? `<button onclick="navigate('#profile')" style="background:none;border:none;cursor:pointer;display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:8px;color:#374151" title="${t('个人资料 & 设置')}">
193
+ <span style="font-size:13px;color:#6b7280">${state.user.name}</span>
194
+ <span style="font-size:18px">👤</span>
195
+ </button>`
196
+ : `<button class="btn btn-primary btn-sm" onclick="navigate('#login')">${t('登录')}</button>`}
197
+ </div>
198
+ </nav>
199
+ <main class="main">${content}</main>
200
+ <nav class="tabbar">
201
+ ${tabs.map(tb => `
202
+ <button class="tab-item ${activeTab === tb.id ? 'active' : ''}" onclick="navigate('#${tb.id}')">
203
+ <span class="tab-icon" style="position:relative">
204
+ ${tb.icon}
205
+ ${tb.badge ? `<span id="notif-badge" style="position:absolute;top:-4px;right:-6px;background:#dc2626;color:#fff;border-radius:99px;font-size:10px;padding:0 4px;min-width:16px;text-align:center;display:${state.unread > 0 ? 'inline' : 'none'}">${state.unread || ''}</span>` : ''}
206
+ </span>${tb.label}
207
+ </button>`).join('')}
208
+ </nav>`
209
+ }
210
+
211
+ // ─── 个人资料 & 设置 ──────────────────────────────────────────
212
+
213
+ async function renderProfile(app) {
214
+ app.innerHTML = shell(loading$(), 'profile')
215
+ const data = await GET('/profile')
216
+ if (data.error) return void (app.innerHTML = shell(alert$('error', data.error), 'profile'))
217
+
218
+ const roles = data.roles || [data.role]
219
+ const allRoles = ['buyer', 'seller', 'logistics', 'arbitrator']
220
+ const roleLabels = { buyer: t('买家'), seller: t('卖家'), logistics: t('物流'), arbitrator: t('仲裁员') }
221
+ const roleIcons = { buyer: '🛍️', seller: '🏪', logistics: '🚚', arbitrator: '⚖️' }
222
+ const addable = allRoles.filter(r => !roles.includes(r))
223
+
224
+ app.innerHTML = shell(`
225
+ <div class="page-header"><h2>${t('👤 个人资料 & 设置')}</h2></div>
226
+ <div id="profile-msg"></div>
227
+
228
+ <!-- 基本信息 -->
229
+ <div class="card" style="margin-bottom:16px">
230
+ <div class="card-body">
231
+ <div style="font-size:13px;color:#6b7280;margin-bottom:4px">${t('昵称')}</div>
232
+ <div style="font-size:18px;font-weight:600;margin-bottom:16px">${data.name}</div>
233
+
234
+ <div style="font-size:13px;color:#6b7280;margin-bottom:6px">API Key <span style="color:#9ca3af">${t('(你的唯一身份凭证,请妥善保管)')}</span></div>
235
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
236
+ <code id="apikey-display" style="background:#f3f4f6;padding:6px 10px;border-radius:6px;font-size:13px;flex:1;word-break:break-all;filter:blur(4px);user-select:none">${data.api_key}</code>
237
+ <button class="btn btn-outline btn-sm" onclick="toggleApiKey()" id="btn-reveal" style="white-space:nowrap">${t('显示')}</button>
238
+ <button class="btn btn-outline btn-sm" onclick="copyApiKey('${data.api_key}')" style="white-space:nowrap">${t('复制')}</button>
239
+ </div>
240
+
241
+ <div style="font-size:13px;color:#6b7280;margin-bottom:6px">${t('钱包余额')}</div>
242
+ <div style="font-size:16px;font-weight:600;color:#059669">${data.wallet?.balance?.toFixed(2) ?? '—'} WAZ</div>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- 角色管理 -->
247
+ <div class="card" style="margin-bottom:16px">
248
+ <div class="card-body">
249
+ <div style="font-size:15px;font-weight:600;margin-bottom:12px">${t('角色管理')}</div>
250
+
251
+ <div style="font-size:13px;color:#6b7280;margin-bottom:8px">${t('已有角色')}</div>
252
+ <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px">
253
+ ${roles.map(r => `
254
+ <button onclick="switchRole('${r}', this)" style="
255
+ display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;cursor:pointer;border:2px solid;
256
+ ${r === data.role ? 'background:#eff6ff;border-color:#3b82f6;color:#1d4ed8;font-weight:600' : 'background:#f9fafb;border-color:#e5e7eb;color:#374151'}
257
+ " title="${r === data.role ? t('当前激活') : t('点击切换')}">
258
+ ${roleIcons[r]} ${roleLabels[r]}
259
+ ${r === data.role ? `<span style="font-size:11px;color:#3b82f6">${t('● 激活')}</span>` : ''}
260
+ </button>
261
+ `).join('')}
262
+ </div>
263
+
264
+ ${addable.length > 0 ? `
265
+ <div style="font-size:13px;color:#6b7280;margin-bottom:8px">${t('添加新角色')}</div>
266
+ <div style="display:flex;flex-wrap:wrap;gap:8px">
267
+ ${addable.map(r => `
268
+ <button onclick="addRole('${r}', this)" style="
269
+ display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;cursor:pointer;
270
+ background:#f9fafb;border:2px dashed #d1d5db;color:#6b7280
271
+ ">${roleIcons[r]} + ${roleLabels[r]}</button>
272
+ `).join('')}
273
+ </div>
274
+ ` : `<div style="font-size:13px;color:#6b7280">${t('已拥有全部角色')}</div>`}
275
+ </div>
276
+ </div>
277
+
278
+ <!-- 找回密钥 -->
279
+ <div class="card" style="margin-bottom:16px">
280
+ <div class="card-body">
281
+ <div style="font-size:15px;font-weight:600;margin-bottom:12px">${t('找回密钥')}</div>
282
+ <p style="font-size:13px;color:#6b7280;margin-bottom:12px">${t('如果你遗失了 API Key,可以通过注册名称找回。')}</p>
283
+ <div style="display:flex;gap:8px">
284
+ <input class="form-control" id="recover-name" placeholder="${t('输入注册时的名称')}" style="flex:1">
285
+ <button class="btn btn-outline btn-sm" onclick="recoverKey()">${t('找回')}</button>
286
+ </div>
287
+ <div id="recover-result" style="margin-top:10px"></div>
288
+ </div>
289
+ </div>
290
+
291
+ <!-- 退出 -->
292
+ <div class="card">
293
+ <div class="card-body">
294
+ <button class="btn btn-outline btn-sm" onclick="logout()" style="color:#dc2626;border-color:#dc2626">${t('退出登录')}</button>
295
+ </div>
296
+ </div>
297
+ `, 'profile')
298
+ }
299
+
300
+ let apiKeyVisible = false
301
+ function toggleApiKey() {
302
+ apiKeyVisible = !apiKeyVisible
303
+ const el = document.getElementById('apikey-display')
304
+ const btn = document.getElementById('btn-reveal')
305
+ if (el) el.style.filter = apiKeyVisible ? 'none' : 'blur(4px)'
306
+ if (btn) btn.textContent = apiKeyVisible ? t('隐藏') : t('显示')
307
+ }
308
+
309
+ function copyApiKey(key) {
310
+ navigator.clipboard.writeText(key).then(() => {
311
+ const msgEl = document.getElementById('profile-msg')
312
+ if (msgEl) { msgEl.innerHTML = alert$('success', t('API Key 已复制到剪贴板')); setTimeout(() => msgEl.innerHTML = '', 2000) }
313
+ })
314
+ }
315
+
316
+ async function switchRole(role, btn) {
317
+ if (btn) { btn.disabled = true; btn.style.opacity = '0.6' }
318
+ const res = await POST('/profile/switch-role', { role })
319
+ if (res.error) {
320
+ if (btn) { btn.disabled = false; btn.style.opacity = '' }
321
+ return void (document.getElementById('profile-msg').innerHTML = alert$('error', res.error))
322
+ }
323
+ state.user = null
324
+ renderProfile(document.getElementById('app'))
325
+ }
326
+
327
+ async function addRole(role, btn) {
328
+ if (btn) { btn.disabled = true; btn.textContent = '...'; btn.style.opacity = '0.6' }
329
+ const res = await POST('/profile/add-role', { role })
330
+ if (res.error) {
331
+ if (btn) { btn.disabled = false; btn.style.opacity = '' }
332
+ return void (document.getElementById('profile-msg').innerHTML = alert$('error', res.error))
333
+ }
334
+ state.user = null
335
+ renderProfile(document.getElementById('app'))
336
+ }
337
+
338
+ async function recoverKey() {
339
+ const name = document.getElementById('recover-name')?.value?.trim()
340
+ if (!name) return
341
+ const res = await api('POST', '/recover-key', { name })
342
+ const el = document.getElementById('recover-result')
343
+ if (!el) return
344
+ if (res.error) return void (el.innerHTML = alert$('error', res.error))
345
+ el.innerHTML = res.accounts.map(a => `
346
+ <div style="background:#f3f4f6;border-radius:8px;padding:10px 12px;margin-bottom:8px">
347
+ <div style="font-size:13px;font-weight:600">${a.name} · ${a.role}</div>
348
+ <div style="display:flex;align-items:center;gap:8px;margin-top:6px">
349
+ <code style="font-size:12px;color:#6b7280;filter:blur(3px);cursor:pointer" onclick="this.style.filter='none'">${a.api_key}</code>
350
+ <button class="btn btn-outline btn-sm" onclick="useKey('${a.api_key}')">${t('使用此账号')}</button>
351
+ </div>
352
+ </div>
353
+ `).join('')
354
+ }
355
+
356
+ function useKey(key) {
357
+ localStorage.setItem('webaz_key', key)
358
+ state.apiKey = key
359
+ state.user = null
360
+ navigate('#shop')
361
+ }
362
+
363
+ function logout() {
364
+ localStorage.removeItem('webaz_key')
365
+ state.apiKey = null
366
+ state.user = null
367
+ if (state.sse) { state.sse.close(); state.sse = null }
368
+ navigate('#login')
369
+ }
370
+
371
+ // ─── 登录/注册页 ──────────────────────────────────────────────
372
+
373
+ function renderLogin() {
374
+ document.getElementById('app').innerHTML = `
375
+ <div class="login-wrap">
376
+ <div class="login-logo">🦞</div>
377
+ <h1 class="login-title">WebAZ</h1>
378
+ <p class="login-sub">${t('去中心化商业协议 · AI Agent 原生')}</p>
379
+ <div style="text-align:center;margin-bottom:16px">
380
+ <button onclick="toggleLang()" style="background:none;border:1px solid #e5e7eb;cursor:pointer;padding:4px 12px;border-radius:6px;font-size:13px;color:#6b7280">${window._lang === 'en' ? '中文' : 'EN'}</button>
381
+ </div>
382
+ <div id="login-msg"></div>
383
+
384
+ <div class="seg-ctrl" style="margin-bottom:24px">
385
+ <button class="seg-btn active" id="tab-login" onclick="switchLoginTab('login')">${t('我已有账号')}</button>
386
+ <button class="seg-btn" id="tab-reg" onclick="switchLoginTab('reg')">${t('注册新账号')}</button>
387
+ </div>
388
+
389
+ <div id="panel-login">
390
+ <div class="form-group">
391
+ <label class="form-label">${t('粘贴你的 api_key')}</label>
392
+ <input class="form-control" id="inp-key" placeholder="key_xxxx..." style="font-family:monospace;font-size:13px">
393
+ </div>
394
+ <button class="btn btn-primary" onclick="doLogin()">${t('登录')}</button>
395
+ </div>
396
+
397
+ <div id="panel-reg" style="display:none">
398
+ <div class="form-group">
399
+ <label class="form-label">${t('名称 / 店铺名')}</label>
400
+ <input class="form-control" id="inp-name" placeholder="${t('例:陈小明 / 竹韵手工坊')}">
401
+ </div>
402
+ <div class="form-group">
403
+ <label class="form-label">${t('角色')}</label>
404
+ <select class="form-control" id="inp-role">
405
+ <option value="buyer">${t('买家 — 浏览购物')}</option>
406
+ <option value="seller">${t('卖家 — 上架商品')}</option>
407
+ <option value="logistics">${t('物流 — 揽收投递')}</option>
408
+ <option value="arbitrator">${t('仲裁员 — 处理争议')}</option>
409
+ </select>
410
+ </div>
411
+ <button class="btn btn-primary" onclick="doRegister()">${t('注册')}</button>
412
+ </div>
413
+ </div>`
414
+ }
415
+
416
+ window.switchLoginTab = (tab) => {
417
+ document.getElementById('panel-login').style.display = tab === 'login' ? '' : 'none'
418
+ document.getElementById('panel-reg').style.display = tab === 'reg' ? '' : 'none'
419
+ document.getElementById('tab-login').className = 'seg-btn' + (tab === 'login' ? ' active' : '')
420
+ document.getElementById('tab-reg').className = 'seg-btn' + (tab === 'reg' ? ' active' : '')
421
+ }
422
+
423
+ window.doLogin = async () => {
424
+ const key = document.getElementById('inp-key').value.trim()
425
+ if (!key) return showMsg('error', t('请粘贴 api_key'))
426
+ state.apiKey = key
427
+ const user = await GET('/me')
428
+ if (user.error) { state.apiKey = null; return showMsg('error', t('无效的 api_key,请重新输入')) }
429
+ state.user = user
430
+ localStorage.setItem('webaz_key', key)
431
+ connectSSE()
432
+ navigate(roleHome(user.role))
433
+ }
434
+
435
+ window.doRegister = async () => {
436
+ const name = document.getElementById('inp-name').value.trim()
437
+ const role = document.getElementById('inp-role').value
438
+ if (!name) return showMsg('error', t('请填写名称'))
439
+ const res = await POST('/register', { name, role })
440
+ if (res.error) return showMsg('error', res.error)
441
+ showMsg('success', `${t('注册成功!')}<br>${t('你的 api_key(请妥善保存,这是你的登录凭证):')}<br><code style="font-size:12px;word-break:break-all">${res.api_key}</code>`)
442
+ state.apiKey = res.api_key
443
+ // Normalize register response to match /me shape (user_id → id)
444
+ state.user = { ...res, id: res.user_id }
445
+ localStorage.setItem('webaz_key', res.api_key)
446
+ connectSSE()
447
+ setTimeout(() => navigate(roleHome(res.role)), 3000)
448
+ }
449
+
450
+ function showMsg(type, html) {
451
+ const el = document.getElementById('login-msg')
452
+ if (el) el.innerHTML = alert$(type, html)
453
+ }
454
+
455
+ // ─── 商店页 ───────────────────────────────────────────────────
456
+
457
+ async function renderShop(app) {
458
+ app.innerHTML = shell(loading$(), 'shop')
459
+ const products = await GET('/products')
460
+
461
+ const grid = products.length === 0
462
+ ? `<div class="empty"><div class="empty-icon">🛍️</div><div class="empty-text">${t('暂无商品')}</div></div>`
463
+ : `<div class="product-grid">
464
+ ${products.map(p => `
465
+ <div class="product-card" onclick="navigate('#order-product/${p.id}')">
466
+ <div class="product-img">${getCategoryIcon(p.category)}</div>
467
+ <div class="product-body">
468
+ <div class="product-name">${p.title}</div>
469
+ <div class="product-price">${p.price} <span style="font-size:11px;font-weight:400">WAZ</span></div>
470
+ <div class="product-seller">${repBadge(p.rep_level)}@${p.seller_name}</div>
471
+ </div>
472
+ </div>`).join('')}
473
+ </div>`
474
+
475
+ const agentBuyBanner = state.user?.role === 'buyer' ? `
476
+ <div onclick="navigate('#agent-buy')" style="background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:12px;padding:14px 16px;margin-bottom:16px;cursor:pointer;display:flex;align-items:center;gap:12px">
477
+ <span style="font-size:28px">🤖</span>
478
+ <div>
479
+ <div style="color:#fff;font-weight:600;font-size:14px">${t('智能下单')}</div>
480
+ <div style="color:rgba(255,255,255,0.8);font-size:12px">${t('粘贴任意平台链接,AI 帮你找更优方案')}</div>
481
+ </div>
482
+ <span style="margin-left:auto;color:rgba(255,255,255,0.7);font-size:18px">›</span>
483
+ </div>` : ''
484
+
485
+ app.innerHTML = shell(`
486
+ <h1 class="page-title">${t('发现好物')}</h1>
487
+ ${agentBuyBanner}
488
+ <div class="search-bar">
489
+ <input class="search-input" id="search-inp" placeholder="${t('搜索商品...')}" onkeydown="if(event.key==='Enter')doSearch()">
490
+ <button class="btn btn-primary btn-sm" style="width:auto;padding:10px 16px" onclick="doSearch()">${t('搜')}</button>
491
+ </div>
492
+ <div style="margin-bottom:16px;display:flex;gap:8px;flex-wrap:wrap">
493
+ <button class="btn btn-outline btn-sm" style="width:auto" onclick="navigate('#skills')">${t('⚡ Skill 市场')}</button>
494
+ <button class="btn btn-outline btn-sm" style="width:auto" onclick="navigate('#verify-tasks')">${t('🛡️ 验证任务')}</button>
495
+ </div>
496
+ <div id="product-list">${grid}</div>
497
+ `, 'shop')
498
+ }
499
+
500
+ window.doSearch = async () => {
501
+ const q = document.getElementById('search-inp').value.trim()
502
+ document.getElementById('product-list').innerHTML = loading$()
503
+ const products = await GET(`/products?q=${encodeURIComponent(q)}`)
504
+ const grid = products.length === 0
505
+ ? `<div class="empty"><div class="empty-icon">🔍</div><div class="empty-text">${t('没有找到"')}${q}"</div></div>`
506
+ : `<div class="product-grid">
507
+ ${products.map(p => `
508
+ <div class="product-card" onclick="navigate('#order-product/${p.id}')">
509
+ <div class="product-img">${getCategoryIcon(p.category)}</div>
510
+ <div class="product-body">
511
+ <div class="product-name">${p.title}</div>
512
+ <div class="product-price">${p.price} WAZ</div>
513
+ <div class="product-seller">${repBadge(p.rep_level)}@${p.seller_name}</div>
514
+ </div>
515
+ </div>`).join('')}
516
+ </div>`
517
+ document.getElementById('product-list').innerHTML = grid
518
+ }
519
+
520
+ function getCategoryIcon(cat) {
521
+ const map = { '茶具':'🍵', '家居':'🏠', '食品':'🍱', '服装':'👗', '电子':'📱', '手工':'🎨' }
522
+ return map[cat] || '📦'
523
+ }
524
+
525
+ function repBadge(level) {
526
+ const map = { new:'', trusted:'⭐', quality:'🌟', star:'💫', legend:'🔥' }
527
+ return map[level] || ''
528
+ }
529
+
530
+ // 买家下单页
531
+ window.render = render // 暴露给 hash 路由用
532
+ window.addEventListener('hashchange', () => {
533
+ const hash = location.hash.slice(1)
534
+ if (hash.startsWith('order-product/')) {
535
+ renderBuyPage(document.getElementById('app'), hash.split('/')[1])
536
+ } else {
537
+ route()
538
+ }
539
+ })
540
+
541
+ async function renderBuyPage(app, productId) {
542
+ app.innerHTML = shell(loading$(), 'shop')
543
+ const products = await GET('/products')
544
+ const p = products.find(x => x.id === productId)
545
+ if (!p) return app.innerHTML = shell(`<div class="empty">${t('商品不存在')}</div>`, 'shop')
546
+
547
+ app.innerHTML = shell(`
548
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:16px" onclick="history.back()">${t('← 返回')}</button>
549
+ <div class="card">
550
+ <div style="font-size:60px;text-align:center;padding:20px 0">${getCategoryIcon(p.category)}</div>
551
+ <h2 style="font-size:18px;font-weight:700;margin-bottom:6px">${p.title}</h2>
552
+ <p style="font-size:14px;color:#6b7280;margin-bottom:12px">${p.description}</p>
553
+ <div class="detail-row"><span class="detail-label">${t('价格')}</span><span class="detail-value" style="color:#4f46e5;font-size:18px">${p.price} WAZ</span></div>
554
+ <div class="detail-row"><span class="detail-label">${t('库存')}</span><span class="detail-value">${p.stock} ${t('件')}</span></div>
555
+ <div class="detail-row"><span class="detail-label">${t('卖家')}</span><span class="detail-value">${p.seller_name}</span></div>
556
+ </div>
557
+ <div id="buy-msg"></div>
558
+ ${state.user?.role === 'buyer' ? `
559
+ <div class="card">
560
+ <div class="form-group">
561
+ <label class="form-label">${t('收货地址')}</label>
562
+ <input class="form-control" id="inp-addr" placeholder="${t('省市区 详细地址')}">
563
+ </div>
564
+ <div class="form-group">
565
+ <label class="form-label">${t('备注(可选)')}</label>
566
+ <input class="form-control" id="inp-notes" placeholder="${t('给卖家的留言')}">
567
+ </div>
568
+ <button class="btn btn-primary" onclick="doBuy('${p.id}', ${p.price})">${t('立即下单')} · ${p.price} WAZ</button>
569
+ </div>` : `
570
+ <div class="alert alert-info">${state.user ? t('只有买家账号可以下单') : `<a href="#login" style="color:inherit;font-weight:700">${t('登录')}</a>${t('后下单')}`}</div>`}
571
+ `, 'shop')
572
+ }
573
+
574
+ window.doBuy = async (productId, price) => {
575
+ const addr = document.getElementById('inp-addr').value.trim()
576
+ const notes = document.getElementById('inp-notes').value.trim()
577
+ if (!addr) { document.getElementById('buy-msg').innerHTML = alert$('error', t('请填写收货地址')); return }
578
+ const res = await POST('/orders', { product_id: productId, shipping_address: addr, notes })
579
+ if (res.error) { document.getElementById('buy-msg').innerHTML = alert$('error', res.error); return }
580
+ document.getElementById('buy-msg').innerHTML = alert$('success', `${t('下单成功!')}${price} WAZ ${t('已托管,等待卖家接单')}`)
581
+ setTimeout(() => navigate(`#order/${res.order_id}`), 1500)
582
+ }
583
+
584
+ // ─── 订单列表页 ───────────────────────────────────────────────
585
+
586
+ async function renderOrders(app) {
587
+ if (!state.user) { renderLogin(); return }
588
+ app.innerHTML = shell(loading$(), 'orders')
589
+ const orders = await GET('/orders')
590
+
591
+ const list = orders.length === 0
592
+ ? `<div class="empty"><div class="empty-icon">📭</div><div class="empty-text">${t('暂无订单')}</div></div>`
593
+ : orders.map(o => `
594
+ <div class="card" onclick="navigate('#order/${o.id}')" style="cursor:pointer">
595
+ <div class="order-item">
596
+ <div class="order-icon">${getCategoryIcon(o.category)}</div>
597
+ <div class="order-info">
598
+ <div class="order-title">${o.product_title}</div>
599
+ <div class="order-meta">${fmtTime(o.created_at)} · ${o.buyer_id === state.user.id ? t('我买的') : t('我卖的')}</div>
600
+ <div style="margin-top:6px">${statusBadge(o.status)}</div>
601
+ </div>
602
+ <div class="order-amount">${o.total_amount}</div>
603
+ </div>
604
+ </div>`).join('')
605
+
606
+ app.innerHTML = shell(`<h1 class="page-title">${t('我的订单')}</h1>${list}`, 'orders')
607
+ }
608
+
609
+ // ─── 订单详情页 ───────────────────────────────────────────────
610
+
611
+ async function renderOrderDetail(app, orderId) {
612
+ if (!orderId) { navigate('#orders'); return }
613
+ app.innerHTML = shell(loading$(), 'orders')
614
+ const data = await GET(`/orders/${orderId}`)
615
+ if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'orders'); return }
616
+
617
+ const { order, history, currentResponsible, activeDeadline, isOverdue, product } = data
618
+ // 如果有争议,拉取含证据的完整争议数据
619
+ let dispute = data.dispute
620
+ if (dispute?.id) {
621
+ const fullDispute = await GET(`/disputes/${dispute.id}`)
622
+ if (!fullDispute.error) dispute = fullDispute
623
+ }
624
+ const isBuyer = order.buyer_id === state.user?.id
625
+ const isSeller = order.seller_id === state.user?.id
626
+ // 物流方:已分配的 or 尚未分配(可自行揽收)
627
+ const isLogistic = state.user?.role === 'logistics' &&
628
+ (order.logistics_id === state.user?.id || (!order.logistics_id && order.status === 'shipped'))
629
+
630
+ // 卖家在 accepted 状态需要物流公司列表
631
+ let logisticsCompanies = []
632
+ if (isSeller && order.status === 'accepted') {
633
+ const lc = await GET('/logistics/companies')
634
+ logisticsCompanies = Array.isArray(lc) ? lc : []
635
+ }
636
+
637
+ // 操作按钮
638
+ const actions = getActions(order, isBuyer, isSeller, isLogistic)
639
+
640
+ const STATUS_ZH = {
641
+ created:'待付款', paid:'待接单', accepted:'待发货', shipped:'已发货',
642
+ picked_up:'已揽收', in_transit:'运输中', delivered:'待确认',
643
+ confirmed:'已确认', completed:'已完成', disputed:'争议中',
644
+ cancelled:'已取消', fault_seller:'卖家违约', fault_buyer:'买家违约', fault_logistics:'物流违约',
645
+ }
646
+
647
+ const historyHtml = (history || []).map(h => `
648
+ <div class="timeline-item">
649
+ <div><span class="timeline-status">${STATUS_ZH[h.from_status] || h.from_status} → ${STATUS_ZH[h.to_status] || h.to_status}</span></div>
650
+ <div class="timeline-actor">${h.actor_name}(${h.actor_role_name || h.actor_role})</div>
651
+ <div class="timeline-time">${fmtTime(h.created_at)}</div>
652
+ ${h.notes ? `<div class="timeline-evidence" style="color:#6b7280">💬 ${h.notes}</div>` : ''}
653
+ ${(h.evidence_items || []).map(e => `<div class="timeline-evidence">📎 ${e.description}</div>`).join('')}
654
+ </div>`).join('')
655
+
656
+ // 物流跟踪卡
657
+ const trackingStepIcons = { shipped:'📦', picked_up:'✅', in_transit:'🚛', delivered:'📬' }
658
+ const trackingHtml = (data.trackingInfo || []).length > 0 ? `
659
+ <div class="card">
660
+ <div class="action-title">🚚 ${t('物流跟踪')}</div>
661
+ ${(data.trackingInfo || []).map(t => `
662
+ <div style="display:flex;gap:10px;align-items:flex-start;padding:8px 0;border-bottom:1px solid #f3f4f6">
663
+ <div style="font-size:20px;line-height:1;flex-shrink:0">${trackingStepIcons[t.status] || '•'}</div>
664
+ <div style="flex:1;min-width:0">
665
+ <div style="font-size:13px;font-weight:600">${STATUS_ZH[t.status] || t.status}
666
+ <span style="font-weight:400;color:#6b7280;margin-left:6px">${t.actor}</span>
667
+ </div>
668
+ <div style="font-size:12px;color:#9ca3af">${fmtTime(t.time)}</div>
669
+ ${t.evidence.map(e => {
670
+ const label = (t.status === 'picked_up' && !e.startsWith('快递单号:')) ? `快递单号:${e}` : e
671
+ return `<div style="font-size:13px;color:#374151;margin-top:3px;background:#f9fafb;border-radius:6px;padding:4px 8px">📎 ${label}</div>`
672
+ }).join('')}
673
+ </div>
674
+ </div>`).join('')}
675
+ </div>` : ''
676
+
677
+ const disputeHtml = dispute ? buildDisputeHtml(dispute, state.user) : ''
678
+
679
+ app.innerHTML = shell(`
680
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:16px" onclick="history.back()">${t('← 返回')}</button>
681
+
682
+ <div class="card">
683
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
684
+ <div style="font-size:13px;color:#6b7280;font-family:monospace">${order.id}</div>
685
+ ${statusBadge(order.status)}
686
+ </div>
687
+ <div class="detail-row"><span class="detail-label">${t('商品')}</span><span class="detail-value">${product?.title || ''}</span></div>
688
+ <div class="detail-row"><span class="detail-label">${t('金额')}</span><span class="detail-value" style="color:#4f46e5">${order.total_amount} WAZ</span></div>
689
+ <div class="detail-row"><span class="detail-label">${t('下单时间')}</span><span class="detail-value">${fmtTime(order.created_at)}</span></div>
690
+ ${order.shipping_address ? `<div class="detail-row"><span class="detail-label">${t('收货地址')}</span><span class="detail-value">${order.shipping_address}</span></div>` : ''}
691
+ ${isOverdue ? `<div class="alert alert-error" style="margin-top:12px">⚠️ ${t('已超时!协议将自动判责')}</div>` : ''}
692
+ ${activeDeadline && !isOverdue ? `<div class="alert alert-warning" style="margin-top:12px">${t('截止时间:')}${fmtTime(activeDeadline.deadline)}</div>` : ''}
693
+ </div>
694
+
695
+ ${trackingHtml}
696
+ ${disputeHtml}
697
+
698
+ <div id="action-area">
699
+ ${actions ? renderActions(orderId, actions, order, logisticsCompanies) : ''}
700
+ </div>
701
+
702
+ <div class="card">
703
+ <div class="action-title">${t('完整状态历史')}</div>
704
+ <div class="timeline">${historyHtml || `<div style="color:#6b7280;font-size:13px">${t('暂无记录')}</div>`}</div>
705
+ </div>
706
+ `, 'orders')
707
+ }
708
+
709
+ function getActions(order, isBuyer, isSeller, isLogistic) {
710
+ const s = order.status
711
+ if (isSeller && s === 'paid')
712
+ return [{ action: 'accept', label: '接单', style: 'success' }]
713
+ if (isSeller && s === 'accepted')
714
+ return [{ action: 'ship', label: '确认发货', style: 'success', logisticsSelector: true,
715
+ evidencePlaceholder: '包装状态描述 / 货物说明(可选)' }]
716
+ if (isLogistic && s === 'shipped')
717
+ return [{ action: 'pickup', label: '✅ 确认揽收', style: 'success', needsEvidence: true,
718
+ noteLabel: '快递单号 *', evidencePlaceholder: '如:SF1234567890' }]
719
+ if (isLogistic && s === 'picked_up')
720
+ return [{ action: 'transit', label: '🚛 开始运输', style: 'primary' }]
721
+ if (isLogistic && s === 'in_transit')
722
+ return [{ action: 'deliver', label: '📬 确认投递', style: 'success', needsEvidence: true,
723
+ noteLabel: '投递凭证', evidencePlaceholder: '门牌照片描述 / 收件人姓名 / 签收时间' }]
724
+ if (isBuyer && s === 'delivered')
725
+ return [
726
+ { action: 'confirm', label: '确认收货', style: 'success' },
727
+ { action: 'dispute', label: '发起争议', style: 'danger', needsEvidence: true,
728
+ noteLabel: '争议理由', evidencePlaceholder: '描述问题(货不对版/货损/未收到等)' },
729
+ ]
730
+ return null
731
+ }
732
+
733
+ function renderActions(orderId, actions, order, logisticsCompanies = []) {
734
+ return `
735
+ <div class="action-area">
736
+ <div class="action-title">我的操作</div>
737
+ <div id="action-msg"></div>
738
+ ${actions.map((a, i) => `
739
+ ${a.logisticsSelector ? `
740
+ <div class="form-group">
741
+ <label class="form-label">选择物流公司 <span style="color:#dc2626">*</span></label>
742
+ <select class="form-control" id="logi-select-${i}">
743
+ <option value="">— 请选择物流公司 —</option>
744
+ ${logisticsCompanies.map(c => `<option value="${c.id}">${c.name}</option>`).join('')}
745
+ </select>
746
+ ${logisticsCompanies.length === 0
747
+ ? `<div class="alert alert-warning" style="margin-top:6px;font-size:13px">暂无已注册的物流公司,请先让物流方注册账号</div>`
748
+ : ''}
749
+ </div>
750
+ <button class="btn btn-${a.style}" style="margin-bottom:8px"
751
+ onclick="handleAction('${orderId}','${a.action}',${i},false,true)">
752
+ ${a.label}
753
+ </button>` :
754
+ a.needsEvidence ? `
755
+ <div class="form-group">
756
+ <label class="form-label">${a.noteLabel || '证据说明'}</label>
757
+ ${a.action === 'pickup'
758
+ ? `<input type="text" class="form-control" id="evid-${i}" placeholder="${a.evidencePlaceholder || '快递单号'}">`
759
+ : `<textarea class="form-control" id="evid-${i}" placeholder="${a.evidencePlaceholder || ''}"></textarea>`}
760
+ </div>
761
+ <button class="btn btn-${a.style}" style="margin-bottom:8px"
762
+ onclick="handleAction('${orderId}','${a.action}',${i},true,false)">
763
+ ${a.label}
764
+ </button>` : `
765
+ <button class="btn btn-${a.style}" style="margin-bottom:8px"
766
+ onclick="handleAction('${orderId}','${a.action}',${i},false,false)">
767
+ ${a.label}
768
+ </button>`}
769
+ `).join('')}
770
+ </div>`
771
+ }
772
+
773
+ window.handleAction = async (orderId, action, idx, needsEvidence, hasLogisticsSelector) => {
774
+ const msgEl = document.getElementById('action-msg')
775
+
776
+ let evidDesc = (needsEvidence || hasLogisticsSelector)
777
+ ? (document.getElementById(`evid-${idx}`)?.value?.trim() || '') : ''
778
+
779
+ // 揽收:需要快递单号,且自动加前缀
780
+ if (action === 'pickup') {
781
+ if (!evidDesc) { msgEl.innerHTML = alert$('error', '请填写快递单号'); return }
782
+ if (!evidDesc.startsWith('快递单号:')) evidDesc = `快递单号:${evidDesc}`
783
+ }
784
+ // 其他有证据要求的操作
785
+ if (needsEvidence && action !== 'pickup' && !evidDesc) {
786
+ msgEl.innerHTML = alert$('error', '请填写凭证内容'); return
787
+ }
788
+
789
+ // 发货:必须选物流公司,自动生成发货证据(单号由物流揽收时回传)
790
+ let logisticsCompanyId = ''
791
+ if (hasLogisticsSelector) {
792
+ const sel = document.getElementById(`logi-select-${idx}`)
793
+ logisticsCompanyId = sel?.value || ''
794
+ if (!logisticsCompanyId) { msgEl.innerHTML = alert$('error', '请选择物流公司'); return }
795
+ const companyName = sel.options[sel.selectedIndex]?.text || logisticsCompanyId
796
+ evidDesc = `已交付物流公司:${companyName},快递单号待物流揽收后回传`
797
+ }
798
+
799
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>处理中...</div>`
800
+
801
+ const body = { action, notes: evidDesc, evidence_description: evidDesc,
802
+ ...(logisticsCompanyId ? { logistics_company_id: logisticsCompanyId } : {}) }
803
+
804
+ const res = await POST(`/orders/${orderId}/action`, body)
805
+ if (res.error) {
806
+ msgEl.innerHTML = alert$('error', res.error)
807
+ } else {
808
+ msgEl.innerHTML = alert$('success', '操作成功!')
809
+ setTimeout(() => renderOrderDetail(document.getElementById('app'), orderId), 1000)
810
+ }
811
+ }
812
+
813
+ // ─── 争议 UI 辅助 ─────────────────────────────────────────────
814
+
815
+ const DISPUTE_STATUS_LABELS = {
816
+ open: ['red', '等待被告回应'],
817
+ in_review: ['yellow', '仲裁审查中'],
818
+ resolved: ['green', '已裁定'],
819
+ dismissed: ['gray', '已撤销'],
820
+ }
821
+
822
+ function disputeStatusBadge(status) {
823
+ const [color, label] = DISPUTE_STATUS_LABELS[status] || ['gray', status]
824
+ return `<span class="badge badge-${color}">${label}</span>`
825
+ }
826
+
827
+ const RULING_LABELS = {
828
+ refund_buyer: '🔵 全额退款给买家',
829
+ release_seller: '🟢 资金释放给卖家',
830
+ partial_refund: '🟡 部分退款',
831
+ liability_split: '⚖️ 责任分配裁定',
832
+ }
833
+
834
+ function evidenceHtml(list, label) {
835
+ if (!list || list.length === 0) return `<div style="color:#9ca3af;font-size:13px">(${label}尚未提交证据)</div>`
836
+ return list.map(e => `
837
+ <div style="background:#f9fafb;border-radius:8px;padding:8px 10px;margin-top:6px;font-size:13px">
838
+ <span style="color:#6b7280">📎 </span>${e.description || e.type}
839
+ </div>`).join('')
840
+ }
841
+
842
+ const EVIDENCE_TYPE_ICONS = {
843
+ text: '📝',
844
+ image: '🖼️',
845
+ video: '🎥',
846
+ document: '📄',
847
+ chain_data: '⛓️',
848
+ }
849
+ const EVIDENCE_TYPE_LABELS = {
850
+ text: '文字说明',
851
+ image: '图片',
852
+ video: '视频',
853
+ document: '单据/文件',
854
+ chain_data: '链上数据(不可篡改)',
855
+ }
856
+ const EVIDENCE_REQUEST_STATUS = {
857
+ pending: ['orange', '待提交'],
858
+ submitted: ['green', '已提交'],
859
+ expired: ['gray', '已过期'],
860
+ }
861
+
862
+ function erStatusBadge(status) {
863
+ const [color, label] = EVIDENCE_REQUEST_STATUS[status] || ['gray', status]
864
+ return `<span class="badge badge-${color}" style="font-size:11px">${label}</span>`
865
+ }
866
+
867
+ // 单条证据请求卡片
868
+ function evidenceRequestCard(req, currentUserId) {
869
+ const isMe = currentUserId && req.requested_from_id === currentUserId
870
+ // evidence_types 存为 JSON 数组字符串
871
+ let types = []
872
+ try { types = typeof req.evidence_types === 'string' ? JSON.parse(req.evidence_types) : (req.evidence_types || []) } catch(e) {}
873
+ const typeLabels = types.map(t => `${EVIDENCE_TYPE_ICONS[t] || ''}${EVIDENCE_TYPE_LABELS[t] || t}`).join(' ')
874
+
875
+ const submittedHtml = (req.submitted_items || []).length > 0 ? `
876
+ <div style="margin-top:8px">
877
+ ${req.submitted_items.map(it => `
878
+ <div style="background:#f0fdf4;border-radius:6px;padding:6px 8px;margin-top:4px;font-size:12px">
879
+ <span style="color:#6b7280">${EVIDENCE_TYPE_ICONS[it.type] || ''} ${EVIDENCE_TYPE_LABELS[it.type] || it.type}</span>
880
+ <div style="margin-top:2px">${it.description}</div>
881
+ ${it.file_hash ? `<div style="margin-top:3px;font-family:monospace;font-size:10px;color:#9ca3af" title="Phase 0 模拟锚点,Phase 2 替换为 IPFS CID / 链上 TX">🔒 ${it.file_hash}</div>` : ''}
882
+ </div>`).join('')}
883
+ </div>` : ''
884
+
885
+ const submitForm = isMe && req.status === 'pending' ? `
886
+ <div id="er-form-${req.id}" style="margin-top:10px;background:#fffbeb;border-radius:8px;padding:10px">
887
+ <div style="font-size:12px;font-weight:600;color:#92400e;margin-bottom:6px">提交所需证据</div>
888
+ <div id="er-msg-${req.id}"></div>
889
+ <select class="form-control" id="er-type-${req.id}" style="margin-bottom:6px;font-size:13px">
890
+ <option value="">— 选择证据类型 —</option>
891
+ ${types.map(t => `<option value="${t}">${EVIDENCE_TYPE_ICONS[t]} ${EVIDENCE_TYPE_LABELS[t] || t}</option>`).join('')}
892
+ </select>
893
+ <textarea class="form-control" id="er-desc-${req.id}" placeholder="详细描述内容(如图片描述、文字陈述、链上 TX hash 等)" style="margin-bottom:6px;font-size:13px"></textarea>
894
+ <input class="form-control" id="er-hash-${req.id}" placeholder="(可选)文件哈希 / IPFS CID / 链上 TX ID" style="margin-bottom:6px;font-size:12px;font-family:monospace">
895
+ <button class="btn btn-primary btn-sm" style="width:auto" onclick="handleSubmitEvidence('${req.id}','${req.dispute_id}')">提交证据</button>
896
+ </div>` : ''
897
+
898
+ return `
899
+ <div style="border:1px solid ${isMe && req.status === 'pending' ? '#f59e0b' : '#e5e7eb'};border-radius:8px;padding:10px;margin-top:8px;background:${isMe && req.status === 'pending' ? '#fffdf0' : '#fff'}">
900
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px">
901
+ <div style="font-size:13px;font-weight:600">${isMe ? '👤 需要你提供' : `请求 → ${req.requested_from_name || '对方'}(${req.requested_from_role || ''})`}</div>
902
+ ${erStatusBadge(req.status)}
903
+ </div>
904
+ <div style="font-size:12px;color:#6b7280;margin-bottom:4px">类型:${typeLabels}</div>
905
+ ${req.description ? `<div style="font-size:13px;margin-bottom:4px">${req.description}</div>` : ''}
906
+ <div style="font-size:11px;color:#9ca3af">截止:${fmtTime(req.deadline)}</div>
907
+ ${submittedHtml}
908
+ ${submitForm}
909
+ </div>`
910
+ }
911
+
912
+ function buildDisputeHtml(dispute, user) {
913
+ const isDefendant = user && user.id === dispute.defendant_id
914
+ const isArbitrator = user && user.role === 'arbitrator'
915
+ // is_party is set by the server; fallback to local check
916
+ const isParty = dispute.is_party || (user && dispute.parties && dispute.parties.some(p => p.id === user.id))
917
+ const parties = dispute.parties || []
918
+ const totalAmount = dispute.total_amount || 0
919
+
920
+ // ── 证据请求区块 ──────────────────────────────
921
+ const evidenceRequests = dispute.evidence_requests || []
922
+ const erSection = evidenceRequests.length > 0 ? `
923
+ <div style="margin-top:14px;border-top:1px solid #fef3c7;padding-top:12px">
924
+ <div style="font-size:12px;font-weight:600;color:#92400e;margin-bottom:6px">📋 补充证据请求(${evidenceRequests.length} 条)</div>
925
+ ${evidenceRequests.map(req => evidenceRequestCard(req, user?.id)).join('')}
926
+ </div>` : ''
927
+
928
+ // ── 参与方证据(物流等第三方主动提交的证据)──────
929
+ const partyEvidence = dispute.party_evidence || []
930
+ const partyEvidenceSection = partyEvidence.length > 0 ? `
931
+ <div style="margin-top:10px">
932
+ <div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:4px">其他参与方证据</div>
933
+ ${evidenceHtml(partyEvidence, '参与方')}
934
+ </div>` : ''
935
+
936
+ // ── 非被告参与方主动举证(物流等)──────────────
937
+ const canAddPartyEvidence = isParty && !isDefendant && !isArbitrator && dispute.status !== 'resolved'
938
+ const partyAddEvidenceSection = canAddPartyEvidence ? `
939
+ <div style="margin-top:12px;background:#f0f9ff;border-radius:8px;padding:10px 12px">
940
+ <div style="font-size:12px;font-weight:600;color:#0369a1;margin-bottom:6px">📤 主动提交我的证据</div>
941
+ <div id="party-evid-msg"></div>
942
+ <select class="form-control" id="party-evid-type" style="margin-bottom:6px;font-size:13px">
943
+ <option value="text">📝 文字说明</option>
944
+ <option value="image">🖼️ 图片</option>
945
+ <option value="video">🎥 视频</option>
946
+ <option value="document">📄 单据/文件</option>
947
+ <option value="chain_data">⛓️ 链上数据(不可篡改)</option>
948
+ </select>
949
+ <textarea class="form-control" id="party-evid-desc"
950
+ placeholder="详细描述你掌握的证据(如揽收记录、配送轨迹、签收凭证等)"
951
+ style="margin-bottom:6px;font-size:13px"></textarea>
952
+ <input class="form-control" id="party-evid-hash"
953
+ placeholder="(可选)文件哈希 / 链上 TX / IPFS CID"
954
+ style="margin-bottom:8px;font-size:12px;font-family:monospace">
955
+ <button class="btn btn-primary btn-sm" style="width:auto"
956
+ onclick="handleAddPartyEvidence('${dispute.id}')">提交证据</button>
957
+ </div>` : ''
958
+
959
+ // ── 被诉方提交反驳 ────────────────────────────
960
+ const respondSection = isDefendant && dispute.status === 'open' ? `
961
+ <div style="margin-top:12px">
962
+ <div style="font-weight:600;font-size:13px;margin-bottom:6px">📝 提交我的反驳(截止 ${fmtTime(dispute.respond_deadline)})</div>
963
+ <div id="respond-msg"></div>
964
+ <textarea class="form-control" id="respond-evidence" placeholder="请描述你的反驳理由、证据(如物流记录、商品照片说明等)" style="margin-bottom:8px"></textarea>
965
+ <button class="btn btn-primary btn-sm" style="width:auto" onclick="handleDisputeRespond('${dispute.id}','${dispute.order_id}')">提交反驳证据</button>
966
+ </div>` : ''
967
+
968
+ // ── 仲裁员:发起补充证据请求 ──────────────────
969
+ const requestEvidenceSection = isArbitrator && dispute.status !== 'resolved' ? `
970
+ <div style="margin-top:12px">
971
+ <button class="btn btn-outline btn-sm" style="width:auto;font-size:12px" onclick="(function(){var s=document.getElementById('req-ev-section');s.style.display=s.style.display==='none'?'':'none'})()">📋 请求补充证据</button>
972
+ <div id="req-ev-section" style="display:none;margin-top:10px;background:#f8fafc;border-radius:8px;padding:12px">
973
+ <div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:8px">向指定当事方请求证据</div>
974
+ <div id="req-ev-msg"></div>
975
+ <div style="margin-bottom:8px">
976
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">当事方</label>
977
+ <select class="form-control" id="req-ev-party" style="font-size:13px">
978
+ <option value="">— 选择当事方 —</option>
979
+ ${parties.map(p => `<option value="${p.id}">${p.name}(${p.role})</option>`).join('')}
980
+ </select>
981
+ </div>
982
+ <div style="margin-bottom:8px">
983
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">所需证据类型(可多选)</label>
984
+ <div style="display:flex;flex-wrap:wrap;gap:8px">
985
+ ${Object.entries(EVIDENCE_TYPE_LABELS).map(([k, v]) => `
986
+ <label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer">
987
+ <input type="checkbox" class="req-ev-type" value="${k}"> ${EVIDENCE_TYPE_ICONS[k]} ${v}
988
+ </label>`).join('')}
989
+ </div>
990
+ </div>
991
+ <div style="margin-bottom:8px">
992
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">说明(告诉对方需要提供什么)</label>
993
+ <textarea class="form-control" id="req-ev-desc" placeholder="例:请提供商品发出时的快递单照片" style="font-size:13px"></textarea>
994
+ </div>
995
+ <div style="margin-bottom:10px">
996
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">截止时间</label>
997
+ <select class="form-control" id="req-ev-deadline" style="font-size:13px">
998
+ <option value="24">24 小时</option>
999
+ <option value="48" selected>48 小时</option>
1000
+ <option value="72">72 小时</option>
1001
+ </select>
1002
+ </div>
1003
+ <button class="btn btn-primary btn-sm" style="width:auto" onclick="handleRequestEvidence('${dispute.id}')">发送请求</button>
1004
+ </div>
1005
+ </div>` : ''
1006
+
1007
+ // ── 仲裁员裁定(含责任分配)────────────────────
1008
+ const arbitrateSection = isArbitrator && (dispute.status === 'open' || dispute.status === 'in_review') ? `
1009
+ <div style="margin-top:12px;border-top:1px solid #fecaca;padding-top:12px">
1010
+ <div style="font-weight:600;font-size:13px;margin-bottom:8px">⚖️ 仲裁员裁定(截止 ${fmtTime(dispute.arbitrate_deadline)})</div>
1011
+ <div style="font-size:12px;color:#92400e;background:#fffbeb;border:1px solid #fcd34d;border-radius:6px;padding:6px 10px;margin-bottom:8px">
1012
+ 💰 败诉方须缴纳仲裁费:订单金额 × 1%(最低 1 WAZ)。部分退款时双方各付 0.5%。仲裁费 50% 归仲裁员,50% 归协议。
1013
+ </div>
1014
+ <div id="arbitrate-msg"></div>
1015
+
1016
+ <div class="form-group" style="margin-bottom:10px">
1017
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">裁定方式</label>
1018
+ <div style="display:flex;flex-direction:column;gap:6px">
1019
+ ${[
1020
+ ['refund_buyer', '🔵 全额退款买家(买家胜诉,卖家承担)'],
1021
+ ['release_seller', '🟢 资金释放给卖家(卖家胜诉)'],
1022
+ ['partial_refund', '🟡 部分退款(折中,需填金额)'],
1023
+ ['liability_split', '⚖️ 责任分配(指定各方赔付额)'],
1024
+ ].map(([val, label]) => `
1025
+ <label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;padding:8px;background:#f9fafb;border-radius:6px">
1026
+ <input type="radio" name="arb-ruling-radio" value="${val}" onclick="onArbRulingChange('${val}')"> ${label}
1027
+ </label>`).join('')}
1028
+ </div>
1029
+ </div>
1030
+
1031
+ <!-- 部分退款金额 -->
1032
+ <div id="arb-partial-row" style="display:none;margin-bottom:8px">
1033
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">退款金额(WAZ,订单总额 ${totalAmount})</label>
1034
+ <input class="form-control" id="arb-amount" type="number" step="0.01" min="0" max="${totalAmount}" placeholder="填写退款金额">
1035
+ </div>
1036
+
1037
+ <!-- 责任分配区块 -->
1038
+ <div id="arb-liability-block" style="display:none;margin-bottom:10px;background:#fff7ed;border-radius:8px;padding:12px">
1039
+ <div style="font-size:12px;font-weight:600;color:#9a3412;margin-bottom:8px">责任方赔付分配</div>
1040
+ <div style="font-size:12px;color:#6b7280;margin-bottom:8px">订单总额:${totalAmount} WAZ。填写各方应承担的赔偿金额,总和即为退款给买家的金额。</div>
1041
+ ${parties.map(p => `
1042
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
1043
+ <label style="display:flex;align-items:center;gap:4px;font-size:13px;min-width:130px;cursor:pointer">
1044
+ <input type="checkbox" class="arb-liable-chk" value="${p.id}" data-role="${p.role}" data-name="${p.name}">
1045
+ ${p.role === 'seller' ? '🏪' : p.role === 'logistics' ? '🚚' : '👤'} ${p.name}(${p.role})
1046
+ </label>
1047
+ <input type="number" class="form-control arb-liable-amount" data-party="${p.id}"
1048
+ step="0.01" min="0" max="${totalAmount}" placeholder="赔付金额"
1049
+ style="width:110px;font-size:13px" disabled>
1050
+ ${p.role === 'logistics' ? `
1051
+ <label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#6b7280;white-space:nowrap">
1052
+ <input type="checkbox" class="arb-liable-insurance" data-party="${p.id}"> 保险兜底
1053
+ <input type="number" class="form-control arb-liable-cap" data-party="${p.id}"
1054
+ step="0.01" min="0" placeholder="上限" style="width:80px;font-size:12px;margin-left:4px" disabled>
1055
+ </label>` : ''}
1056
+ </div>`).join('')}
1057
+ <div style="font-size:12px;color:#374151;margin-top:4px">
1058
+ 合计退款:<span id="arb-total-calc" style="font-weight:700">0</span> WAZ
1059
+ </div>
1060
+ </div>
1061
+
1062
+ <div style="margin-bottom:8px">
1063
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">裁定理由 *</label>
1064
+ <textarea class="form-control" id="arb-reason" placeholder="请详细说明裁定依据(将记录在案)" style="font-size:13px"></textarea>
1065
+ </div>
1066
+ <button class="btn btn-danger btn-sm" style="width:auto" onclick="handleArbitrate('${dispute.id}')">确认裁定</button>
1067
+ </div>` : ''
1068
+
1069
+ // ── 裁定结果 ──────────────────────────────────
1070
+ let rulingDetailHtml = ''
1071
+ if (dispute.status === 'resolved') {
1072
+ const liabilityParties = (() => {
1073
+ try { return JSON.parse(dispute.liability_parties || '[]') } catch { return [] }
1074
+ })()
1075
+ rulingDetailHtml = `
1076
+ <div style="margin-top:12px;background:#f0fdf4;border-radius:8px;padding:10px 12px">
1077
+ <div style="font-weight:700;color:#15803d;margin-bottom:4px">${RULING_LABELS[dispute.ruling_type] || dispute.ruling_type}</div>
1078
+ <div style="font-size:13px;color:#6b7280;margin-bottom:4px">${dispute.verdict_reason || ''}</div>
1079
+ ${dispute.refund_amount != null ? `<div style="font-size:13px">退款金额:<strong>${dispute.refund_amount} WAZ</strong></div>` : ''}
1080
+ ${liabilityParties.length > 0 ? `
1081
+ <div style="margin-top:6px;font-size:12px;color:#6b7280">责任分配:</div>
1082
+ ${liabilityParties.map(lp => `
1083
+ <div style="font-size:12px;margin-top:2px">
1084
+ ${lp.role === 'logistics' ? '🚚' : lp.role === 'seller' ? '🏪' : '👤'} ${lp.role}
1085
+ 承担 ${lp.amount} WAZ
1086
+ ${lp.insurance_cap != null ? `(保险兜底上限 ${lp.insurance_cap} WAZ)` : ''}
1087
+ </div>`).join('')}
1088
+ ` : ''}
1089
+ </div>`
1090
+ }
1091
+
1092
+ return `
1093
+ <div class="card" style="border-left:3px solid #dc2626">
1094
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
1095
+ <div style="font-weight:700">⚖️ 争议详情</div>
1096
+ ${disputeStatusBadge(dispute.status)}
1097
+ </div>
1098
+ <div class="detail-row"><span class="detail-label">发起方</span><span class="detail-value">${dispute.initiator_name}(${dispute.initiator_role || ''})</span></div>
1099
+ <div class="detail-row"><span class="detail-label">被诉方</span><span class="detail-value">${dispute.defendant_name || '—'}(${dispute.defendant_role || ''})</span></div>
1100
+ <div class="detail-row"><span class="detail-label">争议原因</span><span class="detail-value">${dispute.reason}</span></div>
1101
+ ${dispute.respond_deadline && dispute.status === 'open' ? `<div class="detail-row"><span class="detail-label">被告截止</span><span class="detail-value">${fmtTime(dispute.respond_deadline)}</span></div>` : ''}
1102
+
1103
+ <div style="margin-top:12px">
1104
+ <div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:4px">原告证据</div>
1105
+ ${evidenceHtml(dispute.plaintiff_evidence, '原告')}
1106
+ </div>
1107
+
1108
+ ${dispute.status !== 'open' || dispute.defendant_notes ? `
1109
+ <div style="margin-top:10px">
1110
+ <div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:4px">被告回应</div>
1111
+ ${dispute.defendant_notes ? `<div style="font-size:13px;margin-bottom:4px">${dispute.defendant_notes}</div>` : ''}
1112
+ ${evidenceHtml(dispute.defendant_evidence, '被告')}
1113
+ </div>` : ''}
1114
+
1115
+ ${partyEvidenceSection}
1116
+ ${erSection}
1117
+ ${rulingDetailHtml}
1118
+ ${respondSection}
1119
+ ${partyAddEvidenceSection}
1120
+ ${requestEvidenceSection}
1121
+ ${arbitrateSection}
1122
+
1123
+ <div style="margin-top:10px;text-align:right">
1124
+ <button class="btn btn-gray btn-sm" style="width:auto" onclick="navigate('#dispute/${dispute.id}')">查看完整争议页</button>
1125
+ </div>
1126
+ </div>`
1127
+ }
1128
+
1129
+ // 被诉方提交反驳
1130
+ window.handleDisputeRespond = async (disputeId, orderId) => {
1131
+ const evidence = document.getElementById('respond-evidence')?.value?.trim() || ''
1132
+ const msgEl = document.getElementById('respond-msg')
1133
+ if (!evidence) { msgEl.innerHTML = alert$('error', '请填写反驳内容'); return }
1134
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>提交中...</div>`
1135
+ const res = await POST(`/disputes/${disputeId}/respond`, { evidence_description: evidence })
1136
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
1137
+ msgEl.innerHTML = alert$('success', res.message || '反驳已提交!')
1138
+ setTimeout(() => renderOrderDetail(document.getElementById('app'), orderId), 1200)
1139
+ }
1140
+
1141
+ // 参与方主动提交证据(物流等非被告方)
1142
+ window.handleAddPartyEvidence = async (disputeId) => {
1143
+ const type = document.getElementById('party-evid-type')?.value || 'text'
1144
+ const desc = document.getElementById('party-evid-desc')?.value?.trim()
1145
+ const hash = document.getElementById('party-evid-hash')?.value?.trim()
1146
+ const msgEl = document.getElementById('party-evid-msg')
1147
+ if (!desc) { msgEl.innerHTML = alert$('error', '请填写证据内容'); return }
1148
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>提交中...</div>`
1149
+ const res = await POST(`/disputes/${disputeId}/add-evidence`, {
1150
+ description: desc, evidence_type: type, file_hash: hash || undefined
1151
+ })
1152
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
1153
+ msgEl.innerHTML = alert$('success', `已提交!锚点哈希:${res.anchor_hash || ''}`)
1154
+ setTimeout(() => renderDisputeDetail(document.getElementById('app'), disputeId), 1500)
1155
+ }
1156
+
1157
+ // 仲裁员裁定
1158
+ window.handleArbitrate = async (disputeId) => {
1159
+ const ruling = document.querySelector('input[name="arb-ruling-radio"]:checked')?.value
1160
+ const reason = document.getElementById('arb-reason')?.value?.trim()
1161
+ const msgEl = document.getElementById('arbitrate-msg')
1162
+ if (!ruling) { msgEl.innerHTML = alert$('error', '请选择裁定方式'); return }
1163
+ if (!reason) { msgEl.innerHTML = alert$('error', '请填写裁定理由'); return }
1164
+
1165
+ let body = { ruling, reason }
1166
+
1167
+ if (ruling === 'partial_refund') {
1168
+ const amount = document.getElementById('arb-amount')?.value
1169
+ if (!amount) { msgEl.innerHTML = alert$('error', '部分退款需填写退款金额'); return }
1170
+ body = { ...body, refund_amount: Number(amount) }
1171
+ }
1172
+
1173
+ if (ruling === 'liability_split') {
1174
+ const liabilityParties = []
1175
+ let totalCalc = 0
1176
+ for (const chk of document.querySelectorAll('.arb-liable-chk:checked')) {
1177
+ const partyId = chk.value
1178
+ const amtEl = document.querySelector(`.arb-liable-amount[data-party="${partyId}"]`)
1179
+ const amt = parseFloat(amtEl?.value || '0')
1180
+ if (!amt || amt <= 0) {
1181
+ msgEl.innerHTML = alert$('error', `请填写 ${chk.dataset.name} 的赔付金额`); return
1182
+ }
1183
+ const entry = { user_id: partyId, role: chk.dataset.role, amount: amt }
1184
+ const insuranceChk = document.querySelector(`.arb-liable-insurance[data-party="${partyId}"]`)
1185
+ if (insuranceChk?.checked) {
1186
+ const capEl = document.querySelector(`.arb-liable-cap[data-party="${partyId}"]`)
1187
+ const cap = parseFloat(capEl?.value || '0')
1188
+ if (cap > 0) entry.insurance_cap = cap
1189
+ }
1190
+ liabilityParties.push(entry)
1191
+ totalCalc += amt
1192
+ }
1193
+ if (liabilityParties.length === 0) {
1194
+ msgEl.innerHTML = alert$('error', '请至少选择一个责任方并填写金额'); return
1195
+ }
1196
+ body = { ...body, liability_parties: liabilityParties, refund_amount: totalCalc }
1197
+ }
1198
+
1199
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>裁定中...</div>`
1200
+ const res = await POST(`/disputes/${disputeId}/arbitrate`, body)
1201
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
1202
+
1203
+ // 显示仲裁费明细
1204
+ const fees = res.settlement?.arbitration_fees || {}
1205
+ const feeLines = Object.entries(fees).map(([uid, f]) => `用户 ${uid.slice(-6)} 缴纳仲裁费 ${f} WAZ`).join(';')
1206
+ const feeNote = feeLines ? `<div style="margin-top:6px;font-size:12px;color:#6b7280">💰 仲裁费:${feeLines}</div>` : ''
1207
+ msgEl.innerHTML = alert$('success', (res.message || '裁定已执行!') + feeNote)
1208
+ setTimeout(() => renderDisputeList(document.getElementById('app')), 2000)
1209
+ }
1210
+
1211
+ // 裁定方式切换 → 控制相关输入框显隐
1212
+ window.onArbRulingChange = (ruling) => {
1213
+ const partialRow = document.getElementById('arb-partial-row')
1214
+ const liabilityBlock = document.getElementById('arb-liability-block')
1215
+ if (partialRow) partialRow.style.display = ruling === 'partial_refund' ? '' : 'none'
1216
+ if (liabilityBlock) liabilityBlock.style.display = ruling === 'liability_split' ? '' : 'none'
1217
+ }
1218
+
1219
+ // 责任分配:勾选责任方时启用金额输入框,并实时计算合计
1220
+ document.addEventListener('change', (e) => {
1221
+ if (e.target?.classList.contains('arb-liable-chk')) {
1222
+ const partyId = e.target.value
1223
+ const amtEl = document.querySelector(`.arb-liable-amount[data-party="${partyId}"]`)
1224
+ if (amtEl) amtEl.disabled = !e.target.checked
1225
+ updateLiabilityTotal()
1226
+ }
1227
+ if (e.target?.classList.contains('arb-liable-amount')) updateLiabilityTotal()
1228
+ if (e.target?.classList.contains('arb-liable-insurance')) {
1229
+ const partyId = e.target.dataset.party
1230
+ const capEl = document.querySelector(`.arb-liable-cap[data-party="${partyId}"]`)
1231
+ if (capEl) capEl.disabled = !e.target.checked
1232
+ }
1233
+ })
1234
+
1235
+ function updateLiabilityTotal() {
1236
+ let total = 0
1237
+ for (const el of document.querySelectorAll('.arb-liable-amount:not([disabled])')) {
1238
+ total += parseFloat(el.value || '0') || 0
1239
+ }
1240
+ const calcEl = document.getElementById('arb-total-calc')
1241
+ if (calcEl) calcEl.textContent = total.toFixed(2)
1242
+ }
1243
+
1244
+ // 仲裁员请求补充证据
1245
+ window.handleRequestEvidence = async (disputeId) => {
1246
+ const partyId = document.getElementById('req-ev-party')?.value
1247
+ const desc = document.getElementById('req-ev-desc')?.value?.trim()
1248
+ const deadlineH = document.getElementById('req-ev-deadline')?.value
1249
+ const msgEl = document.getElementById('req-ev-msg')
1250
+ const types = [...document.querySelectorAll('.req-ev-type:checked')].map(el => el.value)
1251
+ if (!partyId) { msgEl.innerHTML = alert$('error', '请选择当事方'); return }
1252
+ if (types.length === 0) { msgEl.innerHTML = alert$('error', '请至少选择一种证据类型'); return }
1253
+ if (!desc) { msgEl.innerHTML = alert$('error', '请填写说明'); return }
1254
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>发送中...</div>`
1255
+ const res = await POST(`/disputes/${disputeId}/request-evidence`, {
1256
+ requested_from_id: partyId,
1257
+ evidence_types: types,
1258
+ description: desc,
1259
+ deadline_hours: Number(deadlineH) || 48,
1260
+ })
1261
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
1262
+ msgEl.innerHTML = alert$('success', '已发送证据请求!')
1263
+ setTimeout(() => renderDisputeDetail(document.getElementById('app'), disputeId), 1200)
1264
+ }
1265
+
1266
+ // 当事方提交证据
1267
+ window.handleSubmitEvidence = async (requestId, disputeId) => {
1268
+ const type = document.getElementById(`er-type-${requestId}`)?.value
1269
+ const desc = document.getElementById(`er-desc-${requestId}`)?.value?.trim()
1270
+ const hash = document.getElementById(`er-hash-${requestId}`)?.value?.trim()
1271
+ const msgEl = document.getElementById(`er-msg-${requestId}`)
1272
+ if (!type) { msgEl.innerHTML = alert$('error', '请选择证据类型'); return }
1273
+ if (!desc) { msgEl.innerHTML = alert$('error', '请填写内容'); return }
1274
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>提交中...</div>`
1275
+ const res = await POST(`/evidence-requests/${requestId}/submit`, {
1276
+ evidence_type: type,
1277
+ description: desc,
1278
+ file_hash: hash || undefined,
1279
+ })
1280
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
1281
+ msgEl.innerHTML = alert$('success', `证据已提交,锚点哈希:${res.anchor_hash || ''}`)
1282
+ setTimeout(() => renderDisputeDetail(document.getElementById('app'), disputeId), 1500)
1283
+ }
1284
+
1285
+ // 动态显示部分退款金额输入框
1286
+ document.addEventListener('change', (e) => {
1287
+ if (e.target?.id === 'arb-ruling') {
1288
+ const row = document.getElementById('arb-amount-row')
1289
+ if (row) row.style.display = e.target.value === 'partial_refund' ? '' : 'none'
1290
+ }
1291
+ })
1292
+
1293
+ // ─── 争议详情页(独立页)─────────────────────────────────────
1294
+
1295
+ async function renderDisputeDetail(app, disputeId) {
1296
+ if (!disputeId) { navigate('#disputes'); return }
1297
+ app.innerHTML = shell(loading$(), 'orders')
1298
+ const dispute = await GET(`/disputes/${disputeId}`)
1299
+ if (dispute.error) { app.innerHTML = shell(alert$('error', dispute.error), 'orders'); return }
1300
+
1301
+ app.innerHTML = shell(`
1302
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:16px" onclick="history.back()">${t('← 返回')}</button>
1303
+ <h1 class="page-title">争议 #${dispute.id.slice(-6)}</h1>
1304
+ ${buildDisputeHtml(dispute, state.user)}
1305
+ <div class="card" style="margin-top:12px">
1306
+ <div style="font-weight:700;margin-bottom:8px">关联订单</div>
1307
+ <button class="btn btn-outline btn-sm" style="width:auto" onclick="navigate('#order/${dispute.order_id}')">查看订单 →</button>
1308
+ </div>
1309
+ `, 'orders')
1310
+ }
1311
+
1312
+ // ─── 争议列表页(仲裁员视角)─────────────────────────────────
1313
+
1314
+ async function renderDisputeList(app) {
1315
+ if (!state.user) { renderLogin(); return }
1316
+ if (state.user.role !== 'arbitrator') {
1317
+ app.innerHTML = shell(`
1318
+ <h1 class="page-title">争议仲裁台</h1>
1319
+ <div class="alert alert-info">此功能仅限仲裁员使用。<br>你的角色:${state.user.role}</div>
1320
+ `, 'orders')
1321
+ return
1322
+ }
1323
+
1324
+ app.innerHTML = shell(loading$(), 'orders')
1325
+ const disputes = await GET('/disputes')
1326
+ if (disputes.error) { app.innerHTML = shell(alert$('error', disputes.error), 'orders'); return }
1327
+
1328
+ const html = disputes.length === 0
1329
+ ? `<div class="empty"><div class="empty-icon">⚖️</div><div class="empty-text">${t('暂无开放争议')}</div></div>`
1330
+ : disputes.map(d => `
1331
+ <div class="card" onclick="navigate('#dispute/${d.id}')" style="cursor:pointer">
1332
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
1333
+ <div style="font-weight:600">${d.reason.slice(0, 40)}${d.reason.length > 40 ? '…' : ''}</div>
1334
+ ${disputeStatusBadge(d.status)}
1335
+ </div>
1336
+ <div style="font-size:13px;color:#6b7280">
1337
+ 原告:${d.initiator_name} → 被告:${d.defendant_name || '—'}
1338
+ </div>
1339
+ <div style="font-size:12px;color:#9ca3af;margin-top:4px">
1340
+ 金额:${d.total_amount} WAZ · ${fmtTime(d.created_at)}
1341
+ ${d.status === 'open' ? ` · 截止 ${fmtTime(d.respond_deadline)}` : ''}
1342
+ ${d.status === 'in_review' ? ` · 仲裁截止 ${fmtTime(d.arbitrate_deadline)}` : ''}
1343
+ </div>
1344
+ </div>`).join('')
1345
+
1346
+ app.innerHTML = shell(`
1347
+ <h1 class="page-title">⚖️ 争议仲裁台</h1>
1348
+ <div class="alert alert-info" style="font-size:13px">共 ${disputes.length} 个待处理争议。点击进入查看详情并裁定。</div>
1349
+ ${html}
1350
+ `, 'seller')
1351
+ }
1352
+
1353
+ // ─── 物流仪表盘 ───────────────────────────────────────────────
1354
+
1355
+ async function renderLogistics(app) {
1356
+ if (!state.user) { renderLogin(); return }
1357
+ if (state.user.role !== 'logistics') {
1358
+ app.innerHTML = shell(`
1359
+ <h1 class="page-title">${t('物流仪表盘')}</h1>
1360
+ <div class="alert alert-info">此功能仅限物流角色使用。<br>你的角色:${state.user.role}</div>
1361
+ `, 'seller')
1362
+ return
1363
+ }
1364
+
1365
+ app.innerHTML = shell(loading$(), 'seller')
1366
+ const data = await GET('/logistics/orders')
1367
+ if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'seller'); return }
1368
+
1369
+ const { available, mine } = data
1370
+
1371
+ const LOGISTICS_ACTIONS = {
1372
+ shipped: { action: 'pickup', label: '✅ 确认揽收', style: 'success', hint: '填写运单号和揽收记录', needsEvidence: true },
1373
+ picked_up: { action: 'transit', label: '🚛 开始运输', style: 'primary', hint: '货物已从揽收点发出', needsEvidence: false },
1374
+ in_transit: { action: 'deliver', label: '📬 确认投递', style: 'success', hint: '填写门牌描述或签收记录', needsEvidence: true },
1375
+ }
1376
+
1377
+ const orderCard = (o, canClaim) => {
1378
+ const act = LOGISTICS_ACTIONS[o.status]
1379
+ return `
1380
+ <div class="card">
1381
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
1382
+ <div style="font-weight:600;flex:1;min-width:0;margin-right:8px">${o.product_title}</div>
1383
+ ${statusBadge(o.status)}
1384
+ </div>
1385
+ <div style="font-size:13px;color:#6b7280">
1386
+ ${t('买家:')}${o.buyer_name} · ${t('卖家:')}${o.seller_name}
1387
+ </div>
1388
+ <div style="font-size:12px;color:#9ca3af;margin-top:2px">
1389
+ ${o.total_amount} WAZ · ${fmtTime(o.created_at)}
1390
+ ${o.ship_deadline ? ` · 发货截止 ${fmtTime(o.ship_deadline)}` : ''}
1391
+ </div>
1392
+ ${o.shipping_address ? `<div style="font-size:12px;color:#6b7280;margin-top:4px">📍 ${o.shipping_address}</div>` : ''}
1393
+ ${act ? `
1394
+ <div style="margin-top:10px" id="log-act-${o.id}">
1395
+ ${act.needsEvidence ? `
1396
+ <div style="margin-bottom:8px">
1397
+ <label class="form-label" style="font-size:12px">${act.action === 'pickup' ? '快递单号 *' : '投递凭证 *'}</label>
1398
+ <input type="text" class="form-control" id="log-evid-${o.id}"
1399
+ placeholder="${act.action === 'pickup' ? '如:SF1234567890' : '门牌描述 / 收件人签收 / 时间'}"
1400
+ style="font-size:13px">
1401
+ </div>` : ''}
1402
+ <div id="log-msg-${o.id}"></div>
1403
+ <button class="btn btn-${act.style} btn-sm" style="width:auto"
1404
+ onclick="doLogisticsAction('${o.id}','${act.action}',${act.needsEvidence})">
1405
+ ${act.label}
1406
+ </button>
1407
+ </div>` : ''}
1408
+ </div>`
1409
+ }
1410
+
1411
+ const availableHtml = available.length === 0
1412
+ ? `<div class="empty" style="padding:16px"><div class="empty-icon">📭</div><div class="empty-text">${t('暂无待揽收订单')}</div></div>`
1413
+ : available.map(o => orderCard(o, true)).join('')
1414
+
1415
+ const mineHtml = mine.length === 0
1416
+ ? `<div class="empty" style="padding:16px"><div class="empty-icon">✅</div><div class="empty-text">${t('暂无进行中订单')}</div></div>`
1417
+ : mine.map(o => orderCard(o, false)).join('')
1418
+
1419
+ app.innerHTML = shell(`
1420
+ <h1 class="page-title">🚚 ${t('物流仪表盘')}</h1>
1421
+
1422
+ ${mine.length > 0 ? `<div class="alert alert-warning">📦 你有 ${mine.length} 个订单正在配送</div>` : ''}
1423
+
1424
+ <div style="font-weight:700;margin-bottom:8px">${t('我的配送任务')}</div>
1425
+ ${mineHtml}
1426
+
1427
+ <div class="divider"></div>
1428
+
1429
+ <div style="font-weight:700;margin-bottom:8px">${t('可接订单(未认领)')}</div>
1430
+ <div style="font-size:13px;color:#6b7280;margin-bottom:10px">
1431
+ ${t('揽收后即自动认领为你的配送任务')}
1432
+ </div>
1433
+ ${availableHtml}
1434
+ `, 'seller')
1435
+ }
1436
+
1437
+ window.doLogisticsAction = async (orderId, action, needsEvidence) => {
1438
+ const msgEl = document.getElementById(`log-msg-${orderId}`)
1439
+
1440
+ if (needsEvidence) {
1441
+ const raw = document.getElementById(`log-evid-${orderId}`)?.value?.trim() || ''
1442
+ if (!raw) { if (msgEl) msgEl.innerHTML = alert$('error', action === 'pickup' ? '请填写快递单号' : '请填写投递凭证'); return }
1443
+ // pickup 自动加"快递单号:"前缀(如果用户没加的话)
1444
+ const evid = (action === 'pickup' && !raw.startsWith('快递单号:')) ? `快递单号:${raw}` : raw
1445
+ if (msgEl) msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>处理中...</div>`
1446
+ const res = await POST(`/orders/${orderId}/action`, { action, evidence_description: evid })
1447
+ if (res.error) { if (msgEl) msgEl.innerHTML = alert$('error', res.error) }
1448
+ else { if (msgEl) msgEl.innerHTML = alert$('success', '操作成功!'); setTimeout(() => renderLogistics(document.getElementById('app')), 1000) }
1449
+ return
1450
+ }
1451
+
1452
+ if (msgEl) msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>处理中...</div>`
1453
+ const res = await POST(`/orders/${orderId}/action`, { action })
1454
+ if (res.error) { if (msgEl) msgEl.innerHTML = alert$('error', res.error) }
1455
+ else { if (msgEl) msgEl.innerHTML = alert$('success', '操作成功!'); setTimeout(() => renderLogistics(document.getElementById('app')), 1000) }
1456
+ }
1457
+
1458
+ // ─── 规格辅助函数 ─────────────────────────────────────────────
1459
+ // specs 存储格式:{ "颜色": "黑色", "尺码": "XL" }
1460
+ // 编辑框格式:每行 "key:value" 或 "key:value"
1461
+
1462
+ function parseSpecs(text) {
1463
+ if (!text || !text.trim()) return {}
1464
+ const obj = {}
1465
+ text.split('\n').forEach(line => {
1466
+ const sep = line.indexOf(':') !== -1 ? ':' : ':'
1467
+ const idx = line.indexOf(sep)
1468
+ if (idx < 1) return
1469
+ const k = line.slice(0, idx).trim()
1470
+ const v = line.slice(idx + sep.length).trim()
1471
+ if (k) obj[k] = v
1472
+ })
1473
+ return obj
1474
+ }
1475
+
1476
+ function formatSpecs(specs) {
1477
+ if (!specs || typeof specs !== 'object') return ''
1478
+ return Object.entries(specs).map(([k, v]) => `${k}:${v}`).join('\n')
1479
+ }
1480
+
1481
+ // ─── 卖家后台 ─────────────────────────────────────────────────
1482
+
1483
+ async function renderSeller(app) {
1484
+ if (!state.user) { renderLogin(); return }
1485
+ if (state.user.role !== 'seller') {
1486
+ app.innerHTML = shell(`
1487
+ <h1 class="page-title">${t('卖家后台')}</h1>
1488
+ <div class="alert alert-info">此功能仅限卖家使用。<br>你的角色:${state.user.role}</div>
1489
+ `, 'seller')
1490
+ return
1491
+ }
1492
+
1493
+ app.innerHTML = shell(loading$(), 'seller')
1494
+ const [productsRaw, ordersRaw, mySkillsRaw] = await Promise.all([GET('/my-products'), GET('/orders'), GET('/skills/mine')])
1495
+ const mySkills = Array.isArray(mySkillsRaw) ? mySkillsRaw : []
1496
+ const orders = Array.isArray(ordersRaw) ? ordersRaw : []
1497
+ const products = Array.isArray(productsRaw) ? productsRaw : []
1498
+
1499
+ const pendingOrders = orders.filter(o => ['paid', 'accepted'].includes(o.status) && o.seller_id === state.user.id)
1500
+ const myProducts = products
1501
+
1502
+ const pendingHtml = pendingOrders.length === 0
1503
+ ? `<div class="empty" style="padding:24px"><div class="empty-icon">✅</div><div class="empty-text">${t('暂无待处理订单')}</div></div>`
1504
+ : pendingOrders.map(o => `
1505
+ <div class="card" onclick="navigate('#order/${o.id}')" style="cursor:pointer">
1506
+ <div class="order-item">
1507
+ <div class="order-icon">📦</div>
1508
+ <div class="order-info">
1509
+ <div class="order-title">${o.product_title}</div>
1510
+ <div class="order-meta">${fmtTime(o.created_at)}</div>
1511
+ <div style="margin-top:6px">${statusBadge(o.status)}</div>
1512
+ </div>
1513
+ <div class="order-amount">${o.total_amount} WAZ</div>
1514
+ </div>
1515
+ </div>`).join('')
1516
+
1517
+ const activeProducts = products.filter(p => p.status === 'active')
1518
+ const warehouseProducts = products.filter(p => p.status !== 'active' && p.status !== 'deleted')
1519
+ const deletedProducts = products.filter(p => p.status === 'deleted')
1520
+
1521
+ function productLinksPanel(p) {
1522
+ return `
1523
+ <details style="margin-top:10px" onToggle="onLinksToggle(this,'${p.id}')">
1524
+ <summary style="font-size:12px;color:#6366f1;cursor:pointer;list-style:none">🔗 ${t('外部链接')} <span id="lnk-count-${p.id}"></span></summary>
1525
+ <div id="lnk-panel-${p.id}" style="margin-top:10px">
1526
+ <div style="font-size:12px;color:#9ca3af;margin-bottom:6px">${t('买家粘贴这些链接时,智能下单会直接匹配到你的商品')}</div>
1527
+ <div id="lnk-list-${p.id}">${loading$()}</div>
1528
+ <div style="display:flex;gap:6px;margin-top:8px">
1529
+ <input class="form-control" id="lnk-inp-${p.id}" placeholder="${t('粘贴外部链接(需验证)')}" style="font-size:12px;flex:1">
1530
+ <button class="btn btn-outline btn-sm" style="width:auto" onclick="doAddLink('${p.id}')">${t('添加')}</button>
1531
+ </div>
1532
+ <div id="lnk-msg-${p.id}"></div>
1533
+ </div>
1534
+ </details>`
1535
+ }
1536
+
1537
+ function activeCard(p) {
1538
+ return `<div class="card">
1539
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
1540
+ <div style="flex:1;min-width:0">
1541
+ <div style="font-weight:600">${escHtml(p.title)}</div>
1542
+ <div style="font-size:13px;color:#6b7280;margin-top:2px">${p.price} WAZ · ${t('库存')} ${p.stock}</div>
1543
+ </div>
1544
+ <div style="display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end">
1545
+ <span class="badge badge-green">${t('在售')}</span>
1546
+ <button class="btn btn-outline btn-sm" onclick="navigate('#edit-product/${p.id}')">${t('编辑')}</button>
1547
+ <button class="btn btn-gray btn-sm" onclick="setProductStatus('${p.id}','warehouse')">${t('下架')}</button>
1548
+ </div>
1549
+ </div>
1550
+ ${productLinksPanel(p)}
1551
+ </div>`
1552
+ }
1553
+
1554
+ function warehouseCard(p) {
1555
+ const canList = !p.has_pending_task && !p.all_links_revoked
1556
+ const listBtn = canList
1557
+ ? `<button class="btn btn-primary btn-sm" onclick="setProductStatus('${p.id}','active')">${t('上架')}</button>`
1558
+ : `<button class="btn btn-sm" disabled title="${p.has_pending_task ? t('链接核验中,请等待验证结果') : t('所有链接已失效,请先添加新链接')}" style="opacity:0.45;cursor:not-allowed;background:#e5e7eb;color:#9ca3af;width:auto">
1559
+ ${p.has_pending_task ? t('核验中') : t('链接失效')}
1560
+ </button>`
1561
+ return `<div class="card">
1562
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
1563
+ <div style="flex:1;min-width:0">
1564
+ <div style="font-weight:600">${escHtml(p.title)}</div>
1565
+ <div style="font-size:13px;color:#6b7280;margin-top:2px">${p.price} WAZ · ${t('库存')} ${p.stock}</div>
1566
+ ${p.has_pending_task ? `<div style="font-size:11px;color:#d97706;margin-top:2px">⏳ ${t('链接核验中,请等待验证结果')}</div>` : ''}
1567
+ ${p.all_links_revoked ? `<div style="font-size:11px;color:#ef4444;margin-top:2px">❌ ${t('所有链接已失效,请先添加新链接')}</div>` : ''}
1568
+ </div>
1569
+ <div style="display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end">
1570
+ <span class="badge badge-gray">${t('仓库')}</span>
1571
+ ${listBtn}
1572
+ <button class="btn btn-outline btn-sm" onclick="navigate('#edit-product/${p.id}')">${t('编辑')}</button>
1573
+ <button class="btn btn-sm" style="background:#fee2e2;color:#991b1b" onclick="setProductStatus('${p.id}','deleted')">${t('移入回收箱')}</button>
1574
+ </div>
1575
+ </div>
1576
+ ${productLinksPanel(p)}
1577
+ </div>`
1578
+ }
1579
+
1580
+ function deletedCard(p) {
1581
+ return `<div class="card" style="opacity:0.75;border:1px dashed #fca5a5">
1582
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
1583
+ <div style="flex:1;min-width:0">
1584
+ <div style="font-weight:600;text-decoration:line-through;color:#9ca3af">${escHtml(p.title)}</div>
1585
+ <div style="font-size:13px;color:#d1d5db;margin-top:2px">${p.price} WAZ</div>
1586
+ </div>
1587
+ <div style="display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end">
1588
+ <span class="badge badge-red">${t('回收箱')}</span>
1589
+ <button class="btn btn-outline btn-sm" onclick="setProductStatus('${p.id}','warehouse')">${t('恢复到仓库')}</button>
1590
+ <button class="btn btn-sm" style="background:#dc2626;color:#fff" onclick="deleteProductPermanently('${p.id}')">${t('彻底删除')}</button>
1591
+ </div>
1592
+ </div>
1593
+ </div>`
1594
+ }
1595
+
1596
+ const emptyHtml = `<div class="empty" style="padding:24px"><div class="empty-icon">📭</div><div class="empty-text">${t('暂无商品')}</div></div>`
1597
+ const activeHtml = activeProducts.length ? activeProducts.map(activeCard).join('') : emptyHtml
1598
+ const warehouseHtml = warehouseProducts.length ? warehouseProducts.map(warehouseCard).join('') : emptyHtml
1599
+ const deletedHtml = deletedProducts.length ? deletedProducts.map(deletedCard).join('') : emptyHtml
1600
+
1601
+ app.innerHTML = shell(`
1602
+ <h1 class="page-title">${t('卖家后台')}</h1>
1603
+
1604
+ ${pendingOrders.length > 0 ? `<div class="alert alert-warning">📬 你有 ${pendingOrders.length} 个订单需要处理</div>` : ''}
1605
+
1606
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
1607
+ <div style="font-weight:700">${t('待处理订单')}</div>
1608
+ </div>
1609
+ ${pendingHtml}
1610
+
1611
+ <div class="divider"></div>
1612
+
1613
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
1614
+ <div style="font-weight:700">${t('商品管理')}</div>
1615
+ <div style="display:flex;gap:8px">
1616
+ <button class="btn btn-outline btn-sm" onclick="showImportProduct()">🔗 ${t('导入')}</button>
1617
+ <button class="btn btn-primary btn-sm" onclick="showAddProduct()">${t('+ 上架')}</button>
1618
+ </div>
1619
+ </div>
1620
+
1621
+ <!-- 商品分类标签页 -->
1622
+ <div style="display:flex;gap:6px;margin-bottom:12px">
1623
+ <button class="prd-tab-btn" data-tab="active"
1624
+ onclick="switchProductTab('active')"
1625
+ style="flex:1;padding:8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:1.5px solid #3b82f6;background:#eff6ff;color:#1d4ed8">
1626
+ ${t('在售')} <span style="font-size:11px;font-weight:400">(${activeProducts.length})</span>
1627
+ </button>
1628
+ <button class="prd-tab-btn" data-tab="warehouse"
1629
+ onclick="switchProductTab('warehouse')"
1630
+ style="flex:1;padding:8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:1.5px solid #e5e7eb;background:#f9fafb;color:#374151">
1631
+ ${t('仓库')} <span style="font-size:11px;font-weight:400">(${warehouseProducts.length})</span>
1632
+ </button>
1633
+ <button class="prd-tab-btn" data-tab="deleted"
1634
+ onclick="switchProductTab('deleted')"
1635
+ style="flex:1;padding:8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:1.5px solid #e5e7eb;background:#f9fafb;color:#374151">
1636
+ ${t('回收箱')} <span style="font-size:11px;font-weight:400">(${deletedProducts.length})</span>
1637
+ </button>
1638
+ </div>
1639
+ <div id="prd-tab-active">${activeHtml}</div>
1640
+ <div id="prd-tab-warehouse" style="display:none">${warehouseHtml}</div>
1641
+ <div id="prd-tab-deleted" style="display:none">${deletedHtml}</div>
1642
+
1643
+ <!-- 一键导入面板 -->
1644
+ <div id="import-product-form" style="display:none">
1645
+ <div class="divider"></div>
1646
+ <div class="card">
1647
+ <div style="font-weight:700;margin-bottom:4px">🔗 ${t('一键导入商品')}</div>
1648
+ <div style="font-size:13px;color:#6b7280;margin-bottom:16px">${t('粘贴任意平台商品链接,AI 自动提取信息并给出定价建议')}</div>
1649
+ <div id="import-msg"></div>
1650
+ <input class="form-control" id="import-url" placeholder="${t('粘贴淘宝 / 京东 / 亚马逊 / Shopify 等链接')}" style="margin-bottom:8px">
1651
+ <button class="btn btn-primary" id="btn-import" onclick="doImportProduct()">✨ ${t('解析')}</button>
1652
+ <div id="import-quota" style="font-size:12px;color:#6b7280;margin-bottom:8px"></div>
1653
+ <details style="margin-bottom:16px">
1654
+ <summary style="font-size:12px;color:#9ca3af;cursor:pointer">${t('使用自己的 Anthropic API Key(不限次数)')}</summary>
1655
+ <div style="margin-top:8px;display:flex;gap:8px;align-items:center">
1656
+ <input class="form-control" id="import-own-key" type="text"
1657
+ placeholder="sk-ant-..."
1658
+ autocomplete="off"
1659
+ style="font-family:monospace;font-size:12px;flex:1;-webkit-text-security:disc"
1660
+ value="${localStorage.getItem('webaz_own_ak') || ''}"
1661
+ oninput="localStorage.setItem('webaz_own_ak', this.value.trim())">
1662
+ <button class="btn btn-outline btn-sm" onclick="localStorage.removeItem('webaz_own_ak');document.getElementById('import-own-key').value=''" style="white-space:nowrap">${t('清除')}</button>
1663
+ </div>
1664
+ <div style="font-size:11px;color:#9ca3af;margin-top:4px">${t('Key 仅存储在本地,不上传服务器,用完即丢')}</div>
1665
+ </details>
1666
+ <!-- 预览区(解析后显示) -->
1667
+ <div id="import-preview" style="display:none">
1668
+ <div class="divider"></div>
1669
+ <div style="font-size:13px;font-weight:600;color:#4f46e5;margin-bottom:12px">✅ ${t('解析完成,确认后上架')}</div>
1670
+ <div class="form-group"><label class="form-label">${t('商品名称')}</label><input class="form-control" id="imp-title"></div>
1671
+ <div class="form-group"><label class="form-label">${t('商品描述')}</label><textarea class="form-control" id="imp-desc" rows="3"></textarea></div>
1672
+ <div class="form-group"><label class="form-label">${t('规格参数')}<span style="font-size:11px;color:#9ca3af;font-weight:400;margin-left:6px">${t('每行:材质: 陶瓷')}</span></label>
1673
+ <textarea class="form-control" id="imp-specs" rows="3"></textarea></div>
1674
+ <div style="display:flex;gap:12px">
1675
+ <div class="form-group" style="flex:1"><label class="form-label">${t('价格(WAZ)')}</label><input class="form-control" id="imp-price" type="number"></div>
1676
+ <div class="form-group" style="flex:1"><label class="form-label">${t('库存')}</label><input class="form-control" id="imp-stock" type="number" value="1"></div>
1677
+ </div>
1678
+ <div id="imp-price-hint" style="font-size:12px;color:#059669;margin:-8px 0 12px;padding:8px 12px;background:#f0fdf4;border-radius:6px;display:none"></div>
1679
+ <div style="display:flex;gap:12px">
1680
+ <div class="form-group" style="flex:1"><label class="form-label">${t('分类')}</label>
1681
+ <select class="form-control" id="imp-cat">
1682
+ <option value="">${t('不分类')}</option>
1683
+ <option value="茶具">${t('茶具')}</option><option value="家居">${t('家居')}</option>
1684
+ <option value="食品">${t('食品')}</option><option value="服装">${t('服装')}</option>
1685
+ <option value="手工">${t('手工')}</option><option value="电子">${t('电子')}</option>
1686
+ </select>
1687
+ </div>
1688
+ <div class="form-group" style="flex:1"><label class="form-label">${t('备货时间(小时)')}</label>
1689
+ <input class="form-control" id="imp-handling" type="number" value="24"></div>
1690
+ </div>
1691
+ <div style="display:flex;gap:12px">
1692
+ <div class="form-group" style="flex:1"><label class="form-label">${t('退货天数')}</label><input class="form-control" id="imp-return" type="number" value="7"></div>
1693
+ <div class="form-group" style="flex:1"><label class="form-label">${t('质保天数')}</label><input class="form-control" id="imp-warranty" type="number" value="0"></div>
1694
+ </div>
1695
+ <div class="btn-row">
1696
+ <button class="btn btn-gray" onclick="hideImportProduct()">${t('取消')}</button>
1697
+ <button class="btn btn-primary" onclick="doPublishImported()">${t('确认上架')}</button>
1698
+ </div>
1699
+ </div>
1700
+ </div>
1701
+ </div>
1702
+
1703
+ <div id="add-product-form" style="display:none">
1704
+ <div class="divider"></div>
1705
+ <div class="card">
1706
+ <div style="font-weight:700;margin-bottom:16px">${t('上架新商品')}</div>
1707
+ <div id="add-msg"></div>
1708
+
1709
+ <div class="form-group"><label class="form-label">${t('商品名称')} *</label><input class="form-control" id="prd-title" placeholder="${t('例:手工竹编收纳篮')}"></div>
1710
+ <div class="form-group"><label class="form-label">${t('商品描述')} *<span style="font-size:11px;color:#9ca3af;font-weight:400;margin-left:6px">${t('面向 Agent 检索,写核心参数而非营销语言')}</span></label>
1711
+ <textarea class="form-control" id="prd-desc" rows="3" placeholder="${t('材质、尺寸、颜色、适用场景...')}"></textarea></div>
1712
+ <div style="display:flex;gap:12px">
1713
+ <div class="form-group" style="flex:1"><label class="form-label">${t('价格(WAZ)')} *</label><input class="form-control" id="prd-price" type="number" placeholder="199"></div>
1714
+ <div class="form-group" style="flex:1"><label class="form-label">${t('库存')}</label><input class="form-control" id="prd-stock" type="number" value="1"></div>
1715
+ </div>
1716
+
1717
+ <div class="form-group"><label class="form-label">${t('规格参数')}<span style="font-size:11px;color:#9ca3af;font-weight:400;margin-left:6px">${t('每行一个,格式:材质: 陶瓷')}</span></label>
1718
+ <textarea class="form-control" id="prd-specs" rows="3" placeholder="材质: 陶瓷&#10;容量: 350ml&#10;颜色: 白色"></textarea></div>
1719
+
1720
+ <div style="display:flex;gap:12px">
1721
+ <div class="form-group" style="flex:1"><label class="form-label">${t('分类')}</label>
1722
+ <select class="form-control" id="prd-cat">
1723
+ <option value="">${t('不分类')}</option>
1724
+ <option value="茶具">${t('茶具')}</option><option value="家居">${t('家居')}</option>
1725
+ <option value="食品">${t('食品')}</option><option value="服装">${t('服装')}</option>
1726
+ <option value="手工">${t('手工')}</option><option value="电子">${t('电子')}</option>
1727
+ </select>
1728
+ </div>
1729
+ <div class="form-group" style="flex:1"><label class="form-label">${t('备货时间(小时)')}</label>
1730
+ <input class="form-control" id="prd-handling" type="number" value="24" min="1"></div>
1731
+ </div>
1732
+
1733
+ <details style="margin-bottom:16px">
1734
+ <summary style="font-size:13px;color:#6b7280;cursor:pointer;padding:4px 0">${t('售后与物流(填写越完整,Agent 越优先选择你)')}</summary>
1735
+ <div style="margin-top:12px;display:flex;gap:12px">
1736
+ <div class="form-group" style="flex:1"><label class="form-label">${t('退货天数')}</label>
1737
+ <input class="form-control" id="prd-return" type="number" value="7" min="0"></div>
1738
+ <div class="form-group" style="flex:1"><label class="form-label">${t('质保天数')}</label>
1739
+ <input class="form-control" id="prd-warranty" type="number" value="0" min="0"></div>
1740
+ </div>
1741
+ <div class="form-group"><label class="form-label">${t('退货条件')}</label>
1742
+ <input class="form-control" id="prd-return-cond" placeholder="${t('例:未拆封 / 任意原因 / 质量问题')}"></div>
1743
+ <div class="form-group"><label class="form-label">${t('配送范围')}</label>
1744
+ <input class="form-control" id="prd-ship-regions" value="${t('全国')}" placeholder="${t('全国 / 华东华南 / 不支持偏远地区')}"></div>
1745
+ <div class="form-group"><label class="form-label">${t('预计时效')}<span style="font-size:11px;color:#9ca3af;font-weight:400;margin-left:6px">${t('每行:地区: 天数')}</span></label>
1746
+ <textarea class="form-control" id="prd-est-days" rows="2" placeholder="华东: 2&#10;全国: 5"></textarea></div>
1747
+ <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
1748
+ <input type="checkbox" id="prd-fragile" style="width:16px;height:16px">
1749
+ <label for="prd-fragile" style="font-size:13px">${t('易碎品,需特殊包装')}</label>
1750
+ </div>
1751
+ </details>
1752
+
1753
+ <div class="btn-row">
1754
+ <button class="btn btn-gray" onclick="hideAddProduct()">${t('取消')}</button>
1755
+ <button class="btn btn-primary" onclick="doAddProduct()">${t('上架')}</button>
1756
+ </div>
1757
+ </div>
1758
+ </div>
1759
+
1760
+ <div class="divider"></div>
1761
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
1762
+ <div style="font-weight:700">⚡ ${t('我的 Skill')}</div>
1763
+ <button class="btn btn-outline btn-sm" onclick="navigate('#skills')">${t('Skill 市场')}</button>
1764
+ </div>
1765
+ <div id="my-skills-list">${mySkills.length > 0 ? mySkills.map(s => skillCard(s, 'seller')).join('') : `<div class="empty" style="padding:24px"><div class="empty-icon">⚡</div><div class="empty-text">${t('还没有 Skill')}</div></div>`}</div>
1766
+ <button class="btn btn-outline" style="margin-top:12px" onclick="showPublishSkill()">${t('+ 发布新 Skill')}</button>
1767
+
1768
+ <div id="publish-skill-form" style="display:none">
1769
+ <div class="divider"></div>
1770
+ <div class="card">
1771
+ <div style="font-weight:700;margin-bottom:16px">${t('发布 Skill')}</div>
1772
+ <div id="skill-msg"></div>
1773
+ <div class="form-group"><label class="form-label">${t('Skill 类型')}</label>
1774
+ <select class="form-control" id="skl-type" onchange="updateSkillConfigHint()">
1775
+ <option value="catalog_sync">🔄 目录同步 — 商品接入 WebAZ 搜索</option>
1776
+ <option value="auto_accept">⚡ 自动接单 — 买家下单立即接受</option>
1777
+ <option value="price_negotiation">🤝 价格协商 — 允许 Agent 议价</option>
1778
+ <option value="quality_guarantee">🛡️ 质量承诺 — 额外质押保证</option>
1779
+ <option value="instant_ship">🚀 极速发货 — 承诺 24h 发货</option>
1780
+ </select>
1781
+ </div>
1782
+ <div class="form-group"><label class="form-label">${t('Skill 名称')}</label><input class="form-control" id="skl-name" placeholder="例:竹韵手工坊自动接单"></div>
1783
+ <div class="form-group"><label class="form-label">${t('描述')}</label><textarea class="form-control" id="skl-desc" placeholder="简要说明这个 Skill 能给买家带来什么好处"></textarea></div>
1784
+ <div id="skl-config-hint" class="alert alert-info" style="font-size:13px;margin-bottom:16px">目录同步:将你的商品列入 WebAZ 搜索优先级,买家订阅后可优先发现你的商品。推荐佣金 0.5% 由协议自动分配。</div>
1785
+ <div class="btn-row">
1786
+ <button class="btn btn-gray" onclick="hidePublishSkill()">${t('取消')}</button>
1787
+ <button class="btn btn-primary" onclick="doPublishSkill()">${t('发布')}</button>
1788
+ </div>
1789
+ </div>
1790
+ </div>
1791
+ `, 'seller')
1792
+ }
1793
+
1794
+ window.showAddProduct = () => { document.getElementById('add-product-form').style.display = '' }
1795
+ window.hideAddProduct = () => { document.getElementById('add-product-form').style.display = 'none' }
1796
+
1797
+ window.switchProductTab = (tab) => {
1798
+ ['active', 'warehouse', 'deleted'].forEach(k => {
1799
+ const panel = document.getElementById(`prd-tab-${k}`)
1800
+ if (panel) panel.style.display = k === tab ? '' : 'none'
1801
+ })
1802
+ document.querySelectorAll('.prd-tab-btn').forEach(btn => {
1803
+ const isActive = btn.dataset.tab === tab
1804
+ btn.style.background = isActive ? '#eff6ff' : '#f9fafb'
1805
+ btn.style.color = isActive ? '#1d4ed8' : '#374151'
1806
+ btn.style.borderColor = isActive ? '#3b82f6' : '#e5e7eb'
1807
+ })
1808
+ }
1809
+
1810
+ window.setProductStatus = async (id, status) => {
1811
+ let confirmMsg = ''
1812
+ if (status === 'deleted') confirmMsg = t('确认移入回收箱?移入后商品将下架,可随时恢复。')
1813
+ if (status === 'warehouse') confirmMsg = t('确认下架到仓库?买家将无法购买。')
1814
+ if (confirmMsg && !confirm(confirmMsg)) return
1815
+ const res = await api('PATCH', `/products/${id}/status`, { status })
1816
+ if (res.error) return void alert(res.error)
1817
+ renderSeller(document.getElementById('app'))
1818
+ }
1819
+
1820
+ window.deleteProductPermanently = async (id) => {
1821
+ if (!confirm(t('彻底删除后无法恢复,确认删除?'))) return
1822
+ const res = await api('DELETE', `/products/${id}`)
1823
+ if (res.error) return void alert(res.error)
1824
+ renderSeller(document.getElementById('app'))
1825
+ }
1826
+
1827
+ window.showImportProduct = () => {
1828
+ document.getElementById('import-product-form').style.display = ''
1829
+ document.getElementById('import-preview').style.display = 'none'
1830
+ document.getElementById('import-msg').innerHTML = ''
1831
+ document.getElementById('import-url').value = ''
1832
+ }
1833
+ window.hideImportProduct = () => { document.getElementById('import-product-form').style.display = 'none' }
1834
+
1835
+ window.doImportProduct = async () => {
1836
+ const url = document.getElementById('import-url').value.trim()
1837
+ if (!url) return
1838
+ const btn = document.getElementById('btn-import')
1839
+ const msgEl = document.getElementById('import-msg')
1840
+ const quotaEl = document.getElementById('import-quota')
1841
+ const ownKey = localStorage.getItem('webaz_own_ak') || ''
1842
+
1843
+ btn.disabled = true; btn.textContent = t('解析中...')
1844
+ msgEl.innerHTML = ''
1845
+ document.getElementById('import-preview').style.display = 'none'
1846
+
1847
+ const res = await POST('/import-product', { url, user_api_key: ownKey || undefined })
1848
+ btn.disabled = false; btn.textContent = `✨ ${t('解析')}`
1849
+
1850
+ // 更新额度显示
1851
+ if (res.quota) {
1852
+ quotaEl.textContent = `${t('今日剩余免费次数')}:${res.quota.remaining} / ${res.quota.limit}`
1853
+ quotaEl.style.color = res.quota.remaining <= 2 ? '#dc2626' : '#6b7280'
1854
+ } else if (res.used_own_key) {
1855
+ quotaEl.textContent = `✓ ${t('使用自己的 Key,不限次数')}`
1856
+ quotaEl.style.color = '#059669'
1857
+ }
1858
+
1859
+ if (res.conflict) {
1860
+ // 链接已被他人认领 → 跳转认领流程
1861
+ msgEl.innerHTML = `
1862
+ <div style="padding:12px;background:#fffbeb;border:1px solid #f59e0b;border-radius:8px;font-size:13px;line-height:1.6">
1863
+ ⚠️ <strong>${t('此链接已被其他商家认领上架')}</strong><br>
1864
+ <span style="color:#6b7280">${t('如需认领归属,可发起链接认领验证任务,经众包验证通过后链接归属转移到您的商品。')}</span>
1865
+ <div style="margin-top:10px;display:flex;gap:8px">
1866
+ <button class="btn btn-primary btn-sm" style="width:auto" onclick="navigate('#claim-url/${encodeURIComponent(res.url)}')">${t('发起认领认证 →')}</button>
1867
+ <button class="btn btn-outline btn-sm" style="width:auto" onclick="document.getElementById('import-url').value=''">${t('换一个链接')}</button>
1868
+ </div>
1869
+ </div>`
1870
+ return
1871
+ }
1872
+ if (res.error) {
1873
+ if (res.quota_exceeded) {
1874
+ msgEl.innerHTML = alert$('error', res.error)
1875
+ document.querySelector('#import-product-form details')?.setAttribute('open', '')
1876
+ } else if (res.suggestion === 'manual') {
1877
+ msgEl.innerHTML = alert$('error', res.error) +
1878
+ `<div style="margin-top:8px"><button class="btn btn-outline btn-sm" onclick="hideImportProduct();showAddProduct()">${t('改用手动上架')}</button></div>`
1879
+ } else {
1880
+ msgEl.innerHTML = alert$('error', res.error)
1881
+ }
1882
+ return
1883
+ }
1884
+
1885
+ // 填入预览表单(防御:验证关键字段非空)
1886
+ const titleVal = res.title || ''
1887
+ if (!titleVal) {
1888
+ msgEl.innerHTML = alert$('error', t('提取内容为空,请尝试其他链接或改用手动上架')) +
1889
+ `<div style="margin-top:8px"><button class="btn btn-outline btn-sm" onclick="hideImportProduct();showAddProduct()">${t('改用手动上架')}</button></div>`
1890
+ return
1891
+ }
1892
+
1893
+ document.getElementById('imp-title').value = titleVal
1894
+ document.getElementById('imp-desc').value = res.description || ''
1895
+ document.getElementById('imp-specs').value = formatSpecs(res.specs)
1896
+ document.getElementById('imp-price').value = res.suggested_price || ''
1897
+ document.getElementById('imp-stock').value = res.stock || 1
1898
+ document.getElementById('imp-handling').value = res.handling_hours || 24
1899
+ document.getElementById('imp-return').value = res.return_days ?? 7
1900
+ document.getElementById('imp-warranty').value = res.warranty_days ?? 0
1901
+
1902
+ const catEl = document.getElementById('imp-cat')
1903
+ if (res.category) { const opt = [...catEl.options].find(o => o.value === res.category); if (opt) catEl.value = res.category }
1904
+
1905
+ // 定价建议 + 来源价格对比
1906
+ const hintEl = document.getElementById('imp-price-hint')
1907
+ if (res.price_reasoning) {
1908
+ hintEl.textContent = `💡 ${res.price_reasoning}${res.original_price ? `(${t('原价参考')}:${res.original_price} CNY)` : ''}`
1909
+ hintEl.style.display = ''
1910
+ }
1911
+
1912
+ // 暂存来源信息供上架时使用
1913
+ window._importMeta = { source_url: res.source_url, source_price: res.source_price }
1914
+ document.getElementById('import-preview').style.display = ''
1915
+ }
1916
+
1917
+ window.doPublishImported = async () => {
1918
+ const title = document.getElementById('imp-title').value.trim()
1919
+ const desc = document.getElementById('imp-desc').value.trim()
1920
+ const price = Number(document.getElementById('imp-price').value)
1921
+ const stock = Number(document.getElementById('imp-stock').value) || 1
1922
+ const category = document.getElementById('imp-cat').value
1923
+ const msgEl = document.getElementById('import-msg')
1924
+
1925
+ if (!title || !desc || !price) return void (msgEl.innerHTML = alert$('error', t('请填写商品名、描述、价格')))
1926
+
1927
+ const payload = {
1928
+ title, description: desc, price, stock, category,
1929
+ specs: parseSpecs(document.getElementById('imp-specs').value),
1930
+ handling_hours: Number(document.getElementById('imp-handling').value) || 24,
1931
+ return_days: Number(document.getElementById('imp-return').value) ?? 7,
1932
+ warranty_days: Number(document.getElementById('imp-warranty').value) ?? 0,
1933
+ ...(window._importMeta || {}),
1934
+ }
1935
+ const res = await POST('/products', payload)
1936
+ if (res.error) return void (msgEl.innerHTML = alert$('error', res.error))
1937
+
1938
+ const successBase = alert$('success', `${t('上架成功!质押')} ${res.stake_locked} WAZ ${t('已锁定')}`)
1939
+ let extra = ''
1940
+ window._importMeta = null
1941
+ if (res.link_conflict) {
1942
+ // 有冲突:跳转到商品编辑页,在那里完成验证码确认操作
1943
+ msgEl.innerHTML = alert$('success', t('商品已进入仓库,需完成链接验证后才能上架'))
1944
+ setTimeout(() => navigate(`#edit-product/${res.product_id}`), 1200)
1945
+ } else {
1946
+ msgEl.innerHTML = successBase
1947
+ setTimeout(() => renderSeller(document.getElementById('app')), 1500)
1948
+ }
1949
+ }
1950
+
1951
+ window.doAddProduct = async () => {
1952
+ const title = document.getElementById('prd-title').value.trim()
1953
+ const desc = document.getElementById('prd-desc').value.trim()
1954
+ const price = Number(document.getElementById('prd-price').value)
1955
+ const stock = Number(document.getElementById('prd-stock').value) || 1
1956
+ const category = document.getElementById('prd-cat').value
1957
+ const msgEl = document.getElementById('add-msg')
1958
+
1959
+ if (!title || !desc || !price) { msgEl.innerHTML = alert$('error', t('请填写商品名、描述、价格')); return }
1960
+
1961
+ const payload = {
1962
+ title, description: desc, price, stock, category,
1963
+ specs: parseSpecs(document.getElementById('prd-specs').value),
1964
+ handling_hours: Number(document.getElementById('prd-handling').value) || 24,
1965
+ ship_regions: document.getElementById('prd-ship-regions').value.trim() || '全国',
1966
+ estimated_days: parseSpecs(document.getElementById('prd-est-days').value),
1967
+ return_days: Number(document.getElementById('prd-return').value) ?? 7,
1968
+ return_condition: document.getElementById('prd-return-cond').value.trim(),
1969
+ warranty_days: Number(document.getElementById('prd-warranty').value) ?? 0,
1970
+ fragile: document.getElementById('prd-fragile').checked ? 1 : 0,
1971
+ }
1972
+ const res = await POST('/products', payload)
1973
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
1974
+
1975
+ msgEl.innerHTML = alert$('success', `${t('上架成功!质押')} ${res.stake_locked} WAZ ${t('已锁定')}`)
1976
+ setTimeout(() => renderSeller(document.getElementById('app')), 1500)
1977
+ }
1978
+
1979
+ // ─── 外部链接管理 ─────────────────────────────────────────────
1980
+
1981
+ window.onLinksToggle = async (details, productId) => {
1982
+ if (!details.open) return
1983
+ const listEl = document.getElementById(`lnk-list-${productId}`)
1984
+ if (!listEl || listEl.dataset.loaded) return
1985
+ listEl.dataset.loaded = '1'
1986
+ await refreshLinks(productId)
1987
+ }
1988
+
1989
+ async function refreshLinks(productId) {
1990
+ const listEl = document.getElementById(`lnk-list-${productId}`)
1991
+ const countEl = document.getElementById(`lnk-count-${productId}`)
1992
+ if (!listEl) return
1993
+ const links = await GET(`/products/${productId}/links`)
1994
+ if (links.error) { listEl.innerHTML = alert$('error', links.error); return }
1995
+ if (countEl) countEl.textContent = links.length > 0 ? `(${links.length})` : ''
1996
+ if (links.length === 0) {
1997
+ listEl.innerHTML = `<div style="font-size:12px;color:#9ca3af">${t('暂无外部链接')}</div>`
1998
+ return
1999
+ }
2000
+ listEl.innerHTML = links.map(lk => `
2001
+ <div style="display:flex;align-items:flex-start;gap:6px;margin-bottom:6px;font-size:12px">
2002
+ <span style="margin-top:1px">${lk.verified ? '✅' : '⏳'}</span>
2003
+ <div style="flex:1;min-width:0">
2004
+ <div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#374151">${lk.url}</div>
2005
+ ${lk.verify_note ? `<div style="color:#9ca3af">${lk.verify_note}</div>` : ''}
2006
+ <div style="color:#9ca3af">${lk.source === 'import' ? t('导入时自动保存') : t('手动添加')}</div>
2007
+ </div>
2008
+ <button onclick="doDeleteLink('${productId}','${lk.id}')" style="background:none;border:none;color:#dc2626;cursor:pointer;font-size:14px;padding:0;flex-shrink:0">×</button>
2009
+ </div>`).join('')
2010
+ }
2011
+
2012
+ window.doAddLink = async (productId) => {
2013
+ const inp = document.getElementById(`lnk-inp-${productId}`)
2014
+ const msg = document.getElementById(`lnk-msg-${productId}`)
2015
+ const url = inp.value.trim()
2016
+ if (!url) return
2017
+ msg.innerHTML = `<div style="font-size:12px;color:#6b7280">${t('提交中...')}</div>`
2018
+ inp.disabled = true
2019
+ const res = await POST(`/products/${productId}/links`, { url })
2020
+ inp.disabled = false
2021
+ if (res.error) { msg.innerHTML = alert$('error', res.error); return }
2022
+ inp.value = ''
2023
+ msg.innerHTML = res.verified
2024
+ ? `<div style="font-size:12px;color:#16a34a">✅ ${t('链接已关联')}</div>`
2025
+ : linkTaskCard(res)
2026
+ const listEl = document.getElementById(`lnk-list-${productId}`)
2027
+ if (listEl) { listEl.dataset.loaded = ''; await refreshLinks(productId) }
2028
+ }
2029
+
2030
+ window.doDeleteLink = async (productId, linkId) => {
2031
+ await fetch(`/api/products/${productId}/links/${linkId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${state.apiKey}` } })
2032
+ const listEl = document.getElementById(`lnk-list-${productId}`)
2033
+ if (listEl) { listEl.dataset.loaded = ''; await refreshLinks(productId) }
2034
+ }
2035
+
2036
+ // ─── 卖家 Skill 管理 ──────────────────────────────────────────
2037
+
2038
+ function skillCard(s, context) {
2039
+ const typeIcons = { catalog_sync:'🔄', auto_accept:'⚡', price_negotiation:'🤝', quality_guarantee:'🛡️', instant_ship:'🚀' }
2040
+ const typeLabels = { catalog_sync:'目录同步', auto_accept:'自动接单', price_negotiation:'价格协商', quality_guarantee:'质量承诺', instant_ship:'极速发货' }
2041
+ const icon = typeIcons[s.skill_type] || '⚙️'
2042
+ const label = typeLabels[s.skill_type] || s.skill_type
2043
+ if (context === 'seller') {
2044
+ return `
2045
+ <div class="card">
2046
+ <div style="display:flex;justify-content:space-between;align-items:flex-start">
2047
+ <div style="flex:1;min-width:0">
2048
+ <div style="font-weight:600">${icon} ${s.name}</div>
2049
+ <div style="font-size:12px;color:#6b7280;margin-top:2px">${label} · ${s.subscriber_count || 0} 订阅 · 使用 ${s.total_uses} 次</div>
2050
+ </div>
2051
+ <span class="badge badge-green">${t('运行中')}</span>
2052
+ </div>
2053
+ <div style="font-size:13px;color:#6b7280;margin-top:8px">${s.description}</div>
2054
+ </div>`
2055
+ }
2056
+ // buyer context
2057
+ const subscribed = Boolean(s.subscribed)
2058
+ return `
2059
+ <div class="card">
2060
+ <div style="display:flex;justify-content:space-between;align-items:flex-start">
2061
+ <div style="flex:1;min-width:0">
2062
+ <div style="font-weight:600">${icon} ${s.name}</div>
2063
+ <div style="font-size:12px;color:#6b7280;margin-top:2px">${label} · @${s.seller_name} · ${s.subscriber_count || 0} 订阅</div>
2064
+ </div>
2065
+ <button class="btn ${subscribed ? 'btn-gray' : 'btn-primary'} btn-sm" style="flex-shrink:0;margin-left:8px"
2066
+ onclick="toggleSubscribeSkill('${s.id}', ${subscribed})">${subscribed ? t('已订阅') : t('+ 订阅')}</button>
2067
+ </div>
2068
+ <div style="font-size:13px;color:#6b7280;margin-top:8px">${s.description}</div>
2069
+ </div>`
2070
+ }
2071
+
2072
+ const SKILL_CONFIG_HINTS = {
2073
+ catalog_sync: '目录同步:订阅此 Skill 的买家在搜索时会优先看到你的商品。成交后协议自动给你 0.5% 推荐佣金。',
2074
+ auto_accept: '自动接单:买家下单后无需手动操作,系统自动接受。可设置每日上限、金额范围。',
2075
+ price_negotiation: '价格协商:允许买家 Agent 在你设定的折扣范围内自动议价,减少沟通成本。',
2076
+ quality_guarantee: '质量承诺:额外质押 WAZ 作为品质担保,增强买家信任,适合高客单价商品。',
2077
+ instant_ship: '极速发货:承诺接单后 24h 内发货,违约自动赔付。适合有充足现货的卖家。',
2078
+ }
2079
+
2080
+ window.updateSkillConfigHint = () => {
2081
+ const type = document.getElementById('skl-type').value
2082
+ const hint = document.getElementById('skl-config-hint')
2083
+ if (hint) hint.textContent = SKILL_CONFIG_HINTS[type] || ''
2084
+ }
2085
+
2086
+ window.showPublishSkill = () => { document.getElementById('publish-skill-form').style.display = '' }
2087
+ window.hidePublishSkill = () => { document.getElementById('publish-skill-form').style.display = 'none' }
2088
+
2089
+ window.doPublishSkill = async () => {
2090
+ const skill_type = document.getElementById('skl-type').value
2091
+ const name = document.getElementById('skl-name').value.trim()
2092
+ const description = document.getElementById('skl-desc').value.trim()
2093
+ const msgEl = document.getElementById('skill-msg')
2094
+ if (!name || !description) { msgEl.innerHTML = alert$('error', '请填写名称和描述'); return }
2095
+ const res = await POST('/skills', { skill_type, name, description })
2096
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
2097
+ msgEl.innerHTML = alert$('success', '✅ Skill 已发布!买家可以在 Skill 市场订阅')
2098
+ setTimeout(() => renderSeller(document.getElementById('app')), 1500)
2099
+ }
2100
+
2101
+ // ─── 商品编辑页 ───────────────────────────────────────────────
2102
+
2103
+ async function renderEditProduct(app, productId) {
2104
+ if (!state.user) { renderLogin(); return }
2105
+ app.innerHTML = shell(loading$(), 'seller')
2106
+ const [p, links, tasksData] = await Promise.all([
2107
+ GET(`/products/${productId}`),
2108
+ GET(`/products/${productId}/links`),
2109
+ GET(`/verify-tasks/by-product/${productId}`),
2110
+ ])
2111
+ const openTasks = Array.isArray(tasksData) ? tasksData : []
2112
+ if (p.error) { app.innerHTML = shell(alert$('error', p.error), 'seller'); return }
2113
+
2114
+ const specsText = formatSpecs(p.specs || {})
2115
+ const estText = typeof p.estimated_days === 'object' && p.estimated_days
2116
+ ? Object.entries(p.estimated_days).map(([k,v]) => `${k}:${v}`).join('\n')
2117
+ : (p.estimated_days || '')
2118
+
2119
+ app.innerHTML = shell(`
2120
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
2121
+ <button onclick="navigate('#seller')" style="background:none;border:none;cursor:pointer;font-size:20px;padding:0">←</button>
2122
+ <h1 class="page-title" style="margin:0">${t('编辑商品')}</h1>
2123
+ </div>
2124
+ <div id="edit-msg"></div>
2125
+ <div class="card">
2126
+ <div class="form-group"><label class="form-label">${t('商品名称')}</label>
2127
+ <input class="form-control" id="ep-title" value="${p.title?.replace(/"/g,'&quot;') || ''}"></div>
2128
+ <div class="form-group"><label class="form-label">${t('商品描述')}</label>
2129
+ <textarea class="form-control" id="ep-desc" rows="4">${p.description || ''}</textarea></div>
2130
+ <div class="form-group"><label class="form-label">${t('规格参数')}</label>
2131
+ <textarea class="form-control" id="ep-specs" rows="3" placeholder="颜色:黑色&#10;材质:陶瓷">${specsText}</textarea></div>
2132
+ <div style="display:flex;gap:12px">
2133
+ <div class="form-group" style="flex:1"><label class="form-label">${t('价格(WAZ)')}</label>
2134
+ <input class="form-control" id="ep-price" type="number" value="${p.price || ''}"></div>
2135
+ <div class="form-group" style="flex:1"><label class="form-label">${t('库存')}</label>
2136
+ <input class="form-control" id="ep-stock" type="number" value="${p.stock ?? 1}"></div>
2137
+ </div>
2138
+ <details style="margin-bottom:12px">
2139
+ <summary style="font-size:13px;color:#6b7280;cursor:pointer">${t('物流 & 售后')}</summary>
2140
+ <div style="margin-top:12px">
2141
+ <div style="display:flex;gap:12px">
2142
+ <div class="form-group" style="flex:1"><label class="form-label">${t('处理时间 (小时)')}</label>
2143
+ <input class="form-control" id="ep-handling" type="number" value="${p.handling_hours ?? 24}"></div>
2144
+ <div class="form-group" style="flex:1"><label class="form-label">${t('退货天数')}</label>
2145
+ <input class="form-control" id="ep-return" type="number" value="${p.return_days ?? 7}"></div>
2146
+ </div>
2147
+ <div style="display:flex;gap:12px">
2148
+ <div class="form-group" style="flex:1"><label class="form-label">${t('质保天数')}</label>
2149
+ <input class="form-control" id="ep-warranty" type="number" value="${p.warranty_days ?? 0}"></div>
2150
+ <div class="form-group" style="flex:1"><label class="form-label">${t('发货地区')}</label>
2151
+ <input class="form-control" id="ep-ship-regions" value="${p.ship_regions || '全国'}"></div>
2152
+ </div>
2153
+ <div class="form-group"><label class="form-label">${t('时效 (天)')}<span style="font-size:11px;color:#9ca3af;margin-left:6px">${t('每行:华东: 2')}</span></label>
2154
+ <textarea class="form-control" id="ep-est-days" rows="2">${estText}</textarea></div>
2155
+ <div style="display:flex;align-items:center;gap:8px">
2156
+ <input type="checkbox" id="ep-fragile" ${p.fragile ? 'checked' : ''} style="width:16px;height:16px">
2157
+ <label for="ep-fragile" style="font-size:13px">${t('易碎品,需特殊包装')}</label>
2158
+ </div>
2159
+ </div>
2160
+ </details>
2161
+ <button class="btn btn-primary" onclick="doUpdateProduct('${productId}')">${t('保存修改')}</button>
2162
+ </div>
2163
+
2164
+ <div class="card" style="margin-top:16px">
2165
+ <div style="font-weight:600;margin-bottom:12px">🔗 ${t('外部链接')}</div>
2166
+ <div style="font-size:12px;color:#6b7280;margin-bottom:10px">${t('买家粘贴这些链接时,智能下单会直接匹配到你的商品')}</div>
2167
+ <div id="ep-links-list">
2168
+ ${links.length === 0
2169
+ ? `<div style="font-size:12px;color:#9ca3af">${t('暂无外部链接')}</div>`
2170
+ : links.map(lk => {
2171
+ const pendingTask = lk.verified === 0 && !lk.revoked ? openTasks.find(tk => tk.url === lk.url) : null
2172
+ const isRevoked = lk.revoked === 1
2173
+ let borderColor = '#d1fae5'
2174
+ if (isRevoked) borderColor = '#fecaca'
2175
+ else if (!lk.verified) borderColor = '#fde68a'
2176
+ return `
2177
+ <div style="border:1px solid ${borderColor};border-radius:8px;padding:8px 10px;margin-bottom:8px;font-size:12px">
2178
+ <div style="display:flex;align-items:flex-start;gap:6px">
2179
+ <span style="flex-shrink:0">${isRevoked ? '❌' : lk.verified ? '✅' : '⏳'}</span>
2180
+ <div style="flex:1;min-width:0">
2181
+ <div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${isRevoked ? '#9ca3af' : '#374151'};${isRevoked ? 'text-decoration:line-through' : ''}">${lk.url}</div>
2182
+ <div style="color:#9ca3af;margin-top:2px">${lk.source === 'import' ? t('导入来源') : lk.source === 'claim' ? t('认领验证') : t('手动添加')} · ${isRevoked ? '<span style="color:#ef4444">主权失效</span>' : lk.verified ? '✓ ' + t('已验证') : t('待验证')}</div>
2183
+ </div>
2184
+ <button onclick="doDeleteLinkEdit('${productId}','${lk.id}')" style="background:none;border:none;color:#dc2626;cursor:pointer;font-size:14px;padding:0;flex-shrink:0">×</button>
2185
+ </div>
2186
+ ${isRevoked ? `
2187
+ <div style="margin-top:6px;padding:6px 8px;background:#fef2f2;border-radius:6px;font-size:12px;color:#991b1b">
2188
+ ⚠️ ${t('此链接已被其他商家通过验证取得归属权,如有异议可再次发起挑战(需支付费用)。')}
2189
+ </div>` : ''}
2190
+ ${pendingTask ? `
2191
+ <div style="margin-top:6px;padding:8px 10px;background:#fffbeb;border-radius:6px;font-size:12px">
2192
+ <div style="color:#92400e;font-weight:600;margin-bottom:6px">🔑 ${t('验证码')}:<span style="font-family:monospace;font-size:14px;letter-spacing:2px">[${pendingTask.code}]</span></div>
2193
+ ${pendingTask.status === 'code_issued' ? `
2194
+ <div style="color:#374151;margin-bottom:8px;line-height:1.5">${t('请将以上验证码放入原平台商品标题或描述,完成后点击下方按钮提交任务。')}</div>
2195
+ <div style="display:flex;gap:8px;align-items:center">
2196
+ <button class="btn btn-primary btn-sm" id="confirm-btn-${pendingTask.id}" style="width:auto" onclick="doConfirmVerifyTask('${pendingTask.id}','${productId}')">${t('已添加验证码,提交任务')}</button>
2197
+ <div id="confirm-msg-${pendingTask.id}" style="font-size:12px;color:#6b7280"></div>
2198
+ </div>` : `
2199
+ <div style="color:#6b7280">${t('任务已提交,等待验证员审核 · 截止')} ${new Date(pendingTask.expires_at).toLocaleDateString()}</div>
2200
+ <a href="#verify-tasks" style="font-size:11px;color:#4f46e5">${t('查看任务进度')}</a>`}
2201
+ </div>` : ''}
2202
+ </div>`}).join('')}
2203
+ </div>
2204
+ <div style="display:flex;gap:6px;margin-top:10px">
2205
+ <input class="form-control" id="ep-lnk-inp" placeholder="${t('粘贴站外链接(需众包验证)')}" style="font-size:12px;flex:1" onkeydown="if(event.key==='Enter')doAddLinkEdit('${productId}')">
2206
+ <button class="btn btn-outline btn-sm" style="flex-shrink:0;width:auto" onclick="doAddLinkEdit('${productId}')">${t('添加')}</button>
2207
+ </div>
2208
+ <div id="ep-lnk-msg"></div>
2209
+ </div>
2210
+ `, 'seller')
2211
+ }
2212
+
2213
+ window.doUpdateProduct = async (productId) => {
2214
+ const msgEl = document.getElementById('edit-msg')
2215
+ const estRaw = document.getElementById('ep-est-days').value.trim()
2216
+ const payload = {
2217
+ title: document.getElementById('ep-title').value.trim(),
2218
+ description: document.getElementById('ep-desc').value.trim(),
2219
+ price: Number(document.getElementById('ep-price').value),
2220
+ stock: Number(document.getElementById('ep-stock').value),
2221
+ specs: parseSpecs(document.getElementById('ep-specs').value),
2222
+ handling_hours: Number(document.getElementById('ep-handling').value) || 24,
2223
+ return_days: Number(document.getElementById('ep-return').value) ?? 7,
2224
+ warranty_days: Number(document.getElementById('ep-warranty').value) ?? 0,
2225
+ ship_regions: document.getElementById('ep-ship-regions').value.trim(),
2226
+ estimated_days: parseSpecs(estRaw),
2227
+ fragile: document.getElementById('ep-fragile').checked ? 1 : 0,
2228
+ }
2229
+ if (!payload.title || !payload.description || !payload.price) {
2230
+ msgEl.innerHTML = alert$('error', t('请填写商品名、描述、价格')); return
2231
+ }
2232
+ const btn = document.querySelector('[onclick*="doUpdateProduct"]')
2233
+ if (btn) { btn.disabled = true; btn.textContent = t('保存中...') }
2234
+ const res = await fetch(`/api/products/${productId}`, {
2235
+ method: 'PUT',
2236
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${state.apiKey}` },
2237
+ body: JSON.stringify(payload),
2238
+ }).then(r => r.json())
2239
+ if (btn) { btn.disabled = false; btn.textContent = t('保存修改') }
2240
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
2241
+ msgEl.innerHTML = alert$('success', t('已保存'))
2242
+ setTimeout(() => msgEl.innerHTML = '', 2000)
2243
+ }
2244
+
2245
+ window.doAddLinkEdit = async (productId) => {
2246
+ const inp = document.getElementById('ep-lnk-inp')
2247
+ const msg = document.getElementById('ep-lnk-msg')
2248
+ const url = inp.value.trim()
2249
+ if (!url) return
2250
+ msg.innerHTML = `<div style="font-size:12px;color:#6b7280">${t('提交中...')}</div>`
2251
+ inp.disabled = true
2252
+ const res = await POST(`/products/${productId}/links`, { url })
2253
+ inp.disabled = false
2254
+ if (res.error) { msg.innerHTML = alert$('error', res.error); return }
2255
+ inp.value = ''
2256
+ msg.innerHTML = res.verified
2257
+ ? `<div style="font-size:12px;color:#16a34a">✅ ${t('链接已关联')}</div>`
2258
+ : linkTaskCard(res)
2259
+ renderEditProduct(document.getElementById('app'), productId)
2260
+ }
2261
+
2262
+ window.doDeleteLinkEdit = async (productId, linkId) => {
2263
+ await fetch(`/api/products/${productId}/links/${linkId}`, {
2264
+ method: 'DELETE', headers: { Authorization: `Bearer ${state.apiKey}` }
2265
+ })
2266
+ renderEditProduct(document.getElementById('app'), productId)
2267
+ }
2268
+
2269
+ window.doConfirmVerifyTask = async (taskId, productId) => {
2270
+ const btn = document.getElementById(`confirm-btn-${taskId}`)
2271
+ const msgEl = document.getElementById(`confirm-msg-${taskId}`)
2272
+ if (btn) { btn.disabled = true; btn.textContent = t('提交中...') }
2273
+ const r = await POST(`/verify-tasks/${taskId}/confirm`, {})
2274
+ if (r.error) {
2275
+ if (msgEl) { msgEl.style.color = '#ef4444'; msgEl.textContent = r.error }
2276
+ if (btn) { btn.disabled = false; btn.textContent = t('已添加验证码,提交任务') }
2277
+ } else {
2278
+ if (msgEl) { msgEl.style.color = '#10b981'; msgEl.textContent = t('✓ 已提交,等待审核员确认') }
2279
+ if (btn) btn.style.display = 'none'
2280
+ setTimeout(() => renderEditProduct(document.getElementById('app'), productId), 1000)
2281
+ }
2282
+ }
2283
+
2284
+ // ─── 智能下单(Agent Buy)─────────────────────────────────────
2285
+
2286
+ async function renderAgentBuy(app) {
2287
+ if (!state.user) { renderLogin(); return }
2288
+ if (state.user.role !== 'buyer') {
2289
+ app.innerHTML = shell(`<div class="alert alert-info">${t('智能下单仅限买家使用')}</div>`, 'shop')
2290
+ return
2291
+ }
2292
+
2293
+ app.innerHTML = shell(`
2294
+ <h1 class="page-title">🤖 ${t('智能下单')}</h1>
2295
+ <p style="color:#6b7280;font-size:13px;margin-bottom:16px">${t('粘贴商品链接,AI 自动搜索 WebAZ 更优方案,可一键下单')}</p>
2296
+
2297
+ <div class="card" style="margin-bottom:16px">
2298
+ <div class="form-group">
2299
+ <label class="form-label">${t('商品链接')}</label>
2300
+ <input class="form-control" id="ab-url" placeholder="${t('粘贴淘宝 / 京东 / 亚马逊等链接')}" style="font-size:13px">
2301
+ </div>
2302
+ <div class="form-group">
2303
+ <label class="form-label">${t('收货地址')}</label>
2304
+ <input class="form-control" id="ab-addr" placeholder="${t('省市区街道,用于自动下单')}" style="font-size:13px">
2305
+ </div>
2306
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
2307
+ <input type="checkbox" id="ab-auto" style="width:16px;height:16px">
2308
+ <label for="ab-auto" style="font-size:13px;cursor:pointer">${t('找到更优方案后自动下单(否则仅展示比价结果)')}</label>
2309
+ </div>
2310
+ <button class="btn btn-primary" id="ab-btn" onclick="doAgentBuy()">${t('开始分析')}</button>
2311
+ </div>
2312
+
2313
+ <div id="ab-result"></div>
2314
+ `, 'shop')
2315
+ }
2316
+
2317
+ window.doAgentBuy = async () => {
2318
+ const url = document.getElementById('ab-url').value.trim()
2319
+ const addr = document.getElementById('ab-addr').value.trim()
2320
+ const auto = document.getElementById('ab-auto').checked
2321
+ const btn = document.getElementById('ab-btn')
2322
+ const result = document.getElementById('ab-result')
2323
+
2324
+ if (!url) { result.innerHTML = alert$('error', t('请粘贴商品链接')); return }
2325
+ if (auto && !addr) { result.innerHTML = alert$('error', t('自动下单需填写收货地址')); return }
2326
+
2327
+ btn.disabled = true
2328
+ btn.textContent = t('分析中...')
2329
+ result.innerHTML = loading$()
2330
+
2331
+ const res = await POST('/agent-buy', {
2332
+ source_url: url,
2333
+ shipping_address: addr || undefined,
2334
+ auto_buy: auto,
2335
+ })
2336
+
2337
+ btn.disabled = false
2338
+ btn.textContent = t('开始分析')
2339
+
2340
+ if (res.error) { result.innerHTML = alert$('error', res.error); return }
2341
+
2342
+ const recColor = { buy_webaz: '#16a34a', buy_source: '#2563eb', no_match: '#6b7280' }[res.recommendation] || '#6b7280'
2343
+ const recLabel = { buy_webaz: t('✅ 推荐 WebAZ 方案'), buy_source: t('🔗 建议继续在原平台购买'), no_match: t('😕 暂未找到合适替代') }[res.recommendation] || ''
2344
+
2345
+ const bestCard = res.best_product ? `
2346
+ <div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:12px;margin:12px 0">
2347
+ <div style="font-weight:600;font-size:14px;margin-bottom:4px">${res.best_product.title}</div>
2348
+ <div style="font-size:18px;font-weight:700;color:#16a34a;margin-bottom:4px">${res.best_product.price} WAZ</div>
2349
+ <div style="font-size:12px;color:#6b7280;margin-bottom:8px">${res.best_product.agent_summary || ''}</div>
2350
+ ${!res.auto_bought ? `<button class="btn btn-primary btn-sm" style="width:auto" onclick="navigate('#order-product/${res.best_product.id}')">${t('查看并下单')}</button>` : ''}
2351
+ </div>` : ''
2352
+
2353
+ const orderCard = res.auto_bought ? `
2354
+ <div class="alert alert-success" style="margin-top:12px">
2355
+ <strong>${t('已自动下单!')}</strong> ${t('订单号')}:<a href="#order/${res.order_id}" style="color:#16a34a;font-weight:600">${res.order_id}</a><br>
2356
+ <span style="font-size:12px">${t('金额')}:${res.verified_price} WAZ ${t('(已从钱包托管)')}</span>
2357
+ </div>` : ''
2358
+
2359
+ const altList = res.webaz_products?.length > 0 ? `
2360
+ <div style="margin-top:16px">
2361
+ <div style="font-size:12px;color:#9ca3af;margin-bottom:8px">${t('WebAZ 上的相关商品')}</div>
2362
+ ${res.webaz_products.map(p => `
2363
+ <div onclick="navigate('#order-product/${p.id}')" style="background:${p.url_match ? '#f0fdf4' : '#f9fafb'};border:1px solid ${p.url_match ? '#bbf7d0' : '#f3f4f6'};border-radius:8px;padding:10px 12px;margin-bottom:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center">
2364
+ <div>
2365
+ <div style="font-size:13px;font-weight:500">${p.url_match ? '🎯 ' : ''}${p.title}</div>
2366
+ <div style="font-size:11px;color:#6b7280">${p.agent_summary || ''}${p.url_match ? ` · <span style="color:#16a34a">${t('同款商品')}</span>` : ''}</div>
2367
+ </div>
2368
+ <div style="font-weight:700;color:#1d4ed8;white-space:nowrap;margin-left:8px">${p.price} WAZ</div>
2369
+ </div>`).join('')}
2370
+ </div>` : ''
2371
+
2372
+ result.innerHTML = `
2373
+ <div class="card">
2374
+ <div style="font-size:13px;color:#6b7280;margin-bottom:4px">${t('原商品')}:${res.source.title}${res.source.price_cny ? ` · ¥${res.source.price_cny}` : ''}</div>
2375
+ <div style="font-weight:700;font-size:15px;color:${recColor};margin-bottom:8px">${recLabel}</div>
2376
+ <div style="font-size:14px;line-height:1.5;color:#374151">${res.reason}</div>
2377
+ ${res.savings_note ? `<div style="font-size:12px;color:#16a34a;margin-top:4px">💰 ${res.savings_note}</div>` : ''}
2378
+ ${bestCard}
2379
+ ${orderCard}
2380
+ ${altList}
2381
+ </div>`
2382
+ }
2383
+
2384
+ // ─── 验证任务页 ──────────────────────────────────────────────
2385
+
2386
+ async function renderVerifyTasks(app) {
2387
+ if (!state.user) { renderLogin(); return }
2388
+ app.innerHTML = shell(loading$(), 'verify-tasks')
2389
+
2390
+ const [data, statsData, claimsData] = await Promise.all([
2391
+ GET('/verify-tasks/mine'),
2392
+ GET('/verify-stats'),
2393
+ GET('/verify-tasks/my-claims'),
2394
+ ])
2395
+
2396
+ const stats = statsData || {}
2397
+ const tasks = data?.tasks || []
2398
+ const myClaims = Array.isArray(claimsData) ? claimsData : []
2399
+
2400
+ const rightsColor = (stats.verify_rights ?? 0) >= 0 ? '#10b981' : '#ef4444'
2401
+ const statBar = `
2402
+ <div class="card" style="margin-bottom:16px">
2403
+ <div style="font-weight:700;margin-bottom:12px">🛡️ ${t('我的验证状态')}</div>
2404
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;text-align:center">
2405
+ <div>
2406
+ <div style="font-size:22px;font-weight:800;color:${rightsColor}">${stats.verify_rights ?? 0}</div>
2407
+ <div style="font-size:12px;color:#6b7280">${t('验证权')}</div>
2408
+ </div>
2409
+ <div>
2410
+ <div style="font-size:22px;font-weight:800;color:#4f46e5">${stats.tasks_done ?? 0}</div>
2411
+ <div style="font-size:12px;color:#6b7280">${t('已完成')}</div>
2412
+ </div>
2413
+ <div>
2414
+ <div style="font-size:22px;font-weight:800;color:#10b981">${stats.tasks_correct ?? 0}</div>
2415
+ <div style="font-size:12px;color:#6b7280">${t('正确')}</div>
2416
+ </div>
2417
+ </div>
2418
+ ${stats.suspended_until && new Date(stats.suspended_until) > new Date() ? `
2419
+ <div style="margin-top:12px;padding:8px;background:#fef2f2;border-radius:8px;color:#ef4444;font-size:13px">
2420
+ ⚠️ ${t('验证权已暂停至')} ${new Date(stats.suspended_until).toLocaleDateString()}
2421
+ </div>` : ''}
2422
+ </div>`
2423
+
2424
+ // ── 我发起的认领任务(卖家视角)──────────────────────────────
2425
+ const claimStatusLabel = (s, result) => {
2426
+ if (s === 'code_issued') return `<span style="color:#d97706;font-weight:600">${t('待确认')}</span>`
2427
+ if (s === 'open') return `<span style="color:#4f46e5;font-weight:600">${t('验证中')}</span>`
2428
+ if (s === 'settled' && result === 'verified') return `<span style="color:#10b981;font-weight:600">✓ ${t('已通过')}</span>`
2429
+ if (s === 'settled' && result === 'failed') return `<span style="color:#ef4444;font-weight:600">✗ ${t('未通过')}</span>`
2430
+ return `<span style="color:#9ca3af">${s}</span>`
2431
+ }
2432
+
2433
+ const myClaimsSection = myClaims.length === 0 ? '' : `
2434
+ <div style="font-weight:700;margin-bottom:12px">📋 ${t('我发起的认领任务')} (${myClaims.length})</div>
2435
+ ${myClaims.map(tk => `
2436
+ <div class="card" style="margin-bottom:10px;font-size:13px">
2437
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
2438
+ <div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;margin-right:8px">${escHtml(tk.product_title)}</div>
2439
+ <div>${claimStatusLabel(tk.status, tk.result)}</div>
2440
+ </div>
2441
+ <div style="color:#6b7280;margin-bottom:4px;word-break:break-all;font-size:12px">${escHtml(tk.url)}</div>
2442
+ ${tk.status === 'code_issued' ? `
2443
+ <div style="display:flex;align-items:center;gap:8px;margin-top:6px;padding:6px 8px;background:#fffbeb;border-radius:6px">
2444
+ <span style="font-size:12px;color:#92400e">${t('验证码')}:<strong style="font-family:monospace">[${tk.code}]</strong> · ${t('请在商品编辑页确认')}</span>
2445
+ <button class="btn btn-primary btn-sm" style="width:auto;font-size:11px" onclick="navigate('#edit-product/${tk.product_id}')">${t('去确认')}</button>
2446
+ </div>` : ''}
2447
+ ${tk.status === 'open' ? `
2448
+ <div style="margin-top:6px;font-size:12px;color:#6b7280">
2449
+ ${t('验证进度')}:${tk.submissions_done}/${tk.verifiers_needed} · ${t('截止')} ${new Date(tk.expires_at).toLocaleDateString()}
2450
+ </div>` : ''}
2451
+ ${tk.status === 'settled' ? `
2452
+ <div style="margin-top:6px;font-size:12px;color:#6b7280">${t('结算于')} ${new Date(tk.settled_at).toLocaleDateString()}</div>` : ''}
2453
+ </div>`).join('')}
2454
+ <div style="margin-bottom:16px"></div>`
2455
+
2456
+ // ── 分配给我的验证任务(验证员视角)─────────────────────────
2457
+ const taskCards = tasks.length === 0
2458
+ ? `<div class="empty"><div class="empty-icon">✅</div><div class="empty-text">${t('暂无分配到你的验证任务')}</div></div>`
2459
+ : tasks.map(task => {
2460
+ const already = !!task.submitted_at
2461
+ const expired = new Date(task.expires_at) < new Date()
2462
+ return `
2463
+ <div class="card" style="margin-bottom:12px;${already ? 'opacity:0.7' : ''}">
2464
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px">
2465
+ <div style="font-weight:700;font-size:14px">🔗 ${t('链接所有权验证')}</div>
2466
+ <div style="font-size:12px;color:${expired ? '#ef4444' : '#6b7280'}">
2467
+ ${expired ? t('已过期') : t('截止') + ' ' + new Date(task.expires_at).toLocaleString()}
2468
+ </div>
2469
+ </div>
2470
+ <div style="font-size:13px;color:#6b7280;margin-bottom:4px">${t('奖励')}: <strong style="color:#10b981">${(task.reward_per_verifier || 0).toFixed(3)} WAZ</strong></div>
2471
+ <div style="font-size:13px;margin-bottom:12px;word-break:break-all">
2472
+ <a href="${escHtml(task.url)}" target="_blank" style="color:#4f46e5">${escHtml(task.url)}</a>
2473
+ </div>
2474
+ <div style="font-size:13px;color:#374151;margin-bottom:12px;background:#f9fafb;padding:10px;border-radius:8px;line-height:1.5">
2475
+ ${t('请打开上方链接,在标题或详情中找到放置的')} <strong>${t('8位验证码')}</strong>${t('(格式如 [XXXXXXXX]),将其完整填入下方。')}<br>
2476
+ <span style="color:#6b7280;font-size:12px">${t('填入内容必须与页面中看到的完全一致,包括括号。')}</span>
2477
+ </div>
2478
+ ${already ? `
2479
+ <div style="padding:10px;background:#f0fdf4;border-radius:8px;color:#10b981;font-size:13px">
2480
+ ✓ ${t('已提交,等待其他验证者完成后结算')}
2481
+ </div>` : expired ? `
2482
+ <div style="padding:10px;background:#fef2f2;border-radius:8px;color:#ef4444;font-size:13px">
2483
+ ✗ ${t('任务已过期')}
2484
+ </div>` : `
2485
+ <div style="display:flex;gap:8px;align-items:center">
2486
+ <input class="form-control" id="vtinput-${task.id}" placeholder="${t('填入看到的验证码,如 [AB3KW2QP]')}" style="flex:1;font-family:monospace">
2487
+ <button class="btn btn-primary btn-sm" style="width:auto;white-space:nowrap" onclick="doSubmitVerifyTask('${task.id}')">${t('提交')}</button>
2488
+ </div>
2489
+ <div id="vtmsg-${task.id}" style="margin-top:6px;font-size:12px;color:#6b7280"></div>`}
2490
+ </div>`
2491
+ }).join('')
2492
+
2493
+ app.innerHTML = shell(`
2494
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
2495
+ <h1 class="page-title" style="margin:0">🛡️ ${t('验证任务')}</h1>
2496
+ ${state.user?.id === 'usr_iaudit_001' ? `<button class="btn btn-outline btn-sm" onclick="navigate('#verify-admin')" style="font-size:11px">${t('管理白名单')}</button>` : ''}
2497
+ </div>
2498
+ <div style="font-size:13px;color:#6b7280;margin-bottom:16px">${t('帮助平台验证外部链接所有权,获得 WAZ 奖励')}</div>
2499
+ ${statBar}
2500
+ ${myClaimsSection}
2501
+ <div style="font-weight:700;margin-bottom:12px">${t('分配给我的任务')} (${tasks.length})</div>
2502
+ ${taskCards}
2503
+ `, 'verify-tasks')
2504
+ }
2505
+
2506
+ window.doSubmitVerifyTask = async (taskId) => {
2507
+ const inp = document.getElementById(`vtinput-${taskId}`)
2508
+ const msgEl = document.getElementById(`vtmsg-${taskId}`)
2509
+ const submission = (inp?.value || '').trim()
2510
+ if (!submission) { msgEl.textContent = t('请填入看到的验证码'); return }
2511
+ const btn = inp.nextElementSibling
2512
+ btn.disabled = true
2513
+ btn.textContent = t('提交中...')
2514
+ const r = await POST(`/verify-tasks/${taskId}/submit`, { submission })
2515
+ if (r.error) {
2516
+ msgEl.style.color = '#ef4444'
2517
+ msgEl.textContent = r.error
2518
+ btn.disabled = false
2519
+ btn.textContent = t('提交')
2520
+ } else {
2521
+ msgEl.style.color = '#10b981'
2522
+ msgEl.textContent = t('✓ 已提交,等待结算')
2523
+ btn.disabled = true
2524
+ inp.disabled = true
2525
+ }
2526
+ }
2527
+
2528
+ // ─── 验证员白名单管理页 ───────────────────────────────────────────
2529
+
2530
+ async function renderVerifyAdmin(app) {
2531
+ if (!state.user) { renderLogin(); return }
2532
+ app.innerHTML = shell(loading$(), 'verify-tasks')
2533
+
2534
+ const [auditorRes, listRes] = await Promise.all([
2535
+ GET('/admin/auditor'),
2536
+ GET('/admin/verifier-whitelist'),
2537
+ ])
2538
+
2539
+ const auditor = auditorRes?.api_key ? auditorRes : null
2540
+ const whitelist = Array.isArray(listRes) ? listRes : []
2541
+
2542
+ const auditorCard = auditor ? `
2543
+ <div class="card" style="margin-bottom:16px">
2544
+ <div style="font-weight:700;margin-bottom:12px">🔑 ${t('内部审核账号')}</div>
2545
+ <div style="font-size:13px;color:#6b7280;margin-bottom:8px">${t('将此 API Key 交给负责人工审核的人员,用于登录审核账号。')}</div>
2546
+ <div style="background:#f3f4f6;border-radius:8px;padding:12px;margin-bottom:8px">
2547
+ <div style="font-size:12px;color:#6b7280;margin-bottom:4px">${t('账号名称')}: <strong>${escHtml(auditor.name)}</strong></div>
2548
+ <div style="display:flex;align-items:center;gap:8px">
2549
+ <code id="auditor-key-display" style="font-size:12px;flex:1;word-break:break-all;filter:blur(4px)">${escHtml(auditor.api_key)}</code>
2550
+ <button class="btn btn-outline btn-sm" onclick="document.getElementById('auditor-key-display').style.filter='none';this.style.display='none'">${t('显示')}</button>
2551
+ <button class="btn btn-outline btn-sm" onclick="navigator.clipboard.writeText('${escHtml(auditor.api_key)}')">${t('复制')}</button>
2552
+ </div>
2553
+ </div>
2554
+ <div style="font-size:12px;color:#9ca3af">${t('账号 ID')}: ${escHtml(auditor.id)}</div>
2555
+ </div>` : `<div class="card">${alert$('error', '内部审核账号未初始化')}</div>`
2556
+
2557
+ const whitelistRows = whitelist.map(u => `
2558
+ <div style="display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid #f3f4f6">
2559
+ <div style="flex:1">
2560
+ <div style="font-size:14px;font-weight:600">${escHtml(u.name)}</div>
2561
+ <div style="font-size:11px;color:#9ca3af">${escHtml(u.user_id)} · ${u.role} · ${t('加入')} ${new Date(u.added_at).toLocaleDateString()}</div>
2562
+ ${u.note ? `<div style="font-size:11px;color:#6b7280">${escHtml(u.note)}</div>` : ''}
2563
+ </div>
2564
+ ${u.user_id === 'usr_iaudit_001'
2565
+ ? `<span class="badge badge-blue">${t('系统')}</span>`
2566
+ : `<button class="btn btn-sm" style="background:#fee2e2;color:#991b1b" onclick="removeFromWhitelist('${escHtml(u.user_id)}')">${t('移除')}</button>`}
2567
+ </div>`).join('')
2568
+
2569
+ app.innerHTML = shell(`
2570
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
2571
+ <button class="btn btn-gray btn-sm" onclick="navigate('#verify-tasks')">← ${t('返回')}</button>
2572
+ <h1 class="page-title" style="margin:0">⚙️ ${t('验证管理')}</h1>
2573
+ </div>
2574
+
2575
+ ${auditorCard}
2576
+
2577
+ <div class="card">
2578
+ <div style="font-weight:700;margin-bottom:12px">📋 ${t('验证员白名单')} (${whitelist.length})</div>
2579
+ <div style="font-size:13px;color:#6b7280;margin-bottom:12px">${t('只有白名单内的用户才会被分配到验证任务。')}</div>
2580
+
2581
+ ${whitelist.length ? whitelistRows : `<div style="color:#9ca3af;font-size:13px">${t('白名单为空')}</div>`}
2582
+
2583
+ <div style="margin-top:16px;border-top:1px solid #f3f4f6;padding-top:16px">
2584
+ <div style="font-weight:600;font-size:13px;margin-bottom:8px">${t('添加验证员')}</div>
2585
+ <div style="font-size:12px;color:#9ca3af;margin-bottom:8px">${t('输入用户注册名称')}</div>
2586
+ <div style="display:flex;gap:8px">
2587
+ <input class="form-control" id="wl-name-inp" placeholder="${t('用户名称')}" style="flex:1">
2588
+ <button class="btn btn-primary btn-sm" onclick="addToWhitelist()">${t('添加')}</button>
2589
+ </div>
2590
+ <div id="wl-msg" style="margin-top:8px;font-size:13px"></div>
2591
+ </div>
2592
+ </div>
2593
+ `, 'verify-tasks')
2594
+ }
2595
+
2596
+ window.addToWhitelist = async () => {
2597
+ const name = document.getElementById('wl-name-inp')?.value.trim()
2598
+ const msgEl = document.getElementById('wl-msg')
2599
+ if (!name) return void (msgEl.innerHTML = `<span style="color:#dc2626">${t('请输入用户名称')}</span>`)
2600
+ msgEl.innerHTML = `<span style="color:#6b7280">${t('添加中…')}</span>`
2601
+ const r = await POST('/admin/verifier-whitelist', { name })
2602
+ if (r.error) {
2603
+ msgEl.innerHTML = `<span style="color:#dc2626">${escHtml(r.error)}</span>`
2604
+ } else {
2605
+ msgEl.innerHTML = `<span style="color:#10b981">✓ ${escHtml(r.name)} ${t('已加入白名单')}</span>`
2606
+ setTimeout(() => renderVerifyAdmin(document.getElementById('app')), 800)
2607
+ }
2608
+ }
2609
+
2610
+ window.removeFromWhitelist = async (userId) => {
2611
+ if (!confirm(t('确认从白名单移除?'))) return
2612
+ const r = await api('DELETE', `/admin/verifier-whitelist/${userId}`)
2613
+ if (r.error) return void alert(r.error)
2614
+ renderVerifyAdmin(document.getElementById('app'))
2615
+ }
2616
+
2617
+ // ─── 链接认领认证页 ────────────────────────────────────────────
2618
+
2619
+ async function renderClaimUrl(app, encodedUrl) {
2620
+ if (!state.user) { renderLogin(); return }
2621
+ if (state.user.role !== 'seller') {
2622
+ app.innerHTML = shell(`<div class="card">${alert$('error', t('仅卖家可发起链接认领'))}</div>`, 'seller')
2623
+ return
2624
+ }
2625
+
2626
+ const url = decodeURIComponent(encodedUrl || '')
2627
+ if (!url) { navigate('#seller'); return }
2628
+
2629
+ app.innerHTML = shell(`
2630
+ <h1 class="page-title">🏴 ${t('发起链接认领认证')}</h1>
2631
+ <div class="card" style="margin-bottom:12px;background:#fffbeb;border:1px solid #f59e0b">
2632
+ <div style="font-size:13px;line-height:1.6;color:#92400e">
2633
+ <strong>${t('此链接已被其他商家认领')}</strong><br>
2634
+ ${t('您需要填写您的商品信息,并在原平台商品页面放入系统生成的验证码,经过众包验证者确认后,链接归属将转移到您的商品。')}<br>
2635
+ <div style="word-break:break-all;margin-top:6px;color:#6b7280;font-size:12px">🔗 ${escHtml(url)}</div>
2636
+ </div>
2637
+ </div>
2638
+
2639
+ <div class="card">
2640
+ <div style="font-weight:700;margin-bottom:16px">${t('填写您的商品信息')}</div>
2641
+ <div id="claim-msg"></div>
2642
+ <div class="form-group">
2643
+ <label class="form-label">${t('商品名称')} *</label>
2644
+ <input class="form-control" id="clm-title" placeholder="${t('您在此平台销售的商品名')}">
2645
+ </div>
2646
+ <div class="form-group">
2647
+ <label class="form-label">${t('商品描述')} *</label>
2648
+ <textarea class="form-control" id="clm-desc" rows="3" placeholder="${t('简要描述商品')}"></textarea>
2649
+ </div>
2650
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
2651
+ <div class="form-group">
2652
+ <label class="form-label">${t('价格 (WAZ)')} *</label>
2653
+ <input class="form-control" id="clm-price" type="number" min="0.01" step="0.01" placeholder="0.00">
2654
+ </div>
2655
+ <div class="form-group">
2656
+ <label class="form-label">${t('库存')}</label>
2657
+ <input class="form-control" id="clm-stock" type="number" min="1" value="1">
2658
+ </div>
2659
+ </div>
2660
+ <div class="form-group">
2661
+ <label class="form-label">${t('类目')}</label>
2662
+ <select class="form-control" id="clm-cat">
2663
+ <option value="">-- ${t('选择类目')} --</option>
2664
+ <option value="electronics">📱 ${t('电子产品')}</option>
2665
+ <option value="clothing">👕 ${t('服饰')}</option>
2666
+ <option value="home">🏠 ${t('家居')}</option>
2667
+ <option value="food">🍎 ${t('食品')}</option>
2668
+ <option value="books">📚 ${t('图书')}</option>
2669
+ <option value="sports">⚽ ${t('运动')}</option>
2670
+ <option value="other">📦 ${t('其他')}</option>
2671
+ </select>
2672
+ </div>
2673
+ <div style="font-size:12px;color:#6b7280;margin-bottom:16px;padding:10px;background:#f9fafb;border-radius:8px">
2674
+ 💡 ${t('提交后系统将生成一个 8 位验证码,您需要将其加入原平台商品标题或描述(如:[XXXXXXXX]),然后等待验证者确认。验证通过后链接归属自动转移。')}
2675
+ </div>
2676
+ <div class="btn-row">
2677
+ <button class="btn btn-gray" onclick="navigate('#seller')">${t('取消')}</button>
2678
+ <button class="btn btn-primary" id="clm-btn" onclick="doClaimUrl('${escHtml(url)}')">${t('提交认领申请')}</button>
2679
+ </div>
2680
+ </div>
2681
+ `, 'seller')
2682
+ }
2683
+
2684
+ window.doClaimUrl = async (url) => {
2685
+ const msgEl = document.getElementById('claim-msg')
2686
+ const btn = document.getElementById('clm-btn')
2687
+ const title = document.getElementById('clm-title').value.trim()
2688
+ const desc = document.getElementById('clm-desc').value.trim()
2689
+ const price = Number(document.getElementById('clm-price').value)
2690
+ const stock = Number(document.getElementById('clm-stock').value) || 1
2691
+ const category = document.getElementById('clm-cat').value
2692
+
2693
+ if (!title || !desc || !price) {
2694
+ msgEl.innerHTML = alert$('error', t('请填写商品名、描述和价格'))
2695
+ return
2696
+ }
2697
+
2698
+ btn.disabled = true
2699
+ btn.textContent = t('提交中...')
2700
+
2701
+ const r = await POST('/claim-url', { url, title, description: desc, price, stock, category })
2702
+
2703
+ if (r.error) {
2704
+ msgEl.innerHTML = alert$('error', r.error)
2705
+ btn.disabled = false
2706
+ btn.textContent = t('提交认领申请')
2707
+ return
2708
+ }
2709
+
2710
+ // 成功:显示验证码指引
2711
+ document.querySelector('.card:last-of-type').innerHTML = `
2712
+ <div style="text-align:center;padding:16px 0 8px">
2713
+ <div style="font-size:32px;margin-bottom:8px">✅</div>
2714
+ <div style="font-weight:700;font-size:18px;margin-bottom:4px">${t('认领任务已创建')}</div>
2715
+ <div style="font-size:13px;color:#6b7280;margin-bottom:20px">${t('商品已建立,等待验证完成后链接归属将转移到您的商品')}</div>
2716
+ </div>
2717
+ <div style="padding:16px;background:#f0f9ff;border:2px dashed #60a5fa;border-radius:10px;text-align:center;margin-bottom:16px">
2718
+ <div style="font-size:12px;color:#2563eb;margin-bottom:6px;font-weight:600">${t('请将以下验证码放入原平台商品标题或描述')}</div>
2719
+ <div style="font-size:28px;font-weight:900;letter-spacing:3px;font-family:monospace;color:#1e40af">${escHtml(r.code)}</div>
2720
+ <div style="font-size:12px;color:#6b7280;margin-top:6px">${t('格式示例')}: 【商品标题 ${escHtml(r.code)}】</div>
2721
+ <div style="font-size:12px;color:#ef4444;margin-top:4px">${t('截止')} ${new Date(r.expires_at).toLocaleString()}</div>
2722
+ </div>
2723
+ <div style="font-size:13px;color:#374151;margin-bottom:16px;line-height:1.6">
2724
+ ${escHtml(r.message)}
2725
+ </div>
2726
+ <div class="btn-row">
2727
+ <button class="btn btn-primary" onclick="navigate('#edit-product/${r.product_id}')">${t('去商品编辑页确认已添加 →')}</button>
2728
+ <button class="btn btn-outline" onclick="navigate('#seller')">${t('返回卖家后台')}</button>
2729
+ </div>`
2730
+ }
2731
+
2732
+ // ─── 钱包页 ───────────────────────────────────────────────────
2733
+
2734
+ async function renderWallet(app) {
2735
+ if (!state.user) { renderLogin(); return }
2736
+ app.innerHTML = shell(loading$(), 'wallet')
2737
+ const [wallet, rep] = await Promise.all([GET('/wallet'), GET('/reputation')])
2738
+
2739
+ const LEVEL_LABELS = { new:t('新手 🌱'), trusted:t('可信 ⭐'), quality:t('优质 🌟'), star:t('明星 💫'), legend:t('传奇 🔥') }
2740
+ const LEVEL_THRESHOLDS = { new:0, trusted:200, quality:800, star:2000, legend:5000 }
2741
+ const levelKeys = ['new','trusted','quality','star','legend']
2742
+ const curIdx = levelKeys.indexOf(rep.level?.key || 'new')
2743
+ const nextKey = levelKeys[curIdx + 1]
2744
+ const nextThreshold = LEVEL_THRESHOLDS[nextKey]
2745
+ const curPoints = rep.total_points || 0
2746
+ const progressPct = nextThreshold
2747
+ ? Math.min(100, Math.round((curPoints - LEVEL_THRESHOLDS[rep.level?.key || 'new']) / (nextThreshold - LEVEL_THRESHOLDS[rep.level?.key || 'new']) * 100))
2748
+ : 100
2749
+
2750
+ const recentHtml = (rep.recent_events || []).length > 0
2751
+ ? rep.recent_events.slice(0,5).map(e => `
2752
+ <div style="display:flex;justify-content:space-between;font-size:13px;padding:4px 0;border-bottom:1px solid #f3f4f6">
2753
+ <span style="color:#6b7280">${e.reason}</span>
2754
+ <span style="font-weight:600;color:${e.points > 0 ? '#059669' : '#dc2626'}">${e.points > 0 ? '+' : ''}${e.points}</span>
2755
+ </div>`).join('')
2756
+ : `<div style="color:#9ca3af;font-size:13px;text-align:center;padding:12px 0">${t('完成第一笔交易后开始积累声誉')}</div>`
2757
+
2758
+ app.innerHTML = shell(`
2759
+ <h1 class="page-title">${t('我的钱包')}</h1>
2760
+ <div class="card">
2761
+ <div style="text-align:center;padding:16px 0 8px">
2762
+ <div style="font-size:12px;color:#6b7280;margin-bottom:4px">${t('可用余额')}</div>
2763
+ <div style="font-size:40px;font-weight:800;color:#4f46e5">${(wallet.balance || 0).toFixed(2)}<span style="font-size:16px;font-weight:400"> WAZ</span></div>
2764
+ </div>
2765
+ <div class="divider"></div>
2766
+ <div class="wallet-grid">
2767
+ <div class="wallet-item">
2768
+ <div class="wallet-label">${t('质押中')}</div>
2769
+ <div class="wallet-value">${(wallet.staked || 0).toFixed(2)}<span class="wallet-unit"> WAZ</span></div>
2770
+ </div>
2771
+ <div class="wallet-item">
2772
+ <div class="wallet-label">${t('托管中')}</div>
2773
+ <div class="wallet-value">${(wallet.escrowed || 0).toFixed(2)}<span class="wallet-unit"> WAZ</span></div>
2774
+ </div>
2775
+ <div class="wallet-item" style="grid-column:1/-1">
2776
+ <div class="wallet-label">${t('历史累计收益')}</div>
2777
+ <div class="wallet-value">${(wallet.earned || 0).toFixed(2)}<span class="wallet-unit"> WAZ</span></div>
2778
+ </div>
2779
+ </div>
2780
+ </div>
2781
+
2782
+ <div class="card">
2783
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
2784
+ <div style="font-weight:700">${t('声誉积分')}</div>
2785
+ <div style="font-size:20px;font-weight:800;color:#4f46e5">${curPoints} ${t('分')}</div>
2786
+ </div>
2787
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
2788
+ <span style="font-size:14px;font-weight:600">${LEVEL_LABELS[rep.level?.key || 'new'] || '新手 🌱'}</span>
2789
+ ${nextKey ? `<span style="font-size:12px;color:#6b7280">${t('距')} ${LEVEL_LABELS[nextKey]} ${t('还差')} ${nextThreshold - curPoints} ${t('分')}</span>` : `<span style="font-size:12px;color:#f59e0b">${t('最高等级 🏆')}</span>`}
2790
+ </div>
2791
+ <div style="height:8px;background:#f3f4f6;border-radius:99px;overflow:hidden;margin-bottom:12px">
2792
+ <div style="height:100%;width:${progressPct}%;background:linear-gradient(90deg,#4f46e5,#7c3aed);border-radius:99px;transition:width .3s"></div>
2793
+ </div>
2794
+ <div class="wallet-grid" style="margin-bottom:12px">
2795
+ <div class="wallet-item"><div class="wallet-label">${t('成交次数')}</div><div class="wallet-value" style="font-size:18px">${rep.transactions_done || 0}</div></div>
2796
+ <div class="wallet-item"><div class="wallet-label">${t('争议胜/败')}</div><div class="wallet-value" style="font-size:18px">${rep.disputes_won || 0}/${rep.disputes_lost || 0}</div></div>
2797
+ <div class="wallet-item" style="grid-column:1/-1">
2798
+ <div class="wallet-label">${t('质押优惠')}</div>
2799
+ <div class="wallet-value" style="font-size:15px">${rep.level?.stakeDiscount > 0 ? `-${(rep.level.stakeDiscount * 100).toFixed(0)}%(当前 ${((0.15 - rep.level.stakeDiscount) * 100).toFixed(0)}%)` : t('暂无(升到可信即可享受 -5%)')}</div>
2800
+ </div>
2801
+ </div>
2802
+ <div style="font-weight:600;font-size:13px;margin-bottom:6px">${t('最近记录')}</div>
2803
+ ${recentHtml}
2804
+ </div>
2805
+
2806
+ <div class="card">
2807
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
2808
+ <div style="font-weight:700;font-size:15px">${t('充值')}</div>
2809
+ <span style="background:#dbeafe;color:#1d4ed8;font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px">Base Sepolia ${t('测试网')}</span>
2810
+ </div>
2811
+ <div style="font-size:13px;color:#6b7280;margin-bottom:10px">${t('向以下地址转入 USDC,系统确认后自动到账。')}</div>
2812
+ <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:10px 12px;margin-bottom:10px">
2813
+ <div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${t('您的充值地址')}</div>
2814
+ <div style="font-family:monospace;font-size:13px;word-break:break-all;color:#1e293b;margin-bottom:8px" id="deposit-addr">
2815
+ ${wallet.deposit_address ?? t('加载中...')}
2816
+ </div>
2817
+ <div style="display:flex;gap:8px">
2818
+ <button class="btn btn-outline btn-sm" style="width:auto;padding:4px 12px;font-size:12px"
2819
+ onclick="doCopyAddress()">📋 ${t('复制地址')}</button>
2820
+ <a href="https://sepolia.basescan.org/address/${wallet.deposit_address ?? ''}" target="_blank"
2821
+ style="display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#4f46e5;text-decoration:none;padding:4px 8px">
2822
+ 🔗 ${t('区块链浏览器')}
2823
+ </a>
2824
+ </div>
2825
+ </div>
2826
+ <div class="alert alert-info" style="font-size:12px;margin:0">
2827
+ ${t('Base Sepolia 测试网 USDC 可在 Circle Faucet 免费获取。主网上线后同一地址直接使用。')}
2828
+ </div>
2829
+ </div>
2830
+
2831
+ <div class="card">
2832
+ <div style="font-weight:700;font-size:15px;margin-bottom:12px">${t('提现')}</div>
2833
+ <div id="withdraw-msg"></div>
2834
+ <div style="margin-bottom:10px">
2835
+ <div style="font-size:12px;color:#6b7280;margin-bottom:4px">${t('收款地址(以太坊 / Base)')}</div>
2836
+ <input id="withdraw-addr" type="text" placeholder="0x..."
2837
+ style="width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;font-family:monospace" autocomplete="off">
2838
+ </div>
2839
+ <div style="margin-bottom:12px">
2840
+ <div style="font-size:12px;color:#6b7280;margin-bottom:4px">${t('提现金额(WAZ)')}</div>
2841
+ <input id="withdraw-amount" type="number" min="10" placeholder="${t('最低 10 WAZ')}"
2842
+ style="width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px">
2843
+ </div>
2844
+ <button class="btn btn-primary" onclick="doWithdraw()">${t('申请提现')}</button>
2845
+ <div style="font-size:12px;color:#9ca3af;margin-top:8px">${t('提现申请提交后 24 小时内处理并发送至链上地址。')}</div>
2846
+ </div>
2847
+
2848
+ <details style="margin-bottom:12px">
2849
+ <summary style="font-size:13px;color:#9ca3af;cursor:pointer;padding:8px 0">🧪 ${t('测试模式:模拟充值')}</summary>
2850
+ <div class="card" style="margin-top:8px">
2851
+ <div style="font-size:12px;color:#6b7280;margin-bottom:10px">${t('开发测试专用,单次最多 1000 WAZ,余额上限 5000 WAZ。')}</div>
2852
+ <div id="topup-msg"></div>
2853
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
2854
+ ${[100,300,500,1000].map(n => `
2855
+ <button class="btn btn-outline btn-sm" style="width:auto;flex:1;min-width:60px"
2856
+ onclick="doTopup(${n})">+${n}</button>`).join('')}
2857
+ </div>
2858
+ </div>
2859
+ </details>
2860
+
2861
+ <button class="btn btn-gray" onclick="doLogout()">${t('退出登录')}</button>
2862
+ `, 'wallet')
2863
+ }
2864
+
2865
+ window.doLogout = () => {
2866
+ state.apiKey = null; state.user = null
2867
+ localStorage.removeItem('webaz_key')
2868
+ navigate('#login')
2869
+ }
2870
+
2871
+ window.doCopyAddress = () => {
2872
+ const el = document.getElementById('deposit-addr')
2873
+ const addr = el?.textContent?.trim()
2874
+ if (!addr || addr === t('加载中...')) return
2875
+ navigator.clipboard.writeText(addr).then(() => {
2876
+ const btn = el.parentElement?.querySelector('button')
2877
+ if (btn) { btn.textContent = `✅ ${t('已复制')}`; setTimeout(() => { btn.textContent = `📋 ${t('复制地址')}` }, 1500) }
2878
+ })
2879
+ }
2880
+
2881
+ window.doWithdraw = async () => {
2882
+ const addr = document.getElementById('withdraw-addr')?.value?.trim()
2883
+ const amount = document.getElementById('withdraw-amount')?.value?.trim()
2884
+ const msgEl = document.getElementById('withdraw-msg')
2885
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
2886
+ const res = await POST('/wallet/withdraw', { to_address: addr, amount: Number(amount) })
2887
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
2888
+ msgEl.innerHTML = alert$('success', `✅ ${res.message}`)
2889
+ setTimeout(() => renderWallet(document.getElementById('app')), 2000)
2890
+ }
2891
+
2892
+ window.doTopup = async (amount) => {
2893
+ const msgEl = document.getElementById('topup-msg')
2894
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('充值中...')}</div>`
2895
+ const res = await POST('/wallet/topup', { amount })
2896
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
2897
+ msgEl.innerHTML = alert$('success', `✅ 已充入 ${res.added} WAZ,当前余额 ${res.new_balance.toFixed(2)} WAZ`)
2898
+ setTimeout(() => renderWallet(document.getElementById('app')), 1200)
2899
+ }
2900
+
2901
+ // ─── SSE 实时通知 ─────────────────────────────────────────────
2902
+
2903
+ function connectSSE() {
2904
+ if (!state.apiKey || state.sse) return
2905
+ // EventSource 不支持自定义 header,通过 URL 参数传 key
2906
+ state.sse = new EventSource(`/api/notifications/stream?key=${state.apiKey}`)
2907
+
2908
+ state.sse.onmessage = (e) => {
2909
+ const data = JSON.parse(e.data)
2910
+ if (data.type === 'init') {
2911
+ updateBadge(data.unread)
2912
+ } else {
2913
+ // 实时推送:更新角标 + 显示 toast
2914
+ state.unread++
2915
+ updateBadge(state.unread)
2916
+ showToast(data.title, data.body)
2917
+ }
2918
+ }
2919
+ state.sse.onerror = () => {
2920
+ state.sse?.close(); state.sse = null
2921
+ // 5秒后重连
2922
+ setTimeout(connectSSE, 5000)
2923
+ }
2924
+ }
2925
+
2926
+ function disconnectSSE() {
2927
+ state.sse?.close(); state.sse = null
2928
+ }
2929
+
2930
+ function updateBadge(count) {
2931
+ state.unread = count
2932
+ // 更新 tab bar 角标
2933
+ const badge = document.getElementById('notif-badge')
2934
+ if (badge) {
2935
+ badge.textContent = count > 0 ? count : ''
2936
+ badge.style.display = count > 0 ? 'inline' : 'none'
2937
+ }
2938
+ }
2939
+
2940
+ let toastTimer = null
2941
+ function showToast(title, body) {
2942
+ let toast = document.getElementById('toast')
2943
+ if (!toast) {
2944
+ toast = document.createElement('div')
2945
+ toast.id = 'toast'
2946
+ toast.style.cssText = `position:fixed;bottom:76px;left:16px;right:16px;background:#1e1b4b;color:#fff;border-radius:12px;padding:12px 16px;font-size:14px;z-index:999;box-shadow:0 4px 20px rgba(0,0,0,.3);cursor:pointer`
2947
+ toast.onclick = () => navigate('#notifications')
2948
+ document.body.appendChild(toast)
2949
+ }
2950
+ toast.innerHTML = `<div style="font-weight:700;margin-bottom:3px">${title}</div><div style="opacity:.85;font-size:13px">${body}</div>`
2951
+ toast.style.opacity = '1'
2952
+ toast.style.transform = 'translateY(0)'
2953
+ clearTimeout(toastTimer)
2954
+ toastTimer = setTimeout(() => { toast.style.opacity = '0' }, 4000)
2955
+ }
2956
+
2957
+ // ─── 通知列表页 ───────────────────────────────────────────────
2958
+
2959
+ async function renderNotifications(app) {
2960
+ if (!state.user) { renderLogin(); return }
2961
+ app.innerHTML = shell(loading$(), 'notifications')
2962
+
2963
+ await POST('/notifications/read', {}) // 全部标为已读
2964
+ updateBadge(0)
2965
+
2966
+ const data = await GET('/notifications')
2967
+ const list = (data.notifications || [])
2968
+ const html = list.length === 0
2969
+ ? `<div class="empty"><div class="empty-icon">🔔</div><div class="empty-text">${t('暂无通知')}</div></div>`
2970
+ : list.map(n => `
2971
+ <div class="card" ${n.order_id ? `onclick="navigate('#order/${n.order_id}')" style="cursor:pointer"` : ''}>
2972
+ <div style="display:flex;gap:12px;align-items:flex-start">
2973
+ <div style="font-size:24px;line-height:1;flex-shrink:0">${n.title.slice(0,2)}</div>
2974
+ <div style="flex:1;min-width:0">
2975
+ <div style="font-weight:600;font-size:14px">${n.title.slice(2)}</div>
2976
+ <div style="font-size:13px;color:#6b7280;margin-top:3px">${n.body}</div>
2977
+ <div style="font-size:11px;color:#d1d5db;margin-top:4px">${fmtTime(n.created_at)}</div>
2978
+ </div>
2979
+ </div>
2980
+ </div>`).join('')
2981
+
2982
+ app.innerHTML = shell(`<h1 class="page-title">${t('通知')}</h1>${html}`, 'notifications')
2983
+ }
2984
+
2985
+ // ─── Skill 市场页 ─────────────────────────────────────────────
2986
+
2987
+ async function renderSkills(app) {
2988
+ app.innerHTML = shell(loading$(), 'shop')
2989
+
2990
+ const skills = await GET('/skills')
2991
+ const isBuyer = state.user?.role === 'buyer'
2992
+
2993
+ const typeIcons = { catalog_sync:'🔄', auto_accept:'⚡', price_negotiation:'🤝', quality_guarantee:'🛡️', instant_ship:'🚀' }
2994
+ const typeLabels = { catalog_sync:'目录同步', auto_accept:'自动接单', price_negotiation:'价格协商', quality_guarantee:'质量承诺', instant_ship:'极速发货' }
2995
+
2996
+ const groups = {}
2997
+ for (const s of skills) {
2998
+ if (!groups[s.skill_type]) groups[s.skill_type] = []
2999
+ groups[s.skill_type].push(s)
3000
+ }
3001
+
3002
+ let html = ''
3003
+ if (skills.length === 0) {
3004
+ html = `<div class="empty"><div class="empty-icon">⚡</div><div class="empty-text">还没有 Skill,卖家可以去后台发布</div></div>`
3005
+ } else {
3006
+ for (const [type, items] of Object.entries(groups)) {
3007
+ const icon = typeIcons[type] || '⚙️'
3008
+ const label = typeLabels[type] || type
3009
+ html += `<div style="font-weight:700;margin:16px 0 8px">${icon} ${label}</div>`
3010
+ html += items.map(s => skillCard(s, 'buyer')).join('')
3011
+ }
3012
+ }
3013
+
3014
+ app.innerHTML = shell(`
3015
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
3016
+ <button class="btn btn-gray btn-sm" style="width:auto" onclick="history.back()">←</button>
3017
+ <h1 class="page-title" style="margin:0">⚡ Skill 市场</h1>
3018
+ </div>
3019
+ ${isBuyer ? `
3020
+ <div class="alert alert-info" style="font-size:13px">
3021
+ 订阅卖家发布的 Skill,即可享受自动接单、优先推荐、价格协商等特权。
3022
+ </div>` : !state.user ? `
3023
+ <div class="alert alert-info" style="font-size:13px">
3024
+ <a href="#login" style="color:inherit;font-weight:700">登录</a>后即可订阅 Skill
3025
+ </div>` : ''}
3026
+ <div id="skill-sub-msg"></div>
3027
+ ${html}
3028
+ `, 'shop')
3029
+ }
3030
+
3031
+ window.toggleSubscribeSkill = async (skillId, currentlySubscribed) => {
3032
+ const msgEl = document.getElementById('skill-sub-msg')
3033
+ if (currentlySubscribed) {
3034
+ await fetch(`/api/skills/${skillId}/subscribe`, { method: 'DELETE', headers: { Authorization: `Bearer ${state.apiKey}` } })
3035
+ msgEl.innerHTML = alert$('info', '已取消订阅')
3036
+ } else {
3037
+ const res = await POST(`/skills/${skillId}/subscribe`, {})
3038
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
3039
+ msgEl.innerHTML = alert$('success', '✅ 订阅成功!你将优先看到此卖家商品')
3040
+ }
3041
+ setTimeout(() => renderSkills(document.getElementById('app')), 800)
3042
+ }
3043
+
3044
+ // ─── 启动 ─────────────────────────────────────────────────────
3045
+
3046
+ if ('serviceWorker' in navigator) {
3047
+ navigator.serviceWorker.register('/sw.js').catch(() => {})
3048
+ }
3049
+
3050
+ // 初始路由
3051
+ route()
3052
+
3053
+ // hash 变化时补充处理 order-product
3054
+ const _origHashChange = window.onhashchange
3055
+ window.onhashchange = () => {
3056
+ const hash = location.hash.slice(1)
3057
+ if (hash.startsWith('order-product/')) {
3058
+ renderBuyPage(document.getElementById('app'), hash.split('/')[1])
3059
+ } else {
3060
+ route()
3061
+ }
3062
+ }