@seasonkoh/webaz 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +60 -5
  2. package/dist/layer0-foundation/L0-2-state-machine/engine.js +3 -0
  3. package/dist/layer1-agent/L1-1-mcp-server/server.js +899 -720
  4. package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +287 -0
  5. package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +102 -0
  6. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +180 -0
  7. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +16 -0
  8. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +1 -0
  9. package/dist/mcp.js +7 -3
  10. package/dist/pwa/data/onboarding-cases.js +345 -0
  11. package/dist/pwa/data/onboarding-quiz.js +247 -0
  12. package/dist/pwa/public/app.js +1459 -96
  13. package/dist/pwa/public/i18n.js +303 -2
  14. package/dist/pwa/public/icon-192.png +0 -0
  15. package/dist/pwa/public/icon-512.png +0 -0
  16. package/dist/pwa/public/manifest.json +5 -2
  17. package/dist/pwa/public/openapi.json +1 -1
  18. package/dist/pwa/public/sw.js +1 -1
  19. package/dist/pwa/routes/admin-protocol-params.js +80 -2
  20. package/dist/pwa/routes/admin-reports.js +14 -9
  21. package/dist/pwa/routes/auth-read.js +3 -1
  22. package/dist/pwa/routes/build-feedback.js +82 -0
  23. package/dist/pwa/routes/build-reputation.js +10 -0
  24. package/dist/pwa/routes/build-tasks.js +73 -0
  25. package/dist/pwa/routes/disputes-write.js +149 -1
  26. package/dist/pwa/routes/governance-auto-deactivate.js +108 -0
  27. package/dist/pwa/routes/governance-onboarding.js +785 -0
  28. package/dist/pwa/routes/leaderboard.js +10 -2
  29. package/dist/pwa/routes/orders-action.js +5 -1
  30. package/dist/pwa/routes/products-meta.js +30 -0
  31. package/dist/pwa/routes/profile-identity.js +1 -1
  32. package/dist/pwa/routes/public-utils.js +44 -0
  33. package/dist/pwa/routes/rewards-apply.js +210 -0
  34. package/dist/pwa/routes/rewards-auto-downgrade.js +65 -0
  35. package/dist/pwa/routes/rewards-escrow-expire.js +48 -0
  36. package/dist/pwa/routes/wallet-write.js +17 -31
  37. package/dist/pwa/routes/webauthn.js +1 -1
  38. package/dist/pwa/server.js +641 -64
  39. package/package.json +6 -3
@@ -548,8 +548,8 @@ async function render(page, params) {
548
548
  // 只在首次(没有 sponsor_name 时)阻塞等 fetch;后续重渲染走缓存秒过
549
549
  try { await initShareCtx() } catch (e) { console.warn('[ShareCtx]', e) }
550
550
 
551
- // 未登录时只允许看登录页、找回密钥页、商品、welcome 预发布页
552
- if (!state.apiKey && page !== 'login' && page !== 'shop' && page !== 'recover' && page !== 'welcome' && page !== '') {
551
+ // 未登录时只允许看登录页、找回密钥页、商品、welcome 预发布页、governance-onboarding 公开页
552
+ if (!state.apiKey && page !== 'login' && page !== 'shop' && page !== 'recover' && page !== 'welcome' && page !== 'governance-onboarding' && page !== '') {
553
553
  // 保存目标 hash 以便登录/注册后跳回
554
554
  if (location.hash && !['#login', '#recover'].includes(location.hash)) {
555
555
  sessionStorage.setItem('webaz_intended_hash', location.hash)
@@ -630,6 +630,8 @@ async function render(page, params) {
630
630
  case 'seller-trials': return renderSellerTrials(app)
631
631
  // 2026-05-24 #991:/welcome 预发布页 — 6 区块协议介绍
632
632
  case 'welcome': return renderWelcome(app)
633
+ // 2026-06-02 W3.5-B:#governance-onboarding 治理岗位申请页(公开页)
634
+ case 'governance-onboarding': return renderGovernanceOnboarding(app)
633
635
  case 'seller':
634
636
  if (state.user?.role === 'logistics') return renderLogistics(app)
635
637
  if (state.user?.role === 'arbitrator') return renderDisputeList(app)
@@ -714,9 +716,15 @@ async function render(page, params) {
714
716
  if (params[0] === 'kyc') return renderAdminKyc(app)
715
717
  if (params[0] === 'export') return renderAdminExport(app)
716
718
  if (params[0] === 'errors') return renderAdminErrors(app)
719
+ if (params[0] === 'governance') return renderAdminGovernance(app)
717
720
  return renderAdminDashboard(app)
718
721
  case 'apply-verifier': return renderApplyVerifier(app)
719
722
  case 'apply-arbitrator': return renderApplyArbitrator(app)
723
+ case 'apply-rewards': return renderApplyRewards(app)
724
+ case 'rewards-me': return renderRewardsMe(app)
725
+ case 'onboarding-verifier': return renderOnboarding(app, 'verifier')
726
+ case 'onboarding-arbitrator': return renderOnboarding(app, 'arbitrator')
727
+ case 'governance-me': return renderGovernanceMe(app)
720
728
  case 'wishlist': return renderWishlist(app)
721
729
  case 'waitlist': return renderWaitlist(app)
722
730
  case 'returns': return renderReturnsCenter(app)
@@ -727,6 +735,8 @@ async function render(page, params) {
727
735
  case 'blocklist': return renderBlocklist(app)
728
736
  case 'kyc': return renderKyc(app)
729
737
  case 'feedback': return renderFeedback(app)
738
+ case 'build-feedback': return renderMyBuildFeedback(app)
739
+ case 'my-contributions': return renderMyContributions(app)
730
740
  case 'admin-feedback': return renderAdminFeedback(app)
731
741
  case 'admin-payments': return renderAdminPayments(app)
732
742
  case 'my-agents': return renderMyAgents(app)
@@ -991,7 +1001,7 @@ function preLaunchBannerHTML() {
991
1001
  if (window._protocolPhase && window._protocolPhase !== 'pre_launch') return ''
992
1002
  return `<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;font-size:12px;color:#92400e;text-align:center;line-height:1.5;margin:8px 12px 12px">
993
1003
  ⚠️ <strong>${t('协议尚未公开上线 · 数据为测试 / demo · 请勿据此投资或承诺第三方')}</strong>
994
- <a href="/api/protocol-status" target="_blank" rel="noopener" style="margin-left:8px;color:#7c2d12;text-decoration:underline;font-weight:600">${t('详情')}</a>
1004
+ <a href="#welcome" style="margin-left:8px;color:#7c2d12;text-decoration:underline;font-weight:600">${t('详情')}</a>
995
1005
  </div>`
996
1006
  }
997
1007
 
@@ -1129,6 +1139,12 @@ function shell(content, activeTab, opts) {
1129
1139
  </div>
1130
1140
  </div>
1131
1141
  ` : ''}
1142
+ ${state.user ? `
1143
+ <button id="feedback-fab" onclick="openBuildFeedback()" title="${t('反馈 / 建议')}"
1144
+ style="position:fixed;bottom:74px;left:14px;width:48px;height:48px;border-radius:50%;background:#fff;color:#4f46e5;border:1px solid #c7d2fe;cursor:pointer;font-size:22px;box-shadow:0 4px 12px rgba(0,0,0,0.12);z-index:99;display:flex;align-items:center;justify-content:center" aria-label="${t('反馈 / 建议')}">
1145
+ 💬
1146
+ </button>
1147
+ ` : ''}
1132
1148
  ${_opts.bottomBar ? `<div class="page-bottom-bar" style="position:fixed;bottom:0;left:0;right:0;z-index:50;background:#fff;border-top:1px solid #e5e7eb;padding:10px 14px;box-shadow:0 -2px 12px rgba(0,0,0,0.04);padding-bottom:max(10px, env(safe-area-inset-bottom))">${_opts.bottomBar}</div>` : ''}
1133
1149
  ${_opts.hideTabbar ? '' : `<nav class="tabbar">
1134
1150
  ${tabs.map(tb => `
@@ -1179,7 +1195,7 @@ window.openAvatarMenu = function() {
1179
1195
  ${item('🏠', t('我的主页'), '#me')}
1180
1196
  ${item('⚙️', t('设置 / 角色'), '#me/settings')}
1181
1197
  ${!isTrusted ? item('👁', t('公开主页'), '#u/' + u.id) : ''}
1182
- ${item('🤖', t('我的 Agents'), '#my-agents')}
1198
+ ${item('🤖', t('我的 agents'), '#my-agents')}
1183
1199
 
1184
1200
  ${sectionTitle(t('协议'))}
1185
1201
  ${item('🏛', t('协议治理'), '#governance')}
@@ -1266,7 +1282,7 @@ async function renderMyAdvanced(app) {
1266
1282
 
1267
1283
  <div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">🤖 ${t('Agent 治理')}</div>
1268
1284
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
1269
- ${card('🤖', t('我的 Agents'), t('谁替我做事 · 撤销控制'), '#my-agents')}
1285
+ ${card('🤖', t('我的 agents'), t('谁替我做事 · 撤销控制'), '#my-agents')}
1270
1286
  ${card('⚡', t('卖家自动化'), skillCount > 0 ? skillCount + ' ' + t('个') : t('未发布'), '#skills')}
1271
1287
  ${!isTrusted ? card('🪄', t('AI 推荐'), t('给我推商品'), '#ai-recommend') : ''}
1272
1288
  ${role === 'seller' ? card('🎯', t('Auto-bid'), t('RFQ 自动报价'), '#auto-bid') : ''}
@@ -1288,7 +1304,18 @@ async function renderMyAdvanced(app) {
1288
1304
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
1289
1305
  ${card('🏛', t('协议治理'), t('参数公开 · 变更可追溯'), '#governance')}
1290
1306
  ${card('⚖', t('判例库'), t('争议判决公开'), '#judgments')}
1307
+ ${card('🎁', t('共建身份'), t('申请制 · 含锁仓金'), '#rewards-me')}
1291
1308
  </div>
1309
+
1310
+ <div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">📧 ${t('联系我们')}</div>
1311
+ <a href="mailto:contact@webaz.xyz" class="card" style="padding:14px;display:flex;align-items:center;gap:10px;min-height:64px;text-decoration:none;color:inherit">
1312
+ <div style="font-size:24px;flex-shrink:0">📧</div>
1313
+ <div style="flex:1;min-width:0">
1314
+ <div style="font-weight:600;font-size:14px">contact@webaz.xyz</div>
1315
+ <div style="font-size:11px;color:#9ca3af;margin-top:2px">${t('合作 / 反馈 / 合规咨询')}</div>
1316
+ </div>
1317
+ <div style="color:#9ca3af">›</div>
1318
+ </a>
1292
1319
  `
1293
1320
  app.innerHTML = shell(sections, 'me')
1294
1321
  }
@@ -1685,7 +1712,7 @@ async function renderProfile(app) {
1685
1712
  </div>
1686
1713
  <div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
1687
1714
  <span style="color:#374151">${t('协议')}</span>
1688
- <a href="https://github.com/anthropics/webaz" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px">${t('开源仓库')} ↗</a>
1715
+ <a href="https://github.com/seasonsagents-art/webaz" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px">${t('源码仓库')} ↗</a>
1689
1716
  </div>
1690
1717
  <div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
1691
1718
  <span style="color:#374151">🔔 ${t('推送通知')}</span>
@@ -4193,75 +4220,1078 @@ window.doAdminTaskFilter = () => {
4193
4220
  }
4194
4221
 
4195
4222
  // ─── 用户:申请审核员 ──────────────────────────────────────────
4196
- async function renderApplyVerifier(app) {
4223
+ // ─── 治理岗位申请(W3.5-B governance onboarding,#1093 阶段 1b) ─────
4224
+ // docs/GOVERNANCE-ONBOARDING.md §3.1 流程图前端:
4225
+ // eligibility 检查 + 顶部红色 disclosure + 8s 延迟双勾选 + Passkey 签发 + POST /governance/onboarding/apply
4226
+ async function _renderApplyGovernance(app, role) {
4197
4227
  if (!state.user) { renderLogin(); return }
4198
- app.innerHTML = shell(loading$(), 'verify-tasks')
4199
- const data = await GET('/verifier/eligibility')
4200
- if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'verify-tasks'); return }
4228
+ const returnNav = role === 'arbitrator' ? 'me' : 'verify-tasks'
4229
+ const roleTitle = role === 'arbitrator' ? t('申请仲裁员') : t('申请审核员')
4230
+ const roleIcon = role === 'arbitrator' ? '' : '🛡'
4231
+
4232
+ app.innerHTML = shell(loading$(), returnNav)
4233
+ const data = await GET('/' + role + '/eligibility')
4234
+ if (data.error) { app.innerHTML = shell(alert$('error', data.error), returnNav); return }
4235
+
4201
4236
  const checklist = (data.items || []).map(i => `
4202
4237
  <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
4203
4238
  <span style="${i.ok ? 'color:#16a34a' : 'color:#dc2626'}">${i.ok ? '✓' : '✗'} ${i.label}</span>
4204
4239
  <span style="color:#6b7280">${i.current} / ${i.required}</span>
4205
4240
  </div>`).join('')
4206
4241
  const canApply = data.eligible
4242
+ const pageLoadedAt = Date.now()
4243
+
4207
4244
  app.innerHTML = shell(`
4208
- <h1 class="page-title">🛡 ${t('申请审核员')}</h1>
4245
+ <h1 class="page-title">${roleIcon} ${roleTitle}</h1>
4246
+
4247
+ <div class="card" style="margin-bottom:16px;background:#fef2f2;border-color:#fca5a5">
4248
+ <div style="font-weight:600;color:#991b1b;margin-bottom:6px;font-size:14px">⚠️ ${t('本流程是治理岗位申请,不是赚钱机会')}</div>
4249
+ <div style="font-size:12px;color:#7f1d1d;line-height:1.6">
4250
+ ${t('phase A 不发放任何现金 / WAZ 报酬。治理是公共贡献。详 docs/GOVERNANCE-ONBOARDING.md。')}<br>
4251
+ <span style="opacity:0.85">This is a governance application, not an income opportunity. Phase A pays NO cash / WAZ.</span>
4252
+ </div>
4253
+ </div>
4254
+
4209
4255
  <div class="card" style="margin-bottom:16px">
4210
- <div style="font-size:13px;color:#6b7280;margin-bottom:10px">${t('需通过下列信誉指标,由管理员审核后入试用期')}</div>
4256
+ <div style="font-size:13px;color:#6b7280;margin-bottom:10px">${t('门槛(全部通过才能申请)')}:</div>
4211
4257
  ${checklist}
4212
4258
  </div>
4213
- ${canApply
4214
- ? `<button class="btn btn-primary" onclick="doApplyVerifier()">${t('提交申请')}</button>`
4215
- : `<button class="btn btn-primary" disabled style="opacity:0.5">${t('提交申请')}</button>
4216
- <div style="margin-top:8px;font-size:12px;color:#6b7280">${t('请先完成上述未达标指标')}</div>`}
4217
- <div id="apply-msg" style="margin-top:12px"></div>
4218
- <div style="margin-top:16px"><a href="#verify-tasks" style="font-size:13px;color:#6b7280">← ${t('返回审核任务')}</a></div>
4219
- `, 'verify-tasks')
4220
- }
4221
4259
 
4222
- window.doApplyVerifier = async () => {
4223
- const msg = document.getElementById('apply-msg')
4224
- msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
4225
- const res = await POST('/verifier/apply', {})
4226
- if (res.error) { msg.innerHTML = alert$('error', res.error); return }
4227
- msg.innerHTML = alert$('success', t('申请已提交,等待管理员审核'))
4228
- setTimeout(() => navigate('#verify-tasks'), 1500)
4260
+ ${!canApply ? `
4261
+ <div class="card" style="background:#fff7ed;border-color:#fdba74;padding:12px;font-size:13px;color:#7c2d12">
4262
+ ${t('请先完成上述未达标指标,完成后再来申请')}
4263
+ </div>
4264
+ <div style="margin-top:16px"><a href="#${returnNav}" style="font-size:13px;color:#6b7280">← ${t('返回')}</a></div>
4265
+ ` : `
4266
+ <div class="card" style="margin-bottom:16px">
4267
+ <div style="font-size:13px;color:#374151;margin-bottom:12px;font-weight:600">${t('知情同意(双勾选)')}</div>
4268
+ <label style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;cursor:pointer">
4269
+ <input type="checkbox" id="gov-consent-1" onchange="updateGovApplyState()">
4270
+ <span style="font-size:13px;color:#374151">${t('我已阅读 GOVERNANCE-ONBOARDING / ARBITRATION-PLAYBOOK / META-RULES / CHARTER,理解角色责任')}</span>
4271
+ </label>
4272
+ <label id="gov-consent-2-wrap" style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;cursor:pointer;opacity:0.5">
4273
+ <input type="checkbox" id="gov-consent-2" disabled onchange="updateGovApplyState()">
4274
+ <span style="font-size:13px;color:#374151">${t('我自愿申请,无人收买 / 诱导')} <span id="gov-countdown" style="color:#dc2626;font-weight:600"></span></span>
4275
+ </label>
4276
+ </div>
4277
+
4278
+ <button class="btn btn-primary" id="gov-submit-btn" onclick="doSubmitGovernanceApply('${role}', ${pageLoadedAt})" disabled style="opacity:0.5">
4279
+ 🔑 ${t('用 Passkey 签发提交申请')}
4280
+ </button>
4281
+
4282
+ <div id="gov-apply-msg" style="margin-top:12px"></div>
4283
+ <div style="margin-top:16px"><a href="#${returnNav}" style="font-size:13px;color:#6b7280">← ${t('返回')}</a></div>
4284
+ `}
4285
+ `, returnNav)
4286
+
4287
+ if (canApply) {
4288
+ let secondsLeft = 8
4289
+ const tick = () => {
4290
+ const el = document.getElementById('gov-countdown')
4291
+ if (!el) return
4292
+ if (secondsLeft > 0) {
4293
+ el.textContent = '(' + t('等') + ' ' + secondsLeft + 's)'
4294
+ secondsLeft--
4295
+ setTimeout(tick, 1000)
4296
+ } else {
4297
+ el.textContent = ''
4298
+ const cb2 = document.getElementById('gov-consent-2')
4299
+ if (cb2) cb2.disabled = false
4300
+ const wrap = document.getElementById('gov-consent-2-wrap')
4301
+ if (wrap) wrap.style.opacity = '1'
4302
+ }
4303
+ }
4304
+ tick()
4305
+ }
4229
4306
  }
4230
4307
 
4231
- // ─── 外部仲裁员申请(与 verifier 平行)────────────────────────
4232
- async function renderApplyArbitrator(app) {
4308
+ async function renderApplyRewards(app) {
4233
4309
  if (!state.user) { renderLogin(); return }
4234
- app.innerHTML = shell(loading$(), 'me')
4235
- const data = await GET('/arbitrator/eligibility')
4236
- if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'me'); return }
4237
- const checklist = (data.items || []).map(i => `
4310
+ const returnNav = 'rewards-me'
4311
+
4312
+ app.innerHTML = shell(loading$(), returnNav)
4313
+ const status = await GET('/rewards/status')
4314
+ if (status.error) { app.innerHTML = shell(alert$('error', status.error), returnNav); return }
4315
+
4316
+ if (status.opted_in) {
4317
+ app.innerHTML = shell(`
4318
+ <h1 class="page-title">${t('已是共建身份')}</h1>
4319
+ <div class="card" style="background:#ecfdf5;border-color:#86efac;padding:12px;font-size:13px;color:#064e3b">
4320
+ ${t('你已 opted-in。如需查看状态或退出,前往 ')}
4321
+ <a href="#rewards-me" style="color:#16a34a;font-weight:600">${t('共建身份管理')}</a>
4322
+ </div>
4323
+ `, returnNav)
4324
+ return
4325
+ }
4326
+
4327
+ const eli = status.eligibility || {}
4328
+ const canApply = !!eli.can_apply
4329
+ const consentText = status.consent_text_zh || ''
4330
+ const consentTextEn = status.consent_text_en || ''
4331
+ const consentVersion = status.consent_version || ''
4332
+ const delaySec = Number(eli.consent_delay_seconds || 8)
4333
+ const pageLoadedAt = Date.now()
4334
+
4335
+ const checklist = `
4238
4336
  <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
4239
- <span style="${i.ok ? 'color:#16a34a' : 'color:#dc2626'}">${i.ok ? '✓' : '✗'} ${i.label}</span>
4240
- <span style="color:#6b7280">${i.current} / ${i.required}</span>
4241
- </div>`).join('')
4242
- const canApply = data.eligible
4337
+ <span style="${eli.completed_orders >= eli.min_completed_orders ? 'color:#16a34a' : 'color:#dc2626'}">
4338
+ ${eli.completed_orders >= eli.min_completed_orders ? '✓' : '✗'} ${t('已完成订单')}
4339
+ </span>
4340
+ <span style="color:#6b7280">${eli.completed_orders} / ${eli.min_completed_orders}</span>
4341
+ </div>
4342
+ <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
4343
+ <span style="${!eli.require_passkey || eli.passkey_count > 0 ? 'color:#16a34a' : 'color:#dc2626'}">
4344
+ ${!eli.require_passkey || eli.passkey_count > 0 ? '✓' : '✗'} ${t('Passkey 已注册')}
4345
+ </span>
4346
+ <span style="color:#6b7280">${eli.passkey_count} ${eli.require_passkey ? `(${t('必需')})` : `(${t('可选')})`}</span>
4347
+ </div>`
4348
+
4243
4349
  app.innerHTML = shell(`
4244
- <h1 class="page-title">⚖ ${t('申请仲裁员')}</h1>
4350
+ <h1 class="page-title">🎁 ${t('申请共建身份')}</h1>
4351
+
4352
+ <div class="card" style="margin-bottom:16px;background:#fef2f2;border-color:#fca5a5">
4353
+ <div style="font-weight:600;color:#991b1b;margin-bottom:6px;font-size:14px">⚠️ ${t('本流程与购物无关')}</div>
4354
+ <div style="font-size:12px;color:#7f1d1d;line-height:1.6">
4355
+ ${t('你可以随时退出,不影响任何已下单或未来订单。本流程涉及经济关系登记(三级佣金 + 双轨配对),请仔细阅读全部条款。')}<br>
4356
+ <span style="opacity:0.85">This flow is not part of shopping. You can leave anytime without affecting any orders. This is an economic-relationship registration — please read all terms.</span>
4357
+ </div>
4358
+ </div>
4359
+
4245
4360
  <div class="card" style="margin-bottom:16px">
4246
- <div style="font-size:13px;color:#6b7280;margin-bottom:10px">${t('仲裁员门槛比审核员更高 — 管理员审核后正式生效')}</div>
4361
+ <div style="font-size:13px;color:#6b7280;margin-bottom:10px">${t('门槛(全部通过才能申请)')}:</div>
4247
4362
  ${checklist}
4248
4363
  </div>
4249
- ${canApply
4250
- ? `<button class="btn btn-primary" onclick="doApplyArbitrator()">${t('提交申请')}</button>`
4251
- : `<button class="btn btn-primary" disabled style="opacity:0.5">${t('提交申请')}</button>
4252
- <div style="margin-top:8px;font-size:12px;color:#6b7280">${t('请先完成上述未达标指标')}</div>`}
4253
- <div id="apply-arb-msg" style="margin-top:12px"></div>
4254
- <div style="margin-top:16px"><a href="#me" style="font-size:13px;color:#6b7280">← ${t('返回我的')}</a></div>
4364
+
4365
+ <div class="card" style="margin-bottom:16px;background:#fafafa">
4366
+ <div style="font-size:12px;color:#6b7280;margin-bottom:6px">${t('同意文本')} (v${consentVersion}):</div>
4367
+ <div style="font-size:13px;color:#374151;line-height:1.7;margin-bottom:8px">${escHtml(consentText)}</div>
4368
+ <div style="font-size:12px;color:#6b7280;line-height:1.7;font-style:italic">${escHtml(consentTextEn)}</div>
4369
+ </div>
4370
+
4371
+ ${!canApply ? `
4372
+ <div class="card" style="background:#fff7ed;border-color:#fdba74;padding:12px;font-size:13px;color:#7c2d12">
4373
+ ${t('请先完成上述未达标指标,完成后再来申请')}
4374
+ </div>
4375
+ <div style="margin-top:16px"><a href="#${returnNav}" style="font-size:13px;color:#6b7280">← ${t('返回')}</a></div>
4376
+ ` : `
4377
+ <div class="card" style="margin-bottom:16px">
4378
+ <div style="font-size:13px;color:#374151;margin-bottom:12px;font-weight:600">${t('知情同意(双勾选)')}</div>
4379
+ <label style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;cursor:pointer">
4380
+ <input type="checkbox" id="rwd-consent-1" onchange="updateRwdApplyState()">
4381
+ <span style="font-size:13px;color:#374151">${t('我已阅读全部条款,理解 commission 与 PV 树结构的合规边界')}</span>
4382
+ </label>
4383
+ <label id="rwd-consent-2-wrap" style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;cursor:pointer;opacity:0.5">
4384
+ <input type="checkbox" id="rwd-consent-2" disabled onchange="updateRwdApplyState()">
4385
+ <span style="font-size:13px;color:#374151">${t('我自愿申请,无人收买 / 诱导')} <span id="rwd-countdown" style="color:#dc2626;font-weight:600"></span></span>
4386
+ </label>
4387
+ </div>
4388
+
4389
+ <button class="btn btn-primary" id="rwd-submit-btn" onclick="doSubmitRewardsApply('${consentVersion}', ${pageLoadedAt})" disabled style="opacity:0.5">
4390
+ 🔑 ${t('用 Passkey 签发提交申请')}
4391
+ </button>
4392
+
4393
+ <div id="rwd-apply-msg" style="margin-top:12px"></div>
4394
+ <div style="margin-top:16px"><a href="#${returnNav}" style="font-size:13px;color:#6b7280">← ${t('返回')}</a></div>
4395
+ `}
4396
+ `, returnNav)
4397
+
4398
+ if (canApply) {
4399
+ let secondsLeft = delaySec
4400
+ const tick = () => {
4401
+ const el = document.getElementById('rwd-countdown')
4402
+ if (!el) return
4403
+ if (secondsLeft > 0) {
4404
+ el.textContent = '(' + t('等') + ' ' + secondsLeft + 's)'
4405
+ secondsLeft--
4406
+ setTimeout(tick, 1000)
4407
+ } else {
4408
+ el.textContent = ''
4409
+ const cb2 = document.getElementById('rwd-consent-2')
4410
+ if (cb2) cb2.disabled = false
4411
+ const wrap = document.getElementById('rwd-consent-2-wrap')
4412
+ if (wrap) wrap.style.opacity = '1'
4413
+ }
4414
+ }
4415
+ tick()
4416
+ }
4417
+ }
4418
+
4419
+ window.updateRwdApplyState = () => {
4420
+ const cb1 = document.getElementById('rwd-consent-1')
4421
+ const cb2 = document.getElementById('rwd-consent-2')
4422
+ const btn = document.getElementById('rwd-submit-btn')
4423
+ if (cb1 && cb2 && btn) {
4424
+ const ok = cb1.checked && cb2.checked && !cb2.disabled
4425
+ btn.disabled = !ok
4426
+ btn.style.opacity = ok ? '1' : '0.5'
4427
+ }
4428
+ }
4429
+
4430
+ window.doSubmitRewardsApply = async (consentVersion, pageLoadedAt) => {
4431
+ const msg = document.getElementById('rwd-apply-msg')
4432
+ if (!msg) return
4433
+
4434
+ // Reconstruct consent_hash matching server's expectedApplyConsentHash() exactly
4435
+ const consentText = 'rewards_apply|consent_version=' + consentVersion + '|user=' + state.user.id + '|page_loaded_at=' + pageLoadedAt
4436
+ const encoder = new TextEncoder()
4437
+ const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(consentText))
4438
+ const consentHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2,'0')).join('')
4439
+
4440
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('请通过 Passkey / 生物识别确认...')}</div>`
4441
+ let passkeyToken
4442
+ try {
4443
+ passkeyToken = await requestPasskeyGate('rewards_apply', { consent_version: consentVersion, consent_hash: consentHash })
4444
+ } catch (e) {
4445
+ msg.innerHTML = alert$('error', t('Passkey 签发失败:') + ' ' + (e?.message || e))
4446
+ return
4447
+ }
4448
+
4449
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
4450
+ const res = await POST('/rewards/apply', {
4451
+ consent_version: consentVersion,
4452
+ consent_hash: consentHash,
4453
+ page_loaded_at: pageLoadedAt,
4454
+ webauthn_token: passkeyToken,
4455
+ })
4456
+
4457
+ if (res.error) {
4458
+ msg.innerHTML = alert$('error', res.error)
4459
+ return
4460
+ }
4461
+
4462
+ const drained = res.drained_from_escrow || { count: 0, total: 0 }
4463
+ const drainNote = drained.count > 0
4464
+ ? '<br>' + t('已从 escrow 拨回:') + ' ' + drained.total + ' WAZ (' + drained.count + ' ' + t('笔') + ')'
4465
+ : ''
4466
+ msg.innerHTML = alert$('success', t('✅ 共建身份激活成功') + drainNote)
4467
+ setTimeout(() => { location.hash = '#rewards-me' }, 2000)
4468
+ }
4469
+
4470
+ async function renderRewardsMe(app) {
4471
+ if (!state.user) { renderLogin(); return }
4472
+ app.innerHTML = shell(loading$(), 'me')
4473
+ const status = await GET('/rewards/status')
4474
+ if (status.error) { app.innerHTML = shell(alert$('error', status.error), 'me'); return }
4475
+
4476
+ const stateLabel = {
4477
+ opted_in: { icon: '✅', color: '#16a34a', label: t('共建身份激活中') },
4478
+ never_activated: { icon: '⚪', color: '#6b7280', label: t('未激活') },
4479
+ auto_downgraded: { icon: '⚠️', color: '#f59e0b', label: t('已自动降级(consent 未确认)') },
4480
+ deactivated: { icon: '🔒', color: '#9ca3af', label: t('已退出') },
4481
+ }[status.state] || { icon: '?', color: '#6b7280', label: status.state }
4482
+
4483
+ const escrow = status.pending_escrow || { count: 0, total_amount: 0 }
4484
+ const expired = status.expired_to_charity || { count: 0, total_amount: 0 }
4485
+
4486
+ app.innerHTML = shell(`
4487
+ <h1 class="page-title">🎁 ${t('共建身份管理')}</h1>
4488
+
4489
+ <div class="card" style="margin-bottom:16px;border-left:4px solid ${stateLabel.color};padding:14px">
4490
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
4491
+ <span style="font-size:28px">${stateLabel.icon}</span>
4492
+ <span style="font-size:16px;font-weight:600;color:${stateLabel.color}">${stateLabel.label}</span>
4493
+ </div>
4494
+ ${status.consent_version ? `<div style="font-size:12px;color:#9ca3af">${t('Consent version')}: v${status.consent_version}</div>` : ''}
4495
+ </div>
4496
+
4497
+ ${escrow.count > 0 ? `
4498
+ <div class="card" style="margin-bottom:16px;background:#fef3c7;border-color:#fde68a;padding:12px">
4499
+ <div style="font-weight:600;color:#92400e;font-size:13px;margin-bottom:4px">💰 ${t('待领 escrow')}</div>
4500
+ <div style="font-size:13px;color:#7c2d12">${escrow.count} ${t('笔')} · ${escrow.total_amount} WAZ</div>
4501
+ <div style="font-size:11px;color:#a16207;margin-top:6px">${t('opt-in 后这笔钱会拨回钱包。30 天未领过期入公益。')}</div>
4502
+ </div>
4503
+ ` : ''}
4504
+
4505
+ ${expired.count > 0 ? `
4506
+ <div class="card" style="margin-bottom:16px;background:#f3f4f6;padding:10px;font-size:12px;color:#6b7280">
4507
+ ${t('历史过期入公益')}: ${expired.count} ${t('笔')} · ${expired.total_amount} WAZ
4508
+ </div>
4509
+ ` : ''}
4510
+
4511
+ ${status.state === 'opted_in' ? `
4512
+ <div class="card" style="margin-bottom:16px;padding:14px">
4513
+ <div style="font-size:13px;color:#374151;margin-bottom:10px">${t('退出共建身份后,未来 commission 直接入公益(无 escrow)。可随时再申请。')}</div>
4514
+ <button class="btn" style="background:#fee2e2;color:#991b1b;border:1px solid #fca5a5" onclick="doDeactivateRewards()">
4515
+ 🔒 ${t('退出共建身份')}
4516
+ </button>
4517
+ <div id="rwd-deact-msg" style="margin-top:10px"></div>
4518
+ </div>
4519
+ ` : `
4520
+ <button class="btn btn-primary" onclick="location.hash='#apply-rewards'">
4521
+ 🎁 ${status.state === 'never_activated' ? t('申请共建身份') : t('重新申请')}
4522
+ </button>
4523
+ `}
4524
+
4525
+ <div style="margin-top:20px;font-size:11px;color:#9ca3af;line-height:1.6">
4526
+ ${t('协议依据:')} <a href="https://github.com/seasonsagents-art/webaz/blob/main/docs/rfcs/RFC-002-rewards-opt-in.md" target="_blank" style="color:#6b7280">RFC-002</a>
4527
+ </div>
4528
+ `, 'me')
4529
+ }
4530
+
4531
+ window.doDeactivateRewards = async () => {
4532
+ const msg = document.getElementById('rwd-deact-msg')
4533
+ if (!msg) return
4534
+ if (!confirm(t('确认退出共建身份?未来 commission 将直接入公益,可随时再申请。'))) return
4535
+
4536
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('请通过 Passkey / 生物识别确认...')}</div>`
4537
+ let passkeyToken
4538
+ try {
4539
+ passkeyToken = await requestPasskeyGate('rewards_deactivate', {})
4540
+ } catch (e) {
4541
+ msg.innerHTML = alert$('error', t('Passkey 签发失败:') + ' ' + (e?.message || e))
4542
+ return
4543
+ }
4544
+
4545
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
4546
+ const res = await POST('/rewards/deactivate', { webauthn_token: passkeyToken })
4547
+ if (res.error) {
4548
+ msg.innerHTML = alert$('error', res.error)
4549
+ return
4550
+ }
4551
+ msg.innerHTML = alert$('success', t('✅ 已退出共建身份'))
4552
+ setTimeout(() => renderRewardsMe(document.getElementById('app')), 1200)
4553
+ }
4554
+
4555
+ async function renderApplyVerifier(app) { return _renderApplyGovernance(app, 'verifier') }
4556
+ async function renderApplyArbitrator(app) { return _renderApplyGovernance(app, 'arbitrator') }
4557
+
4558
+ // ─── Onboarding 学习包 + 题目(spec §4.1 + §4.3,#1093 阶段 2a)─────
4559
+ // 案例研读(§4.2)留下一个 PR
4560
+ async function renderOnboarding(app, role) {
4561
+ if (!state.user) { renderLogin(); return }
4562
+ const returnNav = role === 'arbitrator' ? 'me' : 'verify-tasks'
4563
+ const roleTitle = role === 'arbitrator' ? t('仲裁员上岗 onboarding') : t('审核员上岗 onboarding')
4564
+ const roleIcon = role === 'arbitrator' ? '⚖' : '🛡'
4565
+
4566
+ app.innerHTML = shell(loading$(), returnNav)
4567
+
4568
+ // 同时加载 quiz 题库 + 案例库 + 当前 progress
4569
+ const [quizData, casesData, progress] = await Promise.all([
4570
+ GET('/governance/onboarding/quiz?role=' + role),
4571
+ GET('/governance/onboarding/cases?role=' + role),
4572
+ GET('/governance/onboarding/progress'),
4573
+ ])
4574
+
4575
+ if (quizData.error) { app.innerHTML = shell(alert$('error', quizData.error), returnNav); return }
4576
+ if (casesData.error) { app.innerHTML = shell(alert$('error', casesData.error), returnNav); return }
4577
+
4578
+ const currentApp = (progress.applications || []).find(a => a.role === role && a.status === 'pending_onboarding')
4579
+ if (!currentApp) {
4580
+ const roleLabel = role === 'arbitrator' ? t('仲裁员') : t('审核员')
4581
+ app.innerHTML = shell(`
4582
+ <h1 class="page-title">${roleIcon} ${roleTitle}</h1>
4583
+ <div class="card" style="background:#fff7ed;border-color:#fdba74">
4584
+ <div style="font-weight:600;color:#7c2d12">${t('请先提交申请')} (${roleLabel})</div>
4585
+ <div style="font-size:13px;color:#7c2d12;margin-top:8px"><a href="#apply-${role}">#apply-${role}</a> ${t('完成申请')}</div>
4586
+ </div>
4587
+ <div style="margin-top:16px"><a href="#${returnNav}" style="font-size:13px;color:#6b7280">← ${t('返回')}</a></div>
4588
+ `, returnNav)
4589
+ return
4590
+ }
4591
+
4592
+ const studyDocs = [
4593
+ { name: 'META-RULES-FULL.md', desc: t('10 元规则,特别是 #5 不偏袒 / #6 不滥用'), link: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/META-RULES-FULL.md' },
4594
+ { name: 'CHARTER.md §3.2 + §6', desc: t('权力边界:多签 + 修改流程'), link: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/CHARTER.md' },
4595
+ { name: 'SECURITY.md §Iron-Rule', desc: t('真人 Passkey 7 条路径'), link: 'https://github.com/seasonsagents-art/webaz/blob/main/SECURITY.md' },
4596
+ { name: 'ECONOMIC-MODEL.md §11', desc: t('经济博弈原则 + 关系层估值层'), link: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/ECONOMIC-MODEL.md' },
4597
+ { name: 'ARBITRATION-PLAYBOOK.md', desc: t('案例决策树 + 4 种结算路径') + (role === 'arbitrator' ? ' (' + t('必读') + ')' : ''), link: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/ARBITRATION-PLAYBOOK.md' },
4598
+ { name: 'MLM-COMPLIANCE.md', desc: t('合规边界'), link: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/MLM-COMPLIANCE.md' },
4599
+ ]
4600
+
4601
+ const studySection = studyDocs.map((d, i) => `
4602
+ <label style="display:flex;align-items:flex-start;gap:8px;padding:8px 0;border-bottom:1px solid #f3f4f6;cursor:pointer">
4603
+ <input type="checkbox" class="study-cb" data-idx="${i}" onchange="updateOnboardingState()">
4604
+ <div style="flex:1">
4605
+ <a href="${d.link}" target="_blank" rel="noopener" style="font-weight:600;color:#1d4ed8;font-size:13px;text-decoration:none">${d.name} ↗</a>
4606
+ <div style="font-size:11px;color:#6b7280;margin-top:2px">${d.desc}</div>
4607
+ </div>
4608
+ </label>`).join('')
4609
+
4610
+ const quizSection = (quizData.questions || []).map((q, idx) => {
4611
+ if (q.type === 'multiple_choice') {
4612
+ const opts = (q.options || []).map(opt => `
4613
+ <label style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;cursor:pointer">
4614
+ <input type="radio" name="${q.id}" value="${opt.key}" onchange="updateOnboardingState()">
4615
+ <span style="font-size:13px;color:#374151">${opt.key}. ${state._lang === 'en' ? opt.text_en : opt.text_zh}</span>
4616
+ </label>`).join('')
4617
+ return `
4618
+ <div class="card" style="margin-bottom:12px">
4619
+ <div style="font-size:13px;font-weight:600;color:#374151;margin-bottom:8px">${idx + 1}. ${state._lang === 'en' ? q.question_en : q.question_zh}</div>
4620
+ ${opts}
4621
+ </div>`
4622
+ } else {
4623
+ return `
4624
+ <div class="card" style="margin-bottom:12px">
4625
+ <div style="font-size:13px;font-weight:600;color:#374151;margin-bottom:8px">${idx + 1}. ${state._lang === 'en' ? q.question_en : q.question_zh}</div>
4626
+ <textarea class="form-control short-answer" data-qid="${q.id}" rows="4" placeholder="${t('至少 N 字符').replace('N', String(q.min_chars || 50))}" oninput="updateOnboardingState()" style="font-size:13px"></textarea>
4627
+ </div>`
4628
+ }
4629
+ }).join('')
4630
+
4631
+ const lastScore = currentApp.quiz_score
4632
+ const scoreBanner = lastScore != null ? `
4633
+ <div class="card" style="margin-bottom:16px;background:${lastScore >= 80 ? '#f0fdf4;border-color:#86efac' : '#fff7ed;border-color:#fdba74'}">
4634
+ <div style="font-weight:600">${t('上次得分')}: ${lastScore}% ${lastScore >= 80 ? '✅ ' + t('已合格,等待 maintainer 激活') : '⚠️ ' + t('未合格,可重试')}</div>
4635
+ </div>` : ''
4636
+
4637
+ app.innerHTML = shell(`
4638
+ <h1 class="page-title">${roleIcon} ${roleTitle}</h1>
4639
+
4640
+ ${scoreBanner}
4641
+
4642
+ <div class="card" style="margin-bottom:16px;background:#eff6ff;border-color:#93c5fd">
4643
+ <div style="font-weight:600;font-size:13px;color:#1e3a8a;margin-bottom:6px">📚 ${t('§4.1 学习包')}</div>
4644
+ <div style="font-size:12px;color:#1e40af;margin-bottom:10px">${t('阅读以下文档,勾选已读。本步骤不写入 DB(仅本地标记),实际理解由题目和案例考察。')}</div>
4645
+ ${studySection}
4646
+ </div>
4647
+
4648
+ <div class="card" style="margin-bottom:16px;background:#f5f3ff;border-color:#c4b5fd">
4649
+ <div style="font-weight:600;font-size:13px;color:#5b21b6;margin-bottom:6px">📝 ${t('§4.3 题目')}</div>
4650
+ <div style="font-size:12px;color:#6b21a8;margin-bottom:10px">
4651
+ ${(quizData.questions || []).length} ${t('题(多选 + 短答),合格线 80%')}(${quizData.questions ? Math.ceil(quizData.questions.length * 0.8) : 12}/${quizData.questions ? quizData.questions.length : 15})
4652
+ </div>
4653
+ ${quizSection}
4654
+ </div>
4655
+
4656
+ <button class="btn btn-primary" id="onb-submit-btn" onclick="doSubmitOnboarding('${role}')" disabled style="opacity:0.5">
4657
+ ${t('提交题目并评分')}
4658
+ </button>
4659
+
4660
+ <div id="onb-msg" style="margin-top:12px"></div>
4661
+
4662
+ <!-- §4.2 案例研读(spec §4.2,task #1093 阶段 2b) -->
4663
+ <div class="card" style="margin-top:24px;margin-bottom:16px;background:#ecfdf5;border-color:#86efac">
4664
+ <div style="font-weight:600;font-size:13px;color:#065f46;margin-bottom:6px">📚 ${t('§4.2 案例研读')}</div>
4665
+ <div style="font-size:12px;color:#047857;margin-bottom:10px">
4666
+ ${casesData.total} ${t('个案例,每个需选 verdict + 写理由 ≥ 200 字。本步骤')}<strong>${t('不自动评分')}</strong>${t(',maintainer 上岗签字前对比 expected verdict 评估你的 reasoning 方向。')}
4667
+ </div>
4668
+ ${(casesData.cases || []).map((c, idx) => `
4669
+ <details style="margin-bottom:12px;background:white;padding:10px;border-radius:6px;border:1px solid #d1fae5" ${idx === 0 ? 'open' : ''}>
4670
+ <summary style="font-weight:600;font-size:13px;cursor:pointer;color:#065f46">${idx + 1}. ${state._lang === 'en' ? c.scenario_en : c.scenario_zh}</summary>
4671
+ <div style="margin-top:10px">
4672
+ <div style="font-size:12px;color:#374151;font-weight:600;margin-bottom:4px">${t('已知事实')}:</div>
4673
+ <ul style="margin:0;padding-left:18px;font-size:12px;color:#4b5563;line-height:1.7">
4674
+ ${(state._lang === 'en' ? c.facts_en : c.facts_zh).map(f => `<li>${f}</li>`).join('')}
4675
+ </ul>
4676
+ <div style="font-size:12px;color:#374151;font-weight:600;margin-top:10px;margin-bottom:4px">${t('选择你的 verdict')}:</div>
4677
+ ${(c.decision_options || []).map(opt => `
4678
+ <label style="display:flex;align-items:flex-start;gap:8px;padding:4px 0;cursor:pointer">
4679
+ <input type="radio" name="case-${c.id}-verdict" value="${opt.key}" onchange="updateOnboardingState()">
4680
+ <span style="font-size:12px;color:#374151">${opt.key}: ${state._lang === 'en' ? opt.text_en : opt.text_zh}</span>
4681
+ </label>`).join('')}
4682
+ <div style="font-size:12px;color:#374151;font-weight:600;margin-top:10px;margin-bottom:4px">${t('reasoning(≥ N 字符)').replace('N', String(c.min_review_chars))}:</div>
4683
+ <textarea class="form-control case-reasoning" data-case-id="${c.id}" rows="5" placeholder="${t('解释你为什么选这个 verdict,引用具体事实和 spec 原则...')}" oninput="updateOnboardingState()" style="font-size:12px;width:100%"></textarea>
4684
+ </div>
4685
+ </details>`).join('')}
4686
+ </div>
4687
+
4688
+ <button class="btn btn-primary" id="onb-case-submit-btn" onclick="doSubmitCaseReview('${role}')" disabled style="opacity:0.5">
4689
+ ${t('提交案例 review')}
4690
+ </button>
4691
+
4692
+ <div id="onb-case-msg" style="margin-top:12px"></div>
4693
+ <div style="margin-top:16px"><a href="#${returnNav}" style="font-size:13px;color:#6b7280">← ${t('返回')}</a></div>
4694
+ `, returnNav)
4695
+ }
4696
+
4697
+ window.updateOnboardingState = () => {
4698
+ const allDocsChecked = Array.from(document.querySelectorAll('.study-cb')).every(cb => cb.checked)
4699
+ const allMcqAnswered = Array.from(document.querySelectorAll('[name^="mcq-"]')).reduce((acc, el) => {
4700
+ const name = el.name
4701
+ if (!acc.has(name)) acc.set(name, false)
4702
+ if (el.checked) acc.set(name, true)
4703
+ return acc
4704
+ }, new Map())
4705
+ const mcqOk = Array.from(allMcqAnswered.values()).every(v => v) && allMcqAnswered.size > 0
4706
+ const allShortAnswered = Array.from(document.querySelectorAll('.short-answer')).every(ta => ta.value.trim().length >= 50)
4707
+ const btn = document.getElementById('onb-submit-btn')
4708
+ if (btn) {
4709
+ const ok = allDocsChecked && mcqOk && allShortAnswered
4710
+ btn.disabled = !ok
4711
+ btn.style.opacity = ok ? '1' : '0.5'
4712
+ }
4713
+
4714
+ // §4.2 案例 submit 独立启用:每个 case 必须选 verdict + reasoning ≥ 200 字
4715
+ const caseVerdictGroups = new Map()
4716
+ document.querySelectorAll('[name^="case-"][type="radio"]').forEach(el => {
4717
+ if (!caseVerdictGroups.has(el.name)) caseVerdictGroups.set(el.name, false)
4718
+ if (el.checked) caseVerdictGroups.set(el.name, true)
4719
+ })
4720
+ const caseVerdictsOk = caseVerdictGroups.size > 0 && Array.from(caseVerdictGroups.values()).every(v => v)
4721
+ const allCaseReasoningOk = Array.from(document.querySelectorAll('.case-reasoning')).every(ta => ta.value.trim().length >= 200)
4722
+ const caseBtn = document.getElementById('onb-case-submit-btn')
4723
+ if (caseBtn) {
4724
+ const ok = caseVerdictsOk && allCaseReasoningOk
4725
+ caseBtn.disabled = !ok
4726
+ caseBtn.style.opacity = ok ? '1' : '0.5'
4727
+ }
4728
+ }
4729
+
4730
+ // ─── /#governance-me 用户治理面板(#1093 阶段 4)─────
4731
+ // spec docs/GOVERNANCE-ONBOARDING.md §6.1 §7.2:active role 卸任 / auto_deactivate 申诉
4732
+ async function renderGovernanceMe(app) {
4733
+ if (!state.user) { renderLogin(); return }
4734
+ app.innerHTML = shell(loading$(), 'me')
4735
+ const data = await GET('/governance/onboarding/my')
4736
+ if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'me'); return }
4737
+
4738
+ const items = data.items || []
4739
+ const now = Math.floor(Date.now() / 1000)
4740
+
4741
+ // active 角色:有 action 在 ('apply' 但 status='active') 或 ('activate' 且 status='active')
4742
+ // 简化:把 status='active' 全列(可能为 apply row 或 activate row)
4743
+ const actives = items.filter(i => i.status === 'active')
4744
+ const pendings = items.filter(i => i.status === 'pending_onboarding')
4745
+ // auto_deactivate 行(可申诉)
4746
+ const autoDeactivates = items.filter(i => i.action === 'auto_deactivate')
4747
+ // pending appeals
4748
+ const pendingAppeals = items.filter(i => i.action === 'appeal' && i.status === 'pending_review')
4749
+
4750
+ const roleNames = {
4751
+ arbitrator: { zh: '仲裁员', icon: '⚖' },
4752
+ verifier: { zh: '审核员', icon: '🛡' },
4753
+ }
4754
+
4755
+ // active 列表(可卸任)— 按 role 去重(可能有 apply + activate 两行,只显示一个)
4756
+ const seenActive = new Set()
4757
+ const activeBlocks = actives.filter(a => {
4758
+ if (seenActive.has(a.role)) return false
4759
+ seenActive.add(a.role)
4760
+ return true
4761
+ }).map(a => {
4762
+ const rn = roleNames[a.role] || { zh: a.role, icon: '·' }
4763
+ return `
4764
+ <div class="card" style="margin-bottom:12px;border-color:#86efac;background:#f0fdf4">
4765
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
4766
+ <div>
4767
+ <div style="font-weight:600;font-size:15px">${rn.icon} ${t(rn.zh)} <span style="color:#16a34a;font-size:12px;margin-left:6px">● ${t('在岗')}</span></div>
4768
+ <div style="font-size:12px;color:#6b7280;margin-top:4px">${t('上岗时间')}: ${new Date(a.created_at * 1000).toLocaleDateString()}</div>
4769
+ </div>
4770
+ <button class="btn btn-sm btn-outline" style="color:#dc2626;border-color:#fca5a5" onclick="openResignModal('${a.role}')">${t('卸任')}</button>
4771
+ </div>
4772
+ </div>`
4773
+ }).join('')
4774
+
4775
+ // 申诉窗口检查(14d)
4776
+ const windowDays = 14
4777
+ const appealableBlocks = autoDeactivates.filter(a => (now - a.created_at) <= windowDays * 86400).map(a => {
4778
+ const rn = roleNames[a.role] || { zh: a.role, icon: '·' }
4779
+ const daysLeft = windowDays - Math.floor((now - a.created_at) / 86400)
4780
+ // 检查是否已 appeal
4781
+ const hasAppeal = items.some(i => i.action === 'appeal' && i.source_application_id === a.id)
4782
+ return `
4783
+ <div class="card" style="margin-bottom:12px;border-color:#fde68a;background:#fffbeb">
4784
+ <div style="font-weight:600;font-size:14px;color:#92400e">${rn.icon} ${t('被自动卸任')}: ${t(rn.zh)}</div>
4785
+ <div style="font-size:12px;color:#78350f;margin-top:6px">${t('生效时间')}: ${new Date(a.created_at * 1000).toLocaleDateString()} · ${t('申诉窗口剩余')}: ${daysLeft} ${t('天')}</div>
4786
+ ${hasAppeal
4787
+ ? `<div style="margin-top:10px;font-size:12px;color:#1e40af">${t('已提交申诉,等待 maintainer 裁决')}</div>`
4788
+ : `<button class="btn btn-sm btn-primary" style="margin-top:10px" onclick="openAppealModal('${a.id}', '${a.role}')">${t('提交申诉')}</button>`}
4789
+ </div>`
4790
+ }).join('')
4791
+
4792
+ const pendingAppealBlocks = pendingAppeals.map(a => {
4793
+ const rn = roleNames[a.role] || { zh: a.role, icon: '·' }
4794
+ return `
4795
+ <div class="card" style="margin-bottom:12px;border-color:#93c5fd;background:#eff6ff">
4796
+ <div style="font-weight:600;font-size:14px;color:#1e3a8a">${rn.icon} ${t('申诉审核中')}: ${t(rn.zh)}</div>
4797
+ <div style="font-size:12px;color:#1e40af;margin-top:6px">${t('提交时间')}: ${new Date(a.created_at * 1000).toLocaleDateString()}</div>
4798
+ <div style="font-size:12px;color:#374151;margin-top:8px;padding:8px;background:#fff;border-radius:4px">${escHtml((a.appeal_reason || '').slice(0, 200))}${(a.appeal_reason || '').length > 200 ? '...' : ''}</div>
4799
+ </div>`
4800
+ }).join('')
4801
+
4802
+ const pendingBlocks = pendings.map(p => {
4803
+ const rn = roleNames[p.role] || { zh: p.role, icon: '·' }
4804
+ return `
4805
+ <div class="card" style="margin-bottom:12px">
4806
+ <div style="font-weight:600;font-size:14px">${rn.icon} ${t(rn.zh)} <span style="color:#d97706;font-size:12px;margin-left:6px">${t('申请审核中')}</span></div>
4807
+ <div style="margin-top:8px"><a href="#onboarding-${p.role}" class="btn btn-sm btn-outline">${t('继续 onboarding')}</a></div>
4808
+ </div>`
4809
+ }).join('')
4810
+
4811
+ const emptyState = (actives.length + autoDeactivates.length + pendings.length + pendingAppeals.length) === 0 ? `
4812
+ <div class="card" style="text-align:center;padding:24px">
4813
+ <div style="font-size:14px;color:#6b7280">${t('暂无治理岗位记录')}</div>
4814
+ <div style="margin-top:12px;display:flex;gap:8px;justify-content:center">
4815
+ <a href="#apply-arbitrator" class="btn btn-sm btn-outline">⚖ ${t('申请仲裁员')}</a>
4816
+ <a href="#apply-verifier" class="btn btn-sm btn-outline">🛡 ${t('申请审核员')}</a>
4817
+ </div>
4818
+ </div>` : ''
4819
+
4820
+ app.innerHTML = shell(`
4821
+ <h1 class="page-title">⚖️ ${t('我的治理岗位')}</h1>
4822
+ <div class="card" style="margin-bottom:16px;background:#eff6ff;border-color:#93c5fd">
4823
+ <div style="font-size:13px;color:#1e3a8a">${t('spec docs/GOVERNANCE-ONBOARDING.md §6 §7')}</div>
4824
+ <div style="font-size:12px;color:#1e40af;margin-top:4px">${t('phase A 治理 = 关系层数据采集 + 公共贡献意愿表达,不预设估值层')}</div>
4825
+ </div>
4826
+ ${activeBlocks ? `<h2 style="font-size:14px;color:#374151;margin:16px 0 8px">${t('当前在岗')}</h2>${activeBlocks}` : ''}
4827
+ ${appealableBlocks ? `<h2 style="font-size:14px;color:#92400e;margin:16px 0 8px">⚠️ ${t('待申诉(14 天内)')}</h2>${appealableBlocks}` : ''}
4828
+ ${pendingAppealBlocks ? `<h2 style="font-size:14px;color:#1e40af;margin:16px 0 8px">${t('申诉审核中')}</h2>${pendingAppealBlocks}` : ''}
4829
+ ${pendingBlocks ? `<h2 style="font-size:14px;color:#374151;margin:16px 0 8px">${t('待审申请')}</h2>${pendingBlocks}` : ''}
4830
+ ${emptyState}
4831
+ <div style="margin-top:16px"><a href="#me" style="font-size:13px;color:#6b7280">← ${t('返回 #me')}</a></div>
4255
4832
  `, 'me')
4256
4833
  }
4257
4834
 
4258
- window.doApplyArbitrator = async () => {
4259
- const msg = document.getElementById('apply-arb-msg')
4835
+ window.openResignModal = (role) => {
4836
+ const expected = `RESIGN ${role}`
4837
+ const html = `
4838
+ <div id="resign-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px">
4839
+ <div style="background:#fff;border-radius:8px;max-width:480px;width:100%;padding:20px">
4840
+ <h3 style="margin:0 0 12px;color:#dc2626">⚠️ ${t('卸任治理岗位')}: ${role}</h3>
4841
+ <div style="font-size:13px;color:#374151;line-height:1.6;margin-bottom:12px">
4842
+ <ul style="padding-left:20px;margin:0">
4843
+ <li>${t('卸任后历史履职记录保留(关系层,不可逆)')}</li>
4844
+ <li>${t('卸任不影响 reputation / dev_contribution')}</li>
4845
+ <li>${t('30 天内不能重新申请同一角色(冷却期)')}</li>
4846
+ <li>${t('已 assigned 但未完成的 case 必须先完成 / 转交,否则无法卸任')}</li>
4847
+ </ul>
4848
+ </div>
4849
+ <div style="font-size:13px;color:#374151;margin-bottom:8px">${t('输入')} <code style="background:#fee2e2;padding:2px 6px;border-radius:4px;color:#991b1b">${expected}</code> ${t('确认')}:</div>
4850
+ <input id="resign-confirm-input" type="text" placeholder="${expected}" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:4px;font-family:monospace" oninput="document.getElementById('resign-submit-btn').disabled = this.value !== '${expected}'">
4851
+ <div id="resign-msg" style="margin-top:12px"></div>
4852
+ <div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
4853
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('resign-modal').remove()">${t('取消')}</button>
4854
+ <button id="resign-submit-btn" class="btn btn-sm btn-primary" style="background:#dc2626;border-color:#dc2626" disabled onclick="doResignGovernance('${role}')">🔑 ${t('Passkey 签发卸任')}</button>
4855
+ </div>
4856
+ </div>
4857
+ </div>`
4858
+ document.body.insertAdjacentHTML('beforeend', html)
4859
+ }
4860
+
4861
+ window.doResignGovernance = async (role) => {
4862
+ const expected = `RESIGN ${role}`
4863
+ const input = document.getElementById('resign-confirm-input')
4864
+ const msg = document.getElementById('resign-msg')
4865
+ if (!input || input.value !== expected) return
4866
+
4867
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('请求 Passkey...')}</div>`
4868
+
4869
+ let webauthnToken
4870
+ try {
4871
+ webauthnToken = await requestPasskeyGate('governance_resign', { role, action: 'resign' })
4872
+ } catch (e) {
4873
+ msg.innerHTML = alert$('error', t('Passkey 签发失败:') + ' ' + (e?.message || e))
4874
+ return
4875
+ }
4876
+
4877
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
4878
+ const res = await POST('/governance/onboarding/resign', {
4879
+ role,
4880
+ confirm_text: expected,
4881
+ webauthn_token: webauthnToken,
4882
+ })
4883
+ if (res.error) {
4884
+ if (res.code === 'ACTIVE_CASES_EXIST') {
4885
+ // placeholder pattern(避免 t() 拼接)
4886
+ const tpl = t('尚有 {n} 个未结案 dispute,请先完成裁决后再卸任')
4887
+ msg.innerHTML = alert$('error', tpl.replace('{n}', String(res.open_case_count)))
4888
+ } else {
4889
+ msg.innerHTML = alert$('error', res.error)
4890
+ }
4891
+ return
4892
+ }
4893
+ msg.innerHTML = `<div class="alert alert-success">${t('卸任成功。冷却期至')} ${new Date(res.cooldown_until * 1000).toLocaleDateString()}</div>`
4894
+ setTimeout(() => {
4895
+ const modal = document.getElementById('resign-modal')
4896
+ if (modal) modal.remove()
4897
+ renderGovernanceMe(document.getElementById('app'))
4898
+ }, 1500)
4899
+ }
4900
+
4901
+ window.openAppealModal = (sourceAppId, role) => {
4902
+ const html = `
4903
+ <div id="appeal-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px">
4904
+ <div style="background:#fff;border-radius:8px;max-width:520px;width:100%;padding:20px">
4905
+ <h3 style="margin:0 0 12px">${t('对自动卸任提出申诉')}: ${role}</h3>
4906
+ <div style="font-size:12px;color:#6b7280;margin-bottom:12px;line-height:1.6">
4907
+ ${t('请详细说明:为何 outlier 计数 / COI 警告 / inactive 不应导致卸任。理由至少 100 字符,maintainer 群多签审议,通过后恢复 active 状态。')}
4908
+ </div>
4909
+ <textarea id="appeal-reason-input" rows="6" placeholder="${t('详细说明你的申诉理由(≥100 字符)')}" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;font-family:inherit" oninput="document.getElementById('appeal-char-count').textContent = this.value.length; document.getElementById('appeal-submit-btn').disabled = this.value.length < 100"></textarea>
4910
+ <div style="font-size:12px;color:#6b7280;margin-top:4px">${t('字符数')}: <span id="appeal-char-count">0</span> / 100</div>
4911
+ <div id="appeal-msg" style="margin-top:12px"></div>
4912
+ <div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
4913
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('appeal-modal').remove()">${t('取消')}</button>
4914
+ <button id="appeal-submit-btn" class="btn btn-sm btn-primary" disabled onclick="doSubmitAppeal('${sourceAppId}')">${t('提交申诉')}</button>
4915
+ </div>
4916
+ </div>
4917
+ </div>`
4918
+ document.body.insertAdjacentHTML('beforeend', html)
4919
+ }
4920
+
4921
+ window.doSubmitAppeal = async (sourceAppId) => {
4922
+ const input = document.getElementById('appeal-reason-input')
4923
+ const msg = document.getElementById('appeal-msg')
4924
+ if (!input || input.value.length < 100) return
4925
+
4926
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
4927
+ const res = await POST('/governance/onboarding/appeal', {
4928
+ source_application_id: sourceAppId,
4929
+ appeal_reason: input.value.trim(),
4930
+ })
4931
+ if (res.error) { msg.innerHTML = alert$('error', res.error); return }
4932
+ msg.innerHTML = `<div class="alert alert-success">${t('申诉已提交,等待 maintainer 裁决')}</div>`
4933
+ setTimeout(() => {
4934
+ const modal = document.getElementById('appeal-modal')
4935
+ if (modal) modal.remove()
4936
+ renderGovernanceMe(document.getElementById('app'))
4937
+ }, 1500)
4938
+ }
4939
+
4940
+ // ─── /admin/governance(maintainer activation,#1093 阶段 3)─────
4941
+ async function renderAdminGovernance(app) {
4942
+ if (!state.user) { renderLogin(); return }
4943
+ app.innerHTML = shell(loading$(), 'admin')
4944
+
4945
+ // 阶段 4:同时拉 appeals
4946
+ const [data, appealsData] = await Promise.all([
4947
+ GET('/admin/governance/applications'),
4948
+ GET('/admin/governance/appeals'),
4949
+ ])
4950
+ if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'admin'); return }
4951
+
4952
+ const items = data.items || []
4953
+ const appeals = (appealsData && !appealsData.error) ? (appealsData.items || []) : []
4954
+ if (items.length === 0 && appeals.length === 0) {
4955
+ app.innerHTML = shell(`
4956
+ <h1 class="page-title">⚖️ ${t('治理岗位激活')}</h1>
4957
+ <div class="card" style="background:#f0fdf4;border-color:#86efac">
4958
+ <div style="font-weight:600;color:#065f46">${t('当前无待激活申请')}</div>
4959
+ <div style="font-size:13px;color:#047857;margin-top:8px">${t('所有 governance application 均已 active 或未达 onboarding 完成')}</div>
4960
+ </div>
4961
+ <div style="margin-top:16px"><a href="#admin" style="font-size:13px;color:#6b7280">← ${t('返回 admin')}</a></div>
4962
+ `, 'admin')
4963
+ return
4964
+ }
4965
+
4966
+ const list = items.map(a => {
4967
+ const quizOk = a.quiz_passed_at != null
4968
+ const caseOk = a.has_case_review === 1 || a.has_case_review === true
4969
+ const onboardingComplete = quizOk && caseOk
4970
+ const roleLabel = a.role === 'arbitrator' ? t('仲裁员') : t('审核员')
4971
+ return `
4972
+ <div class="card" style="margin-bottom:12px;border-color:${onboardingComplete ? '#86efac' : '#fde68a'}">
4973
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
4974
+ <div style="flex:1">
4975
+ <div style="font-weight:600;font-size:14px">${a.role === 'arbitrator' ? '⚖' : '🛡'} ${escHtml(a.user_name || '')} <span style="color:#6b7280;font-size:12px">(${a.handle ? '@' + escHtml(a.handle) : a.user_id})</span></div>
4976
+ <div style="font-size:12px;color:#6b7280;margin-top:4px">${t('申请')}: ${roleLabel} · ${t('Region')}: ${escHtml(a.region || '—')} · ${t('Email')}: ${a.email ? '✓' : '✗'}</div>
4977
+ <div style="font-size:12px;margin-top:6px;display:flex;gap:12px">
4978
+ <span style="color:${quizOk ? '#16a34a' : '#dc2626'}">${quizOk ? '✓' : '✗'} ${t('题目')} ${a.quiz_score != null ? a.quiz_score + '%' : '—'}</span>
4979
+ <span style="color:${caseOk ? '#16a34a' : '#dc2626'}">${caseOk ? '✓' : '✗'} ${t('案例 review')}</span>
4980
+ </div>
4981
+ </div>
4982
+ <div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
4983
+ <button class="btn btn-sm btn-outline" onclick="viewGovernanceApplication('${a.id}')">${t('查看详情')}</button>
4984
+ ${onboardingComplete ? `<button class="btn btn-sm btn-primary" onclick="doActivateGovernance('${a.id}', '${a.user_id}')">🔑 ${t('激活')}</button>` : `<span style="font-size:11px;color:#dc2626">${t('onboarding 未完成')}</span>`}
4985
+ </div>
4986
+ </div>
4987
+ <div id="gov-app-detail-${a.id}" style="margin-top:10px"></div>
4988
+ </div>`
4989
+ }).join('')
4990
+
4991
+ // 阶段 4:待裁决申诉列表
4992
+ const appealsList = appeals.map(ap => {
4993
+ const roleLabel = ap.role === 'arbitrator' ? t('仲裁员') : t('审核员')
4994
+ const reasonShort = (ap.appeal_reason || '').slice(0, 280)
4995
+ return `
4996
+ <div class="card" style="margin-bottom:12px;border-color:#fde68a;background:#fffbeb">
4997
+ <div style="display:flex;justify-content:space-between;gap:10px">
4998
+ <div style="flex:1">
4999
+ <div style="font-weight:600;font-size:14px">${ap.role === 'arbitrator' ? '⚖' : '🛡'} ${escHtml(ap.user_name || '')} <span style="color:#6b7280;font-size:12px">(${ap.handle ? '@' + escHtml(ap.handle) : ap.user_id})</span></div>
5000
+ <div style="font-size:12px;color:#6b7280;margin-top:4px">${t('申诉')}: ${roleLabel} · ${t('提交于')} ${new Date(ap.created_at * 1000).toLocaleDateString()}${ap.auto_deactivate_at ? ' · ' + t('自动卸任于') + ' ' + new Date(ap.auto_deactivate_at * 1000).toLocaleDateString() : ''}</div>
5001
+ <div style="font-size:13px;color:#374151;margin-top:8px;padding:8px;background:#fff;border-radius:4px;line-height:1.6">${escHtml(reasonShort)}${(ap.appeal_reason || '').length > 280 ? '...' : ''}</div>
5002
+ </div>
5003
+ </div>
5004
+ <div style="margin-top:10px;display:flex;gap:6px;justify-content:flex-end">
5005
+ <button class="btn btn-sm btn-outline" style="color:#dc2626;border-color:#fca5a5" onclick="openResolveAppealModal('${ap.id}', 'reject')">${t('驳回')}</button>
5006
+ <button class="btn btn-sm btn-primary" style="background:#16a34a;border-color:#16a34a" onclick="openResolveAppealModal('${ap.id}', 'accept')">${t('接受恢复')}</button>
5007
+ </div>
5008
+ </div>`
5009
+ }).join('')
5010
+
5011
+ app.innerHTML = shell(`
5012
+ <h1 class="page-title">⚖️ ${t('治理岗位激活')}</h1>
5013
+ ${appeals.length > 0 ? `
5014
+ <h2 style="font-size:15px;color:#92400e;margin:0 0 8px">${t('待裁决申诉')} (${appeals.length})</h2>
5015
+ <div class="card" style="margin-bottom:12px;background:#fff7ed;border-color:#fdba74">
5016
+ <div style="font-size:12px;color:#7c2d12">${t('spec §7.2:申诉通过 → 恢复 active + 抹除 outlier;驳回 → 公开理由(对应元规则 #1 当一切可见)')}</div>
5017
+ </div>
5018
+ ${appealsList}
5019
+ <h2 style="font-size:15px;color:#374151;margin:24px 0 8px">${t('待激活申请')} (${items.length})</h2>
5020
+ ` : ''}
5021
+ <div class="card" style="margin-bottom:16px;background:#eff6ff;border-color:#93c5fd">
5022
+ <div style="font-size:13px;color:#1e3a8a">${t('待激活申请')}: <strong>${items.length}</strong></div>
5023
+ <div style="font-size:12px;color:#1e40af;margin-top:6px">${t('激活前:代码自动 re-gate(eligibility 二次校验)+ Iron-Rule Passkey ceremony,防 maintainer 漏检')}</div>
5024
+ </div>
5025
+ ${list}
5026
+ <div style="margin-top:16px"><a href="#admin" style="font-size:13px;color:#6b7280">← ${t('返回 admin')}</a></div>
5027
+ `, 'admin')
5028
+ }
5029
+
5030
+ window.openResolveAppealModal = (appealId, decision) => {
5031
+ const isAccept = decision === 'accept'
5032
+ const html = `
5033
+ <div id="resolve-appeal-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px">
5034
+ <div style="background:#fff;border-radius:8px;max-width:520px;width:100%;padding:20px">
5035
+ <h3 style="margin:0 0 12px;color:${isAccept ? '#16a34a' : '#dc2626'}">${isAccept ? '✅ ' + t('接受申诉(恢复 active)') : '❌ ' + t('驳回申诉')}</h3>
5036
+ <div style="font-size:12px;color:#6b7280;margin-bottom:12px;line-height:1.6">
5037
+ ${isAccept
5038
+ ? t('user.roles 将恢复角色,可立即继续履职(抹除 outlier 计数,审计留痕)')
5039
+ : t('user 维持 inactive 状态。处置理由会公开(元规则 #1 当一切可见)')}
5040
+ </div>
5041
+ <textarea id="resolve-text-input" rows="5" placeholder="${t('处置理由(≥30 字符,公开)')}" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;font-family:inherit" oninput="document.getElementById('resolve-char-count').textContent = this.value.length; document.getElementById('resolve-submit-btn').disabled = this.value.length < 30"></textarea>
5042
+ <div style="font-size:12px;color:#6b7280;margin-top:4px">${t('字符数')}: <span id="resolve-char-count">0</span> / 30</div>
5043
+ <div id="resolve-msg" style="margin-top:12px"></div>
5044
+ <div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
5045
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('resolve-appeal-modal').remove()">${t('取消')}</button>
5046
+ <button id="resolve-submit-btn" class="btn btn-sm btn-primary" disabled onclick="doResolveAppeal('${appealId}', '${decision}')">🔑 ${t('Passkey 签发裁决')}</button>
5047
+ </div>
5048
+ </div>
5049
+ </div>`
5050
+ document.body.insertAdjacentHTML('beforeend', html)
5051
+ }
5052
+
5053
+ window.doResolveAppeal = async (appealId, decision) => {
5054
+ const input = document.getElementById('resolve-text-input')
5055
+ const msg = document.getElementById('resolve-msg')
5056
+ if (!input || input.value.length < 30) return
5057
+ const resolution_text = input.value.trim()
5058
+
5059
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('请求 Passkey...')}</div>`
5060
+ let webauthnToken
5061
+ try {
5062
+ webauthnToken = await requestPasskeyGate('governance_appeal_resolve', { appeal_application_id: appealId, decision })
5063
+ } catch (e) {
5064
+ msg.innerHTML = alert$('error', t('Passkey 签发失败:') + ' ' + (e?.message || e))
5065
+ return
5066
+ }
5067
+
5068
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
5069
+ const res = await POST('/admin/governance/resolve-appeal', {
5070
+ appeal_application_id: appealId,
5071
+ decision,
5072
+ resolution_text,
5073
+ webauthn_token: webauthnToken,
5074
+ })
5075
+ if (res.error) { msg.innerHTML = alert$('error', res.error); return }
5076
+ msg.innerHTML = `<div class="alert alert-success">${t('裁决已记录,user 已通知')}</div>`
5077
+ setTimeout(() => {
5078
+ const modal = document.getElementById('resolve-appeal-modal')
5079
+ if (modal) modal.remove()
5080
+ renderAdminGovernance(document.getElementById('app'))
5081
+ }, 1200)
5082
+ }
5083
+
5084
+ window.viewGovernanceApplication = async (appId) => {
5085
+ const detailEl = document.getElementById('gov-app-detail-' + appId)
5086
+ if (!detailEl) return
5087
+ detailEl.innerHTML = `<div style="font-size:12px;color:#6b7280">${t('加载中...')}</div>`
5088
+ const data = await GET('/admin/governance/application/' + encodeURIComponent(appId))
5089
+ if (data.error) { detailEl.innerHTML = alert$('error', data.error); return }
5090
+
5091
+ const a = data.application
5092
+ const cases = data.cases_with_expected || []
5093
+ const reviewObj = data.parsed_review
5094
+ const reviews = reviewObj?.reviews || []
5095
+
5096
+ // Map case_id → submitted review
5097
+ const reviewMap = new Map(reviews.map(r => [r.case_id, r]))
5098
+
5099
+ const caseRows = cases.map(c => {
5100
+ const submitted = reviewMap.get(c.id)
5101
+ const match = submitted && submitted.chosen_verdict === c.expected_verdict
5102
+ return `
5103
+ <div style="margin-bottom:8px;padding:8px;background:#f9fafb;border-radius:4px;font-size:12px">
5104
+ <div style="font-weight:600;color:#374151">${c.id}: ${state._lang === 'en' ? c.scenario_en : c.scenario_zh}</div>
5105
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px">
5106
+ <div>
5107
+ <div style="color:#6b7280">${t('申请者选')}:</div>
5108
+ <div style="color:${match ? '#16a34a' : '#dc2626'};font-weight:600">${submitted ? submitted.chosen_verdict : '—'}</div>
5109
+ </div>
5110
+ <div>
5111
+ <div style="color:#6b7280">${t('expected')}:</div>
5112
+ <div style="font-weight:600;color:#047857">${c.expected_verdict}</div>
5113
+ </div>
5114
+ </div>
5115
+ <div style="margin-top:6px">
5116
+ <div style="color:#6b7280">${t('reasoning')}:</div>
5117
+ <div style="white-space:pre-wrap;padding:6px;background:white;border-radius:4px;border:1px solid #e5e7eb">${escHtml(submitted?.reasoning || '—')}</div>
5118
+ </div>
5119
+ <div style="margin-top:6px;font-size:11px;color:#6b7280">${t('key principles')}: ${c.key_principles.join(' / ')}</div>
5120
+ </div>`
5121
+ }).join('')
5122
+
5123
+ detailEl.innerHTML = `
5124
+ <div style="border-top:1px solid #e5e7eb;padding-top:10px">
5125
+ <div style="font-weight:600;font-size:13px;margin-bottom:6px">${t('案例 review 对比')}:</div>
5126
+ ${caseRows || '<div style="font-size:12px;color:#6b7280">' + t('无 review 数据') + '</div>'}
5127
+ </div>`
5128
+ }
5129
+
5130
+ window.doActivateGovernance = async (applicationId, targetUserId) => {
5131
+ if (!confirm(t('确认激活该申请?将自动 re-gate eligibility + Passkey 签发'))) return
5132
+
5133
+ let webauthnToken
5134
+ try {
5135
+ webauthnToken = await requestPasskeyGate('governance_activate', { application_id: applicationId, target_user_id: targetUserId })
5136
+ } catch (e) {
5137
+ alert(t('Passkey 签发失败:') + ' ' + (e?.message || e))
5138
+ return
5139
+ }
5140
+
5141
+ const res = await POST('/admin/governance/activate', {
5142
+ application_id: applicationId,
5143
+ webauthn_token: webauthnToken,
5144
+ })
5145
+
5146
+ if (res.error) {
5147
+ if (res.missing_requirements) {
5148
+ alert(t('代码自动 re-gate 失败') + ': ' + res.missing_requirements.join(', '))
5149
+ } else {
5150
+ alert(res.error)
5151
+ }
5152
+ return
5153
+ }
5154
+
5155
+ alert(t('✅ 激活成功') + ': ' + res.role)
5156
+ renderAdminGovernance(document.getElementById('app'))
5157
+ }
5158
+
5159
+ window.doSubmitCaseReview = async (role) => {
5160
+ const msg = document.getElementById('onb-case-msg')
5161
+ if (!msg) return
5162
+
5163
+ // 收集 case reviews
5164
+ const reviews = []
5165
+ const caseVerdictMap = new Map()
5166
+ document.querySelectorAll('[name^="case-"][type="radio"]:checked').forEach(el => {
5167
+ // name format: case-<id>-verdict
5168
+ const caseId = el.name.replace(/^case-/, '').replace(/-verdict$/, '')
5169
+ caseVerdictMap.set(caseId, el.value)
5170
+ })
5171
+ document.querySelectorAll('textarea.case-reasoning').forEach(ta => {
5172
+ const caseId = ta.dataset.caseId
5173
+ const verdict = caseVerdictMap.get(caseId)
5174
+ if (caseId && verdict) {
5175
+ reviews.push({ case_id: caseId, chosen_verdict: verdict, reasoning: ta.value.trim() })
5176
+ }
5177
+ })
5178
+
4260
5179
  msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
4261
- const res = await POST('/arbitrator/apply', {})
5180
+ const res = await POST('/governance/onboarding/case-review', { role, reviews })
5181
+
5182
+ if (res.error) {
5183
+ if (res.errors && Array.isArray(res.errors)) {
5184
+ const detail = res.errors.map(e => `${e.case_id}: ${e.reason}`).join('<br>')
5185
+ msg.innerHTML = alert$('error', t('案例 review 不完整') + ':<br>' + detail)
5186
+ } else {
5187
+ msg.innerHTML = alert$('error', res.error)
5188
+ }
5189
+ return
5190
+ }
5191
+
5192
+ msg.innerHTML = `
5193
+ <div class="alert alert-success">
5194
+ <div style="font-weight:600">✅ ${t('案例 review 已提交')} (${res.submitted_count} ${t('个案例')})</div>
5195
+ <div style="margin-top:8px;font-size:13px">${t('maintainer 上岗签字前会对比 expected verdict 评估你的 reasoning 方向')}</div>
5196
+ </div>`
5197
+ }
5198
+
5199
+ window.doSubmitOnboarding = async (role) => {
5200
+ const msg = document.getElementById('onb-msg')
5201
+ if (!msg) return
5202
+
5203
+ // 收集答案
5204
+ const answers = []
5205
+ // multi-choice
5206
+ const radioGroups = new Map()
5207
+ document.querySelectorAll('input[type="radio"][name^="mcq-"]:checked').forEach(el => {
5208
+ radioGroups.set(el.name, el.value)
5209
+ })
5210
+ radioGroups.forEach((answer, qid) => answers.push({ question_id: qid, answer }))
5211
+ // short-answer
5212
+ document.querySelectorAll('textarea.short-answer').forEach(ta => {
5213
+ answers.push({ question_id: ta.dataset.qid, answer: ta.value.trim() })
5214
+ })
5215
+
5216
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('评分中...')}</div>`
5217
+ const res = await POST('/governance/onboarding/quiz-submit', { role, answers })
4262
5218
  if (res.error) { msg.innerHTML = alert$('error', res.error); return }
4263
- msg.innerHTML = alert$('success', t('申请已提交,等待管理员审核'))
4264
- setTimeout(() => navigate('#me'), 1500)
5219
+
5220
+ const detail = (res.per_question || []).map(p => `
5221
+ <span style="display:inline-block;margin:2px;padding:2px 6px;background:${p.ok ? '#dcfce7' : '#fee2e2'};color:${p.ok ? '#166534' : '#991b1b'};font-size:11px;border-radius:4px">${p.id} ${p.ok ? '✓' : '✗'}</span>`).join('')
5222
+
5223
+ if (res.passed) {
5224
+ msg.innerHTML = `
5225
+ <div class="alert alert-success">
5226
+ <div style="font-weight:600">✅ ${t('合格')}! ${t('得分')}: ${res.score_pct}% (${res.correct}/${res.total})</div>
5227
+ <div style="margin-top:8px;font-size:12px">${detail}</div>
5228
+ <div style="margin-top:10px;font-size:13px">${t('下一步:等待 maintainer 激活,本阶段未上线 admin UI')}</div>
5229
+ </div>`
5230
+ } else {
5231
+ msg.innerHTML = `
5232
+ <div class="alert alert-error">
5233
+ <div style="font-weight:600">⚠️ ${t('未合格')}: ${res.score_pct}% < ${res.pass_threshold}%</div>
5234
+ <div style="margin-top:8px;font-size:12px">${detail}</div>
5235
+ <div style="margin-top:10px;font-size:13px">${t('可重试提交')}</div>
5236
+ </div>`
5237
+ }
5238
+ }
5239
+
5240
+ window.updateGovApplyState = () => {
5241
+ const cb1 = document.getElementById('gov-consent-1')
5242
+ const cb2 = document.getElementById('gov-consent-2')
5243
+ const btn = document.getElementById('gov-submit-btn')
5244
+ if (cb1 && cb2 && btn) {
5245
+ const ok = cb1.checked && cb2.checked && !cb2.disabled
5246
+ btn.disabled = !ok
5247
+ btn.style.opacity = ok ? '1' : '0.5'
5248
+ }
5249
+ }
5250
+
5251
+ window.doSubmitGovernanceApply = async (role, pageLoadedAt) => {
5252
+ const msg = document.getElementById('gov-apply-msg')
5253
+ if (!msg) return
5254
+
5255
+ // 1. 生成 consent_hash(client-side SHA-256)
5256
+ // PR #22 review fix P1-2:与 server 同步的 disclosure_version,server 重建 hash 校验内容(防"任意 16 字符过关")
5257
+ // 文本格式必须与 src/pwa/routes/governance-onboarding.ts expectedConsentHash() 一致
5258
+ const GOVERNANCE_APPLY_DISCLOSURE_VERSION = 'v1.0-2026-06-02'
5259
+ const consentText = 'governance_apply|disclosure=' + GOVERNANCE_APPLY_DISCLOSURE_VERSION + '|role=' + role + '|user=' + state.user.id + '|page_loaded_at=' + pageLoadedAt
5260
+ const encoder = new TextEncoder()
5261
+ const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(consentText))
5262
+ const consentHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2,'0')).join('')
5263
+
5264
+ // 2. Passkey 签发(spec §3.1 Iron-Rule 真人 gate)
5265
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('请通过 Passkey / 生物识别确认...')}</div>`
5266
+ let passkeyToken
5267
+ try {
5268
+ passkeyToken = await requestPasskeyGate('governance_apply', { role, consent_hash: consentHash })
5269
+ } catch (e) {
5270
+ msg.innerHTML = alert$('error', t('Passkey 签发失败:') + ' ' + (e?.message || e))
5271
+ return
5272
+ }
5273
+
5274
+ // 3. POST /api/governance/onboarding/apply
5275
+ msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
5276
+ const res = await POST('/governance/onboarding/apply', {
5277
+ role,
5278
+ consent_hash: consentHash,
5279
+ passkey_sig: passkeyToken,
5280
+ iron_rule_method: 'passkey',
5281
+ page_loaded_at: pageLoadedAt,
5282
+ })
5283
+
5284
+ if (res.error) {
5285
+ if (res.missing_requirements && Array.isArray(res.missing_requirements)) {
5286
+ msg.innerHTML = alert$('error', t('门槛未达标') + ': ' + res.missing_requirements.join(', '))
5287
+ } else {
5288
+ msg.innerHTML = alert$('error', res.error)
5289
+ }
5290
+ return
5291
+ }
5292
+
5293
+ msg.innerHTML = alert$('success', t('申请已提交,等待 maintainer review') + ' (' + (res.application_id || '') + ')')
5294
+ setTimeout(() => navigate('#me'), 2500)
4265
5295
  }
4266
5296
 
4267
5297
  window.doWithdrawVerifierApp = async () => {
@@ -5213,6 +6243,15 @@ function renderWelcome(app) {
5213
6243
  <a class="w-btn-full w-btn-outline" href="https://t.me/webazer" target="_blank" rel="noopener noreferrer" style="display:flex;align-items:center;justify-content:center;text-decoration:none">${T('加入 Telegram', 'Join Telegram')}</a>
5214
6244
  </div>
5215
6245
  </div>
6246
+ <div class="w-card w-join-card">
6247
+ <div class="w-join-card-left">
6248
+ <div class="w-card-title">📧 ${T('邮件联系', 'Email us')}</div>
6249
+ <div class="w-card-desc">${T('合作 / 反馈 / 合规咨询', 'Partnerships / feedback / compliance')}</div>
6250
+ </div>
6251
+ <div class="w-join-card-right">
6252
+ <a class="w-btn-full w-btn-outline" href="mailto:contact@webaz.xyz" style="display:flex;align-items:center;justify-content:center;text-decoration:none;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:13px">contact@webaz.xyz</a>
6253
+ </div>
6254
+ </div>
5216
6255
  <div class="w-card w-join-card">
5217
6256
  <div class="w-join-card-left">
5218
6257
  <div class="w-card-title">🚀 ${T('立即注册', 'Sign up now')}</div>
@@ -5233,7 +6272,7 @@ function renderWelcome(app) {
5233
6272
  <a href="https://github.com/seasonsagents-art/webaz/blob/main/docs/META-RULES-FULL.md" target="_blank" rel="noopener">${T('完整元规则', 'Full Meta-Rules')}</a>
5234
6273
  <a href="https://github.com/seasonsagents-art/webaz" target="_blank" rel="noopener">GitHub</a>
5235
6274
  <a href="#">${T('协议白皮书', 'Whitepaper')}</a>
5236
- <a href="mailto:contact@webaz.xyz">${T('联系', 'Contact')}</a>
6275
+ <a href="mailto:contact@webaz.xyz">📧 contact@webaz.xyz</a>
5237
6276
  </div>
5238
6277
  </footer>
5239
6278
  </div>
@@ -5306,6 +6345,132 @@ function renderWelcome(app) {
5306
6345
  })
5307
6346
  } catch { /* never break the page */ }
5308
6347
  }
6348
+
6349
+ // 2026-06-02 W3.5-B:/governance-onboarding 公开页(无 auth)
6350
+ // 引导潜在 arbitrator/verifier 申请人;phase A 无报酬声明;现有岗位人数 stat
6351
+ async function renderGovernanceOnboarding(app) {
6352
+ const en = window._lang === 'en'
6353
+ const T = (zh, e) => en && e ? e : zh
6354
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
6355
+ app.innerHTML = `
6356
+ ${preLaunchBannerHTML()}
6357
+ <div style="max-width:880px;margin:0 auto;padding:24px 16px 80px;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','PingFang SC','Microsoft YaHei UI',sans-serif">
6358
+ <header style="text-align:center;margin-bottom:40px">
6359
+ <h1 style="font-size:clamp(28px,5vw,36px);margin:0 0 12px;color:#18181B">⚖️ ${T('治理上岗', 'Governance Onboarding')}</h1>
6360
+ <p style="color:#52525B;font-size:16px;margin:0">${T('phase A:公共贡献,无报酬', 'Phase A: public contribution, no compensation')}</p>
6361
+ </header>
6362
+
6363
+ <section style="background:#fff;border:1px solid #e4e4e7;border-radius:12px;padding:24px;margin-bottom:24px">
6364
+ <h2 style="font-size:22px;margin:0 0 16px;color:#18181B">📊 ${T('当前在岗', 'Currently active')}</h2>
6365
+ <div id="gov-stats" style="min-height:80px">
6366
+ <div style="color:#a1a1aa;font-size:14px;text-align:center;padding:20px">${T('加载中…', 'Loading…')}</div>
6367
+ </div>
6368
+ </section>
6369
+
6370
+ <section style="background:#fee2e2;border:1px solid #ef4444;border-radius:12px;padding:20px;margin-bottom:24px">
6371
+ <h3 style="margin:0 0 12px;color:#991b1b;font-size:18px">⚠️ ${T('重要:这不是赚钱机会', 'Important: this is NOT an income opportunity')}</h3>
6372
+ <ul style="margin:0;padding-left:20px;color:#7f1d1d;line-height:1.8;font-size:14px">
6373
+ <li>${T('phase A 不发任何现金 / WAZ 报酬', 'No cash / WAZ compensation in phase A')}</li>
6374
+ <li>${T('治理是公共贡献,非雇佣关系', 'Governance is public contribution, not employment')}</li>
6375
+ <li>${T('履职记录关系层永久保留,估值层 phase D DAO 决定', 'Performance records kept in relationship layer (permanent), valuation by phase D DAO')}</li>
6376
+ <li>${T('误以为 income 来源会失望 — 请先确认意愿', 'Mistaking it as income source will disappoint — confirm intent first')}</li>
6377
+ </ul>
6378
+ </section>
6379
+
6380
+ <section style="background:#fff;border:1px solid #e4e4e7;border-radius:12px;padding:24px;margin-bottom:24px">
6381
+ <h3 style="margin:0 0 16px;color:#18181B;font-size:18px">📋 ${T('资格门槛', 'Eligibility')}</h3>
6382
+ <div id="gov-thresholds" style="color:#52525B;font-size:14px;line-height:1.8">
6383
+ <div style="color:#a1a1aa;text-align:center;padding:12px">${T('加载阈值…', 'Loading thresholds…')}</div>
6384
+ </div>
6385
+ </section>
6386
+
6387
+ <section style="background:#dbeafe;border:1px solid #3b82f6;border-radius:12px;padding:20px;margin-bottom:24px">
6388
+ <h3 style="margin:0 0 12px;color:#1e40af;font-size:18px">🛡️ ${T('履职范围', 'Duties')}</h3>
6389
+ <ul style="margin:0;padding-left:20px;color:#1e3a8a;line-height:1.8;font-size:14px">
6390
+ <li><strong>arbitrator</strong> — ${T('仲裁纠纷 dispute(Iron-Rule 真人 Passkey 必备)', 'Arbitrate disputes (Iron-Rule requires real human Passkey)')}</li>
6391
+ <li><strong>verifier</strong> — ${T('验证 claim(同 Iron-Rule)', 'Verify claims (same Iron-Rule)')}</li>
6392
+ <li>${T('卸任 30 天冷却 / outlier 自动 deactivate / 申诉路径', 'Resignation 30d cooldown / outlier auto-deactivate / appeal path')}</li>
6393
+ </ul>
6394
+ </section>
6395
+
6396
+ <section style="background:#fef3c7;border:1px solid #f59e0b;border-radius:12px;padding:20px;margin-bottom:24px">
6397
+ <h3 style="margin:0 0 12px;color:#92400e;font-size:18px">🚀 ${T('如何申请', 'How to apply')}</h3>
6398
+ <ol style="margin:0;padding-left:20px;color:#78350f;line-height:1.8;font-size:14px">
6399
+ <li>${T('登录(若未登录)→ 进入 #me', 'Log in (if needed) → go to #me')}</li>
6400
+ <li>${T('达成资格门槛(Passkey + 30 天 + 5 笔订单 + reputation)', 'Meet thresholds (Passkey + 30d + 5 orders + reputation)')}</li>
6401
+ <li>${T('点 [申请审核员] 入口(verifier)', 'Click [Apply to be verifier] entry')}</li>
6402
+ <li>${T('阅读披露 + 双勾选 + 8s 反诱导延迟 + Passkey 签发', 'Read disclosure + dual checkbox + 8s anti-railroad delay + Passkey sign')}</li>
6403
+ <li>${T('完成 onboarding(学习包 + 案例 + 80% 测试)', 'Complete onboarding (study pack + cases + 80% quiz)')}</li>
6404
+ <li>${T('maintainer Iron-Rule 签发激活', 'Maintainer Iron-Rule signing activation')}</li>
6405
+ </ol>
6406
+ </section>
6407
+
6408
+ <section style="border-top:1px solid #e4e4e7;padding-top:20px;color:#71717a;font-size:13px;line-height:1.7">
6409
+ <p style="margin:0 0 8px"><strong>${T('完整规范', 'Full spec')}:</strong></p>
6410
+ <ul style="margin:0;padding-left:20px">
6411
+ <li><a href="https://github.com/seasonsagents-art/webaz/blob/main/docs/GOVERNANCE-ONBOARDING.md" target="_blank" rel="noopener" style="color:#1d4ed8">GOVERNANCE-ONBOARDING.md</a> — ${T('资格 / 流程 / 卸任 / 申诉', 'eligibility / flow / resignation / appeal')}</li>
6412
+ <li><a href="https://github.com/seasonsagents-art/webaz/blob/main/docs/ARBITRATION-PLAYBOOK.md" target="_blank" rel="noopener" style="color:#1d4ed8">ARBITRATION-PLAYBOOK.md</a> — ${T('arbitrator 决策框架 + 5 模拟案例', 'arbitrator decision framework + 5 simulated cases')}</li>
6413
+ <li>${T('机读 JSON 端点', 'Machine-readable JSON endpoint')}: <code style="background:#f4f4f5;padding:2px 6px;border-radius:4px">/api/governance/onboarding-stats</code></li>
6414
+ </ul>
6415
+ </section>
6416
+ </div>
6417
+ `
6418
+
6419
+ try {
6420
+ const r = await fetch('/api/governance/onboarding-stats', { signal: AbortSignal.timeout(10000) })
6421
+ const j = await r.json()
6422
+ const stats = document.getElementById('gov-stats')
6423
+ const thr = document.getElementById('gov-thresholds')
6424
+ stats.innerHTML = `
6425
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px">
6426
+ <div style="text-align:center;padding:16px;background:#f4f4f5;border-radius:8px">
6427
+ <div style="font-size:32px;font-weight:700;color:#18181B">${j.active_arbitrators || 0}</div>
6428
+ <div style="font-size:13px;color:#71717a;margin-top:4px">${T('在岗 arbitrator', 'Active arbitrators')}</div>
6429
+ </div>
6430
+ <div style="text-align:center;padding:16px;background:#f4f4f5;border-radius:8px">
6431
+ <div style="font-size:32px;font-weight:700;color:#18181B">${j.active_verifiers || 0}</div>
6432
+ <div style="font-size:13px;color:#71717a;margin-top:4px">${T('在岗 verifier', 'Active verifiers')}</div>
6433
+ </div>
6434
+ <div style="text-align:center;padding:16px;background:#f4f4f5;border-radius:8px">
6435
+ <div style="font-size:32px;font-weight:700;color:#18181B">${j.pending_applications || 0}</div>
6436
+ <div style="font-size:13px;color:#71717a;margin-top:4px">${T('待审申请', 'Pending applications')}</div>
6437
+ </div>
6438
+ </div>
6439
+ <p style="text-align:center;color:#a1a1aa;font-size:12px;margin:12px 0 0">${T('phase A 暂多为 fixture 数据;launch 后接受真实申请', 'Phase A counts are mostly fixtures; real applications post-launch')}</p>
6440
+ `
6441
+ // 真实 enforced 门槛(role-split),来自 onboarding-stats.eligibility — 与 server 代码同步
6442
+ const el = j.eligibility || {}
6443
+ const arb = el.arbitrator || {}
6444
+ const ver = el.verifier || {}
6445
+ const yes = T('需', 'required')
6446
+ thr.innerHTML = `
6447
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
6448
+ <div style="background:#f4f4f5;border-radius:8px;padding:12px">
6449
+ <div style="font-weight:700;margin-bottom:6px">⚖️ Arbitrator</div>
6450
+ <div style="font-size:13px;line-height:1.9;color:#3f3f46">
6451
+ 📅 ${T('注册', 'Registered')} ≥ <strong>${esc(arb.registration_days ?? '?')}</strong> ${T('天', 'd')}<br>
6452
+ 📦 ${T('完成订单', 'Completed orders')} ≥ <strong>${esc(arb.completed_orders ?? '?')}</strong><br>
6453
+ ⭐ ${T('信誉', 'Reputation')} ≥ <strong>${esc(arb.reputation ?? '?')}</strong><br>
6454
+ 💰 ${T('钱包余额', 'Balance')} ≥ <strong>${esc(arb.balance_waz ?? '?')}</strong> WAZ<br>
6455
+ ✉️ ${T('邮箱验证', 'Email verified')} · 🚫 ${T('零仲裁判输', 'Zero disputes lost')} · ✅ ${T('未曾暂停', 'Never suspended')}
6456
+ </div>
6457
+ </div>
6458
+ <div style="background:#f4f4f5;border-radius:8px;padding:12px">
6459
+ <div style="font-weight:700;margin-bottom:6px">🔍 Verifier</div>
6460
+ <div style="font-size:13px;line-height:1.9;color:#3f3f46">
6461
+ 📅 ${T('注册', 'Registered')} ≥ <strong>${esc(ver.registration_days ?? '?')}</strong> ${T('天', 'd')}<br>
6462
+ 📦 ${T('完成订单', 'Completed orders')} ≥ <strong>${esc(ver.completed_orders ?? '?')}</strong><br>
6463
+ ✉️ ${T('邮箱验证', 'Email verified')} · 🚫 ${T('零仲裁判输', 'Zero disputes lost')} · ✅ ${T('未曾暂停', 'Never suspended')}
6464
+ </div>
6465
+ </div>
6466
+ </div>
6467
+ <div style="margin-top:10px;font-size:13px;color:#3f3f46">📝 ${T('两岗均需 onboarding 测试合格分', 'Both roles: onboarding quiz pass score')} ≥ <strong>${esc(j.quiz_pass_score ?? 80)}%</strong></div>
6468
+ `
6469
+ } catch (e) {
6470
+ document.getElementById('gov-stats').innerHTML = `<div style="color:#dc2626;text-align:center;padding:20px;font-size:14px">${T('加载失败', 'Load failed')}: ${esc(e.message || 'unknown')}</div>`
6471
+ }
6472
+ }
6473
+
5309
6474
  function renderRule(num, text) {
5310
6475
  const [zh, en] = text.includes(' / ') ? text.split(' / ') : [text, '']
5311
6476
  return `<div class="w-rule w-rule-item">
@@ -5549,11 +6714,11 @@ async function renderPromoter(app) {
5549
6714
  const pending = atomic.score?.pending_score || 0
5550
6715
  const _mlmMax = Number(state.user?.region_max_levels ?? 1)
5551
6716
  const _kpiRestricted = _mlmMax <= 1
5552
- // 两个语义独立的 gate(三级奖励 ≠ PV 双轨系统):
6717
+ // 两个语义独立的 gate(三级奖励 ≠ PV 双轨系统)—— 2026-06-04 已解耦:
5553
6718
  // - _kpiRestricted (max ≤ 1):紧张地区 — KPI 改时间线 / team 隐 WAZ
5554
- // - _pvAllowed (max = 3):仅 DSA 框架国家 — 显示双轨/tier/对碰/左右码
5555
- // 中国/美国/欧盟等 max=2 地区:commission L1+L2 允许,但 PV 双轨系统必须禁
5556
- const _pvAllowed = _mlmMax >= 3
6719
+ // - _pvAllowed:PV 双轨/对碰系统是否开启,读 region_pv_enabled(独立旋钮,不再绑 max≥3
6720
+ // commission 层级(max_levels) PV 系统(pv_enabled) 分离:可单独开某辖区到 L2/L3 PV 仍关。
6721
+ const _pvAllowed = Number(state.user?.region_pv_enabled ?? 0) === 1
5557
6722
  // 紧张地区:拼装最近奖励时间线(commission + 对碰 binary 混合,按时间倒序取 5 条)
5558
6723
  let kpiBar = ''
5559
6724
  if (_kpiRestricted) {
@@ -5892,8 +7057,8 @@ async function renderPromoter(app) {
5892
7057
  // 最右侧地区显示(所有模式都加)
5893
7058
  const _userRegion = state.user?.region || 'global'
5894
7059
  const _regionChip = `<span style="font-size:11px;color:#6b7280;white-space:nowrap;font-weight:400">${regionLabel(_userRegion)}</span>`
5895
- // PV 双轨系统:只在 _pvAllowed (max=3) 才显示完整 tier/对碰/弱腿;
5896
- // 中国/美国/欧盟等 max=2 地区走精简卡(同 max≤1)
7060
+ // PV 双轨系统:只在 _pvAllowed(region_pv_enabled=1,2026-06-04 解耦,独立于 max_levels)才显示完整 tier/对碰/弱腿;
7061
+ // 未开启 PV 的地区走精简卡(分数仍后台计算累积,待 pv_enabled 开启/迁移后兑现)
5897
7062
  const atomicSection = atomic.left_invite_url
5898
7063
  ? (!_pvAllowed
5899
7064
  ? `<div style="margin-bottom:12px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:14px">
@@ -8518,6 +9683,128 @@ async function renderFeedback(app) {
8518
9683
  `, 'me')
8519
9684
  }
8520
9685
 
9686
+ // ─── RFC-004 build feedback(用→建):浮动入口弹窗 + 闭环视图 ───────────
9687
+ window.openBuildFeedback = () => {
9688
+ if (!state.user) { toast$(t('请先登录')); navigate('#login'); return }
9689
+ const page = (location.hash || '#/').split('?')[0] // 非 PII:只取当前页面路由作上下文
9690
+ const html = `
9691
+ <div class="js-modal" style="background:rgba(0,0,0,0.6);position:fixed;inset:0;z-index:1000;display:flex;align-items:flex-end;justify-content:center" onclick="this.remove()">
9692
+ <div style="background:#fff;width:100%;max-width:560px;border-radius:16px 16px 0 0;padding:20px;max-height:82vh;overflow-y:auto" onclick="event.stopPropagation()">
9693
+ <h2 style="font-size:16px;font-weight:700;margin-bottom:4px">💬 ${t('反馈 / 建议')}</h2>
9694
+ <div style="font-size:11px;color:#9ca3af;margin-bottom:12px">${t('帮我们改进 WebAZ。会自动附上当前页面')} <code style="background:#f3f4f6;padding:1px 5px;border-radius:4px">${escHtml(page)}</code> ${t('作为上下文(不含个人信息)')}</div>
9695
+ <div class="form-group">
9696
+ <label class="form-label">${t('类型')}</label>
9697
+ <select class="form-control" id="bfb-type">
9698
+ <option value="ux_issue">${t('体验问题(用着别扭)')}</option>
9699
+ <option value="bug">${t('Bug(报错 / 坏了)')}</option>
9700
+ <option value="proposal">${t('改进提案(建设 · 需绑 Passkey,被采纳记共建信誉)')}</option>
9701
+ </select>
9702
+ </div>
9703
+ <div class="form-group">
9704
+ <label class="form-label">${t('描述')} *</label>
9705
+ <textarea class="form-control" id="bfb-text" rows="5" maxlength="2000" placeholder="${t('发生了什么 / 你的想法(≥5 字)')}"></textarea>
9706
+ </div>
9707
+ <div id="bfb-msg" style="margin:8px 0"></div>
9708
+ <div style="display:flex;gap:8px">
9709
+ <button class="btn btn-gray" style="flex:1" onclick="this.closest('.js-modal').remove()">${t('取消')}</button>
9710
+ <button class="btn btn-primary" style="flex:1" onclick="submitBuildFeedback('${escHtml(page)}')">${t('提交')}</button>
9711
+ </div>
9712
+ <div style="text-align:center;margin-top:12px">
9713
+ <a onclick="this.closest('.js-modal').remove();navigate('#build-feedback')" style="font-size:12px;color:#4f46e5;cursor:pointer">${t('查看我的反馈进度 →')}</a>
9714
+ </div>
9715
+ </div>
9716
+ </div>`
9717
+ const div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild)
9718
+ }
9719
+
9720
+ window.submitBuildFeedback = async (page) => {
9721
+ const type = document.getElementById('bfb-type').value
9722
+ const text = document.getElementById('bfb-text').value.trim()
9723
+ const msg = document.getElementById('bfb-msg')
9724
+ if (text.length < 5) { msg.innerHTML = alert$('error', t('请至少写 5 个字')); return }
9725
+ msg.innerHTML = loading$()
9726
+ const res = await POST('/build-feedback', { type, text, area: page, scene: [{ page }] })
9727
+ if (res.error) { msg.innerHTML = alert$('error', res.error); return } // 如 proposal 无 Passkey → 后端返回引导
9728
+ document.querySelector('.js-modal')?.remove()
9729
+ toast$(res.status === 'duplicate' ? t('已有类似建议,已合并 🙌') : t('反馈已提交,谢谢! 🙏'))
9730
+ }
9731
+
9732
+ async function renderMyBuildFeedback(app) {
9733
+ if (!state.user) { renderLogin(); return }
9734
+ app.innerHTML = shell(loading$(), 'me')
9735
+ const r = await GET('/build-feedback/mine')
9736
+ const list = r?.feedback || []
9737
+ const SL = () => ({ received: t('已收到'), triaged: t('已分类'), in_progress: t('处理中'), resolved: t('已采纳 ✅'), declined: t('未采纳'), duplicate: t('已合并') })
9738
+ const SC = { received: '#d97706', triaged: '#4f46e5', in_progress: '#4f46e5', resolved: '#16a34a', declined: '#6b7280', duplicate: '#6b7280' }
9739
+ const TL = () => ({ ux_issue: t('体验问题'), bug: 'Bug', proposal: t('提案') })
9740
+ const rows = list.length === 0
9741
+ ? `<div style="text-align:center;padding:40px;color:#9ca3af"><div style="font-size:48px;margin-bottom:8px">💬</div><div style="font-size:13px">${t('还没有反馈 — 用着有问题随时点左下角 💬')}</div></div>`
9742
+ : list.map(f => `
9743
+ <div class="card" style="padding:12px;margin-bottom:8px">
9744
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
9745
+ <span style="font-size:11px;color:#6b7280">${TL()[f.type] || f.type}${f.area ? ` · <code style="background:#f3f4f6;padding:1px 4px;border-radius:4px">${escHtml(String(f.area))}</code>` : ''}</span>
9746
+ <span style="font-size:10px;font-weight:600;color:#fff;background:${SC[f.status] || '#6b7280'};padding:2px 8px;border-radius:99px;white-space:nowrap">${SL()[f.status] || f.status}</span>
9747
+ </div>
9748
+ <div style="font-size:13px;color:#374151">${escHtml(String(f.body || ''))}</div>
9749
+ ${f.resolution ? `<div style="font-size:11px;color:#6b7280;margin-top:6px;padding-top:6px;border-top:1px solid #f3f4f6">↳ ${escHtml(String(f.resolution))}</div>` : ''}
9750
+ ${Number(f.credited_points) > 0 ? `<div style="font-size:11px;color:#16a34a;margin-top:4px">🏅 +${f.credited_points} ${t('共建信誉')}</div>` : ''}
9751
+ </div>`).join('')
9752
+ app.innerHTML = shell(`
9753
+ <h1 class="page-title">💬 ${t('我的反馈')}</h1>
9754
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('你提的问题 / 建议进展 — 被采纳的提案会记入共建信誉')}</div>
9755
+ <button class="btn btn-primary btn-sm" style="margin-bottom:12px" onclick="openBuildFeedback()">+ ${t('新反馈 / 建议')}</button>
9756
+ ${rows}
9757
+ `, 'me')
9758
+ }
9759
+
9760
+ // RFC-006 Gap 2:贡献者【自查】看板 — KPI / 等级 / 限制 + 申诉。仅自查(私密),建设信誉独立池不解锁交易角色。
9761
+ async function renderMyContributions(app) {
9762
+ if (!state.user) { renderLogin(); return }
9763
+ app.innerHTML = shell(loading$(), 'me')
9764
+ const p = await GET('/build-reputation/me')
9765
+ if (!p || p.error) { app.innerHTML = shell(`<div class="card" style="padding:16px;color:#b91c1c">${escHtml(p?.error || t('加载失败'))}</div>`, 'me'); return }
9766
+ const lang = window._lang === 'zh' ? 'zh' : 'en'
9767
+ const tier = p.tier || {}
9768
+ const k = p.kpi || {}
9769
+ const stat = (label, val) => `<div class="card" style="padding:10px;text-align:center"><div style="font-size:20px;font-weight:700;color:#4f46e5">${val ?? 0}</div><div style="font-size:11px;color:#6b7280">${label}</div></div>`
9770
+ const provRows = (p.provenance || []).map(x => {
9771
+ const m = { human: t('真人'), ai_assisted: t('AI 辅助'), ai_authored: t('AI 主笔'), unspecified: t('未声明') }
9772
+ return `<span style="font-size:11px;color:#6b7280;margin-right:10px">${m[x.provenance] || x.provenance}: <b>${x.count}</b></span>`
9773
+ }).join('')
9774
+ const restr = (p.restrictions || [])
9775
+ const restrHtml = restr.length === 0
9776
+ ? `<div style="font-size:12px;color:#16a34a">✓ ${t('无限制')}</div>`
9777
+ : restr.map(r => `<div class="card" style="padding:10px;margin-bottom:6px;border-left:3px solid #dc2626">
9778
+ <div style="font-size:12px;color:#dc2626;font-weight:600">${escHtml(String(r.reason_code || ''))} · ${escHtml(String(r.severity || ''))}</div>
9779
+ <div style="font-size:12px;color:#374151">${escHtml(String(r.reason_detail || ''))}</div>
9780
+ <div style="font-size:11px;color:#6b7280;margin-top:4px">${t('如有异议可申诉')} → <code>/api/me/agents/strikes/${escHtml(String(r.id))}/appeal</code></div>
9781
+ </div>`).join('')
9782
+ app.innerHTML = shell(`
9783
+ <h1 class="page-title">🛠️ ${t('我的共建')}</h1>
9784
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('你的建设贡献(独立于交易信誉 — 不影响 verifier/仲裁准入)')}</div>
9785
+ <div class="card" style="padding:14px;margin-bottom:12px;background:linear-gradient(135deg,#eef2ff,#faf5ff)">
9786
+ <div style="display:flex;justify-content:space-between;align-items:center">
9787
+ <div><div style="font-size:11px;color:#6b7280">${t('建设等级')}</div><div style="font-size:18px;font-weight:700">${lang === 'zh' ? (tier.label_zh || '') : (tier.label_en || '')}</div></div>
9788
+ <div style="text-align:right"><div style="font-size:11px;color:#6b7280">${t('建设积分')}</div><div style="font-size:18px;font-weight:700;color:#4f46e5">${p.build_points ?? 0}</div></div>
9789
+ </div>
9790
+ <div style="font-size:11px;color:#6b7280;margin-top:8px">${t('当前可参与')}:${lang === 'zh' ? (tier.caps_zh || '') : (tier.caps_en || '')}${tier.next_at ? ` · ${t('下一级')} @ ${tier.next_at}` : ''}</div>
9791
+ </div>
9792
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px">
9793
+ ${stat(t('认领中'), k.tasks_claimed)}
9794
+ ${stat(t('审核中'), k.tasks_in_review)}
9795
+ ${stat(t('已验收'), k.tasks_done)}
9796
+ ${stat(t('提反馈'), k.feedback_submitted)}
9797
+ ${stat(t('被采纳'), k.feedback_accepted)}
9798
+ ${stat(t('提任务'), k.tasks_created)}
9799
+ </div>
9800
+ ${provRows ? `<div class="card" style="padding:10px;margin-bottom:12px"><div style="font-size:11px;color:#6b7280;margin-bottom:4px">${t('署名构成(自报)')}</div>${provRows}</div>` : ''}
9801
+ ${!p.reward_anchored ? `<div class="card" style="padding:10px;margin-bottom:12px;border-left:3px solid #d97706"><div style="font-size:12px;color:#92400e">${t('未绑 Passkey:贡献可受理致谢,但需绑定真人锚点才记入建设信誉。')}</div></div>` : ''}
9802
+ <div style="font-size:13px;font-weight:600;margin:14px 0 6px">${t('限制与申诉')}</div>
9803
+ ${restrHtml}
9804
+ <div style="font-size:11px;color:#9ca3af;margin-top:14px">${t('看板仅自己可见,不做公开排行。')}</div>
9805
+ `, 'me')
9806
+ }
9807
+
8521
9808
  // W7 客服 ticket-thread 视图
8522
9809
  const TICKET_TYPE_META = {
8523
9810
  created: { icon: '🛟', title: '新建工单', border: '#d97706' },
@@ -9572,6 +10859,11 @@ window.sharePromoLink = async (productId, title) => {
9572
10859
  // 商品级 verified 校验 + 拿/建 shareable,走 /s/<id> 短链
9573
10860
  const res = await POST(`/products/${productId}/get-or-create-share`)
9574
10861
  if (res.error) {
10862
+ if (res.error === 'rewards_opt_in_required') {
10863
+ const msg = (window._lang === 'en' ? res.message_en : res.message_zh) || res.message_zh || res.error
10864
+ const missing = Array.isArray(res.missing_requirements) ? `\n\n${t('待补')}: ${res.missing_requirements.join(', ')}` : ''
10865
+ return alert(`⚠ ${msg}${missing}\n\n${t('前往 #me 申请共建身份')}`)
10866
+ }
9575
10867
  return alert(`⚠ ${res.error}${res.completed_orders === 0 ? '\n' + t('完成该商品的购买后再分享') : ''}`)
9576
10868
  }
9577
10869
  const link = `${location.origin}${res.short_url}`
@@ -19303,6 +20595,45 @@ function buildDisputeHtml(dispute, user) {
19303
20595
  <button class="btn btn-primary btn-sm" style="width:auto" onclick="handleDisputeRespond('${dispute.id}','${dispute.order_id}')">提交反驳证据</button>
19304
20596
  </div>` : ''
19305
20597
 
20598
+ // ── 仲裁员:暂停 / 恢复自动判定时钟(playbook §2.1)────
20599
+ // 补证据期 > 48h 时必须显式暂停,避免被协议层 48h 沉默判架空
20600
+ const isPaused = dispute.auto_judge_paused_until && dispute.auto_judge_paused_until * 1000 > Date.now()
20601
+ const pauseSection = isArbitrator && dispute.status !== 'resolved' && !dispute.ruling_type ? (
20602
+ isPaused ? `
20603
+ <div style="margin-top:12px;background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;padding:12px">
20604
+ <div style="font-size:13px;font-weight:600;color:#92400e;margin-bottom:6px">⏸️ ${t('自动判定时钟已冻结')}</div>
20605
+ <div style="font-size:12px;color:#78350f;line-height:1.6;margin-bottom:8px">
20606
+ ${t('到期')}: ${new Date(dispute.auto_judge_paused_until * 1000).toLocaleString()}<br>
20607
+ ${t('理由')}: ${escHtml(dispute.auto_judge_pause_reason || '—')}
20608
+ </div>
20609
+ <button class="btn btn-sm btn-outline" style="font-size:12px" onclick="handleArbitratorResume('${dispute.id}')">▶️ ${t('立即解冻(直接进入裁决/补证后)')}</button>
20610
+ </div>
20611
+ ` : `
20612
+ <div style="margin-top:12px">
20613
+ <button class="btn btn-outline btn-sm" style="width:auto;font-size:12px" onclick="(function(){var s=document.getElementById('pause-section-${dispute.id}');s.style.display=s.style.display==='none'?'':'none'})()">⏸️ ${t('暂停自动判定时钟')}</button>
20614
+ <div id="pause-section-${dispute.id}" style="display:none;margin-top:10px;background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;padding:12px">
20615
+ <div style="font-size:12px;color:#92400e;line-height:1.6;margin-bottom:8px">${t('playbook §2.1:补证据期 > 48h 时显式调用,冻结协议自动判定时钟。最大窗口 7 天。')}</div>
20616
+ <div id="pause-msg-${dispute.id}"></div>
20617
+ <div style="margin-bottom:8px">
20618
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">${t('暂停理由(≥10 字符,公开 audit_log)')}</label>
20619
+ <textarea class="form-control" id="pause-reason-${dispute.id}" rows="3" placeholder="${t('例:已通知 buyer 补开箱视频,等 72h')}" style="font-size:13px"></textarea>
20620
+ </div>
20621
+ <div style="margin-bottom:10px">
20622
+ <label style="font-size:12px;color:#6b7280;display:block;margin-bottom:4px">${t('冻结到期')}</label>
20623
+ <select class="form-control" id="pause-hours-${dispute.id}" style="font-size:13px">
20624
+ <option value="24">24h</option>
20625
+ <option value="48" selected>48h</option>
20626
+ <option value="72">72h</option>
20627
+ <option value="120">5d</option>
20628
+ <option value="168">7d ${t('(最大)')}</option>
20629
+ </select>
20630
+ </div>
20631
+ <button class="btn btn-primary btn-sm" style="width:auto" onclick="handleArbitratorPause('${dispute.id}')">⏸️ ${t('暂停时钟')}</button>
20632
+ </div>
20633
+ </div>
20634
+ `
20635
+ ) : ''
20636
+
19306
20637
  // ── 仲裁员:发起补充证据请求 ──────────────────
19307
20638
  const requestEvidenceSection = isArbitrator && dispute.status !== 'resolved' ? `
19308
20639
  <div style="margin-top:12px">
@@ -19435,6 +20766,7 @@ function buildDisputeHtml(dispute, user) {
19435
20766
  ${respondSection}
19436
20767
  ${partyAddEvidenceSection}
19437
20768
  ${uploadBlobSection}
20769
+ ${pauseSection}
19438
20770
  ${requestEvidenceSection}
19439
20771
  ${arbitrateSection}
19440
20772
 
@@ -19707,6 +21039,30 @@ window.handleRequestEvidence = async (disputeId) => {
19707
21039
  setTimeout(() => renderDisputeDetail(document.getElementById('app'), disputeId), 1200)
19708
21040
  }
19709
21041
 
21042
+ // task #1093 stage 6: arbitrator pause / resume auto-judge clock (playbook §2.1)
21043
+ window.handleArbitratorPause = async (disputeId) => {
21044
+ const reason = document.getElementById('pause-reason-' + disputeId)?.value?.trim()
21045
+ const hours = Number(document.getElementById('pause-hours-' + disputeId)?.value) || 48
21046
+ const msgEl = document.getElementById('pause-msg-' + disputeId)
21047
+ if (!reason || reason.length < 10) {
21048
+ msgEl.innerHTML = alert$('error', t('暂停理由至少 10 字符'))
21049
+ return
21050
+ }
21051
+ const untilTs = Math.floor(Date.now() / 1000) + hours * 3600
21052
+ msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('提交中...')}</div>`
21053
+ const res = await POST(`/disputes/${disputeId}/arbitrator-pause-auto-judge`, { reason, until_ts: untilTs })
21054
+ if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
21055
+ msgEl.innerHTML = alert$('success', t('自动判定时钟已冻结'))
21056
+ setTimeout(() => renderDisputeDetail(document.getElementById('app'), disputeId), 1200)
21057
+ }
21058
+
21059
+ window.handleArbitratorResume = async (disputeId) => {
21060
+ if (!confirm(t('确认立即解冻自动判定时钟? 协议层 48h 沉默判定将重新生效。'))) return
21061
+ const res = await POST(`/disputes/${disputeId}/arbitrator-resume-auto-judge`, {})
21062
+ if (res.error) { alert(res.error); return }
21063
+ renderDisputeDetail(document.getElementById('app'), disputeId)
21064
+ }
21065
+
19710
21066
  // 当事方提交证据
19711
21067
  window.handleSubmitEvidence = async (requestId, disputeId) => {
19712
21068
  const type = document.getElementById(`er-type-${requestId}`)?.value
@@ -23553,8 +24909,7 @@ async function renderWallet(app) {
23553
24909
 
23554
24910
  <!-- 卖家收入分类速览(仅 seller 显示)- 2026-05-24 对碰列只在 PV 允许地区显示 -->
23555
24911
  ${state.user?.role === 'seller' ? (() => {
23556
- const _maxLvl = getMaxLevels()
23557
- const _pvOK = _maxLvl >= 3
24912
+ const _pvOK = Number(state.user?.region_pv_enabled ?? 0) === 1 // 2026-06-04 解耦:PV 列读 pv_enabled,不再绑 max≥3
23558
24913
  const cols = _pvOK ? 4 : 3
23559
24914
  return `
23560
24915
  <div class="card" style="margin-bottom:12px;padding:14px">
@@ -24548,25 +25903,9 @@ if ('serviceWorker' in navigator) {
24548
25903
  navigator.serviceWorker.register('/sw.js').catch(() => {})
24549
25904
  }
24550
25905
 
24551
- // PWA install prompt: 捕获 beforeinstallprompt,等待用户主动触发
24552
- let _deferredInstallPrompt = null
24553
- window.addEventListener('beforeinstallprompt', (e) => {
24554
- e.preventDefault()
24555
- _deferredInstallPrompt = e
24556
- // 触发一次重渲,让 navbar install 按钮出现
24557
- try { if (typeof route === 'function') route() } catch {}
24558
- })
24559
- window.addEventListener('appinstalled', () => {
24560
- _deferredInstallPrompt = null
24561
- try { if (typeof route === 'function') route() } catch {}
24562
- })
24563
- window.canInstallPWA = () => !!_deferredInstallPrompt
24564
- window.doInstallPWA = async () => {
24565
- if (!_deferredInstallPrompt) return
24566
- _deferredInstallPrompt.prompt()
24567
- try { await _deferredInstallPrompt.userChoice } catch {}
24568
- _deferredInstallPrompt = null
24569
- }
25906
+ // 上面 line ~25263 已注册 beforeinstallprompt 监听 + doInstallPWA(带 iOS/通用引导 fallback);
25907
+ // 此处只补一个 canInstallPWA helper 给 navbar 按钮判 visibility 用。
25908
+ window.canInstallPWA = () => !!window._installPromptEvent || isIOS()
24570
25909
 
24571
25910
  // 离线状态全局 banner
24572
25911
  function ensureOfflineBanner() {
@@ -27834,9 +29173,20 @@ async function renderLeaderboard(app) {
27834
29173
  // 2026-05-23 隐私第一原理:buyers/sellers 榜不再展示 GMV 金额(运营状态私密)
27835
29174
  const tabBtn = (k, label) => `<button onclick="setLeaderboardKind('${k}')" style="padding:6px 14px;border-radius:99px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid ${kind===k?'#4f46e5':'#e5e7eb'};background:${kind===k?'#eef2ff':'#fff'};color:${kind===k?'#4338ca':'#6b7280'}">${label}</button>`
27836
29175
 
29176
+ // #1080 audit: spec §6.1 mandatory banner for arbitrators/verifiers leaderboards
29177
+ // (governance roles where reward-distribution misinterpretation is highest risk)
29178
+ const isGovernanceLeaderboard = kind === 'arbitrators' || kind === 'verifiers'
29179
+ const governanceBanner = isGovernanceLeaderboard ? `
29180
+ <div style="margin-bottom:14px;padding:12px;background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;font-size:12px;color:#7c2d12;line-height:1.6">
29181
+ <div style="font-weight:600;margin-bottom:4px">📊 ${t('本榜单仅展示履职数据,不构成 token / 经济利益分配')}</div>
29182
+ <div style="opacity:0.85">${t('Reward distribution 机制由 phase D first DAO 决定。详 docs/GOVERNANCE-LEADERBOARD-SPEC.md。')}</div>
29183
+ </div>
29184
+ ` : ''
29185
+
27837
29186
  document.getElementById('lb-root').innerHTML = `
27838
29187
  <h2 style="font-size:18px;font-weight:700;margin-bottom:6px">🏆 ${t('排行榜')}</h2>
27839
29188
  <div style="font-size:11px;color:#6b7280;margin-bottom:14px;line-height:1.5">${t('协议不分发流量;越多真实推荐 / 点赞 → 越靠前')}</div>
29189
+ ${governanceBanner}
27840
29190
  <div style="display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap">
27841
29191
  ${tabBtn('products', '📦 ' + t('商品榜'))}
27842
29192
  ${tabBtn('value_products', '💎 ' + t('性价比榜'))}
@@ -27901,16 +29251,21 @@ async function renderLeaderboard(app) {
27901
29251
  `
27902
29252
  }).join('') :
27903
29253
  kind === 'arbitrators' ? items.map((u, i) => {
27904
- const fs = u.fairness_score != null ? (u.fairness_score * 100).toFixed(1) + '%' : '—'
27905
- const fsColor = u.fairness_score != null && u.fairness_score >= 0.8 ? '#16a34a' : u.fairness_score != null && u.fairness_score >= 0.5 ? '#d97706' : u.fairness_score == null ? '#9ca3af' : '#dc2626'
29254
+ // #1080 audit: spec §4 fairness 阈值 < 10 cases( accuracy < 5 更严,因为
29255
+ // fairness 公式需要更多样本才统计稳定)。当前 arbitrator metric fairness,所以用 10。
29256
+ const casesCount = Number(u.cases_count || 0)
29257
+ const isInsufficient = casesCount < 10
29258
+ const fs = isInsufficient ? t('insufficient data') : (u.fairness_score != null ? (u.fairness_score * 100).toFixed(1) + '%' : '—')
29259
+ // spec §3:无 "best/worst" 价值判断 — 中性灰色显示,不再 green/yellow/red 暗示评级
29260
+ const fsColor = isInsufficient ? '#9ca3af' : '#374151'
27906
29261
  const totalRatings = Number(u.total_yes || 0) + Number(u.total_no || 0)
27907
29262
  return `
27908
- <div class="card" style="padding:12px;margin-bottom:8px;display:flex;gap:10px;align-items:center" title="${t('仲裁员公平评价:判决获得 👍 占总评价比例')}">
27909
- <div style="font-size:24px;font-weight:800;color:${i<3?'#dc2626':'#9ca3af'};min-width:32px;text-align:center">${i+1}</div>
29263
+ <div class="card" style="padding:12px;margin-bottom:8px;display:flex;gap:10px;align-items:center" title="${t('仲裁员公平评价(< 10 案显 insufficient data,spec §4)')}">
29264
+ <div style="font-size:24px;font-weight:800;color:#9ca3af;min-width:32px;text-align:center">${i+1}</div>
27910
29265
  <div style="flex:1;min-width:0">
27911
29266
  <div style="font-weight:600;font-size:14px">⚖ @${escHtml(u.handle || u.name?.slice(0,8) || '?')}</div>
27912
29267
  <div style="font-size:11px;color:#6b7280;margin-top:2px">
27913
- ${u.cases_count} ${t('案')}
29268
+ ${casesCount} ${t('案')}
27914
29269
  ${totalRatings > 0 ? ` · 👍 ${u.total_yes} / 👎 ${u.total_no}` : ` · <span style="color:#9ca3af">${t('暂无公众评价')}</span>`}
27915
29270
  </div>
27916
29271
  </div>
@@ -27922,22 +29277,25 @@ async function renderLeaderboard(app) {
27922
29277
  `
27923
29278
  }).join('') :
27924
29279
  kind === 'verifiers' ? items.map((u, i) => {
27925
- const acc = u.accuracy != null ? (u.accuracy * 100).toFixed(1) + '%' : '—'
27926
- const accColor = u.accuracy != null && u.accuracy >= 0.9 ? '#16a34a' : u.accuracy != null && u.accuracy >= 0.7 ? '#d97706' : '#dc2626'
27927
- const isNewcomer = Number(u.tasks_done || 0) < 5
29280
+ // #1080 audit: spec §4 < 5 cases "insufficient data"
29281
+ const tasksDone = Number(u.tasks_done || 0)
29282
+ const isInsufficient = tasksDone < 5
29283
+ const acc = isInsufficient ? t('insufficient data') : (u.accuracy != null ? (u.accuracy * 100).toFixed(1) + '%' : '—')
29284
+ // spec §3: 无 "best/worst" 价值判断 — 中性显示
29285
+ const accColor = isInsufficient ? '#9ca3af' : '#374151'
27928
29286
  const wrongCount = Number(u.tasks_wrong || 0)
27929
29287
  return `
27930
- <div class="card" style="padding:12px;margin-bottom:8px;display:flex;gap:10px;align-items:center" title="${t('正确数 / 总任务数 · 命中率 = 与多数共识一致的比例')}">
27931
- <div style="font-size:24px;font-weight:800;color:${i<3?'#dc2626':'#9ca3af'};min-width:32px;text-align:center">${i+1}</div>
29288
+ <div class="card" style="padding:12px;margin-bottom:8px;display:flex;gap:10px;align-items:center" title="${t('正确数 / 总任务数 · 命中率(< 5 任务显 insufficient data,spec §4)')}">
29289
+ <div style="font-size:24px;font-weight:800;color:#9ca3af;min-width:32px;text-align:center">${i+1}</div>
27932
29290
  <div style="flex:1;min-width:0">
27933
29291
  <div style="font-weight:600;font-size:14px;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
27934
29292
  @${escHtml(u.handle || u.name?.slice(0,8) || '?')}
27935
29293
  ${u.tier ? `<span style="font-size:10px;background:#ede9fe;color:#5b21b6;padding:1px 6px;border-radius:99px">${u.tier}</span>` : ''}
27936
- ${isNewcomer ? `<span style="font-size:10px;background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:99px">${t('新人')}</span>` : ''}
29294
+ ${isInsufficient ? `<span style="font-size:10px;background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:99px">${t('新人')}</span>` : ''}
27937
29295
  </div>
27938
29296
  <div style="font-size:11px;color:#6b7280;margin-top:2px">
27939
- ✓ ${u.tasks_correct} ${t('正确')} / ${u.tasks_done} ${t('总任务')}
27940
- ${wrongCount > 0 ? ` · <span style="color:#dc2626">✗ ${wrongCount} ${t('偏离')}</span>` : ''}
29297
+ ✓ ${u.tasks_correct} ${t('正确')} / ${tasksDone} ${t('总任务')}
29298
+ ${wrongCount > 0 ? ` · <span style="color:#9ca3af">✗ ${wrongCount} ${t('偏离')}</span>` : ''}
27941
29299
  </div>
27942
29300
  </div>
27943
29301
  <div style="text-align:right">
@@ -28913,6 +30271,13 @@ async function renderBuyerMyHome(app) {
28913
30271
  `
28914
30272
  }
28915
30273
 
30274
+ // 阶段 4(#1093):新治理 onboarding 入口 — 始终可访问我的治理岗位面板(卸任 / 申诉 / 历史)
30275
+ const governanceMeSection = (isExternalArb || isExternalVerifier) ? `
30276
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
30277
+ ${card('🏛', t('我的治理岗位'), t('在岗 / 申诉 / 卸任'), '#governance-me')}
30278
+ </div>
30279
+ ` : ''
30280
+
28916
30281
  // ③ 通用索赔任务 tile
28917
30282
  const claimsTile = myClaimTasks > 0 ? `
28918
30283
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
@@ -28921,11 +30286,12 @@ async function renderBuyerMyHome(app) {
28921
30286
  ` : ''
28922
30287
 
28923
30288
  // 拼装:有内容才显标题
28924
- const hasAnyTrust = verifierSection || arbSection || claimsTile
30289
+ const hasAnyTrust = verifierSection || arbSection || claimsTile || governanceMeSection
28925
30290
  const trustGrid = hasAnyTrust ? `
28926
30291
  ${(!isExternalVerifier && !isExternalArb) ? `<div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">🛡 ${t('信任与协议')}</div>` : ''}
28927
30292
  ${verifierSection}
28928
30293
  ${arbSection}
30294
+ ${governanceMeSection}
28929
30295
  ${claimsTile}
28930
30296
  ` : ''
28931
30297
 
@@ -30487,7 +31853,7 @@ async function renderCharityFund(app) {
30487
31853
  }[k] || '·')
30488
31854
  document.getElementById('cf-root').innerHTML = `
30489
31855
  <h2 style="font-size:18px;font-weight:700;margin-bottom:6px">🏦 ${t('慈善基金')}</h2>
30490
- <div style="font-size:11px;color:#6b7280;margin-bottom:14px;line-height:1.5">${t('协议持有 · 公开账本 · 任何人可捐款 · 未发出佣金按科目入池 · 治理委员会决定出金')}</div>
31856
+ <div style="font-size:11px;color:#6b7280;margin-bottom:14px;line-height:1.5">${t('协议持有 · 公开账本 · 任何人可捐款 · 专款专用于慈善许愿 · 治理委员会决定出金')}</div>
30491
31857
 
30492
31858
  <div class="card" style="padding:18px;margin-bottom:14px;background:linear-gradient(135deg,#fef2f2,#fff7ed)">
30493
31859
  <div style="display:flex;justify-content:space-between;align-items:center">
@@ -30501,9 +31867,6 @@ async function renderCharityFund(app) {
30501
31867
  <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px;font-size:10px;text-align:left;background:#fff;padding:8px;border-radius:6px">
30502
31868
  <div style="display:flex;justify-content:space-between"><span style="color:#9ca3af">💝 ${t('用户捐款')}</span><span style="font-weight:700;color:#9333ea">${Number(f.total_donated||0).toFixed(1)}</span></div>
30503
31869
  <div style="display:flex;justify-content:space-between"><span style="color:#9ca3af">🙏 ${t('还愿转入')}</span><span style="font-weight:700;color:#ea580c">${Number(f.total_redirected||0).toFixed(1)}</span></div>
30504
- <div style="display:flex;justify-content:space-between"><span style="color:#9ca3af">🔗 ${t('分享链兜底')}</span><span style="font-weight:700;color:#0369a1">${Number(f.total_chain_gap||0).toFixed(1)}</span></div>
30505
- <div style="display:flex;justify-content:space-between"><span style="color:#9ca3af">👤 ${t('孤儿订单兜底')}</span><span style="font-weight:700;color:#7c3aed">${Number(f.total_orphan_sponsor||0).toFixed(1)}</span></div>
30506
- ${Number(f.total_region_cap||0) > 0 ? `<div style="display:flex;justify-content:space-between"><span style="color:#9ca3af">⚖️ ${t('区域禁 MLM 入池')}</span><span style="font-weight:700;color:#dc2626">${Number(f.total_region_cap||0).toFixed(1)}</span></div>` : ''}
30507
31870
  </div>
30508
31871
  <div style="display:flex;justify-content:space-between;margin-top:6px;font-size:10px;background:#fff;padding:6px 8px;border-radius:6px">
30509
31872
  <span style="color:#9ca3af">🤲 ${t('累计已拨款')}</span><span style="font-weight:700;color:#16a34a">${Number(f.total_disbursed||0).toFixed(1)}</span>