@seasonkoh/webaz 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -2
- package/NOTICE +24 -3
- package/README.md +74 -330
- package/README.zh-CN.md +419 -0
- package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
- package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +69 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +270 -82
- package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
- package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
- package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
- package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +24 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
- package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +57 -7
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
- package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +187 -18
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
- package/dist/ledger.js +1 -1
- package/dist/pwa/admin-audit.js +38 -0
- package/dist/pwa/anti-abuse-thresholds.js +135 -0
- package/dist/pwa/cf-origin-guard.js +33 -0
- package/dist/pwa/contract-fingerprint.js +1 -0
- package/dist/pwa/data/onboarding-cases.js +2 -2
- package/dist/pwa/data/onboarding-quiz.js +1 -1
- package/dist/pwa/economic-participation.js +2 -2
- package/dist/pwa/integration-contract.js +46 -4
- package/dist/pwa/internal/pv-settlement.js +12 -0
- package/dist/pwa/internal/wallet-signer.js +26 -0
- package/dist/pwa/public/app-account.js +977 -0
- package/dist/pwa/public/app-admin.js +608 -0
- package/dist/pwa/public/app-agents.js +63 -0
- package/dist/pwa/public/app-ai.js +2162 -0
- package/dist/pwa/public/app-contribution.js +836 -0
- package/dist/pwa/public/app-discover.js +1296 -0
- package/dist/pwa/public/app-listings.js +226 -0
- package/dist/pwa/public/app-profile.js +1692 -0
- package/dist/pwa/public/app-seller.js +199 -0
- package/dist/pwa/public/app-shop.js +1145 -0
- package/dist/pwa/public/app.js +15075 -23960
- package/dist/pwa/public/i18n.js +31 -28
- package/dist/pwa/public/index.html +11 -1
- package/dist/pwa/public/openapi.json +4851 -2776
- package/dist/pwa/pv-kill-switch.js +31 -0
- package/dist/pwa/routes/admin-admins.js +48 -1
- package/dist/pwa/routes/admin-analytics.js +1 -10
- package/dist/pwa/routes/admin-atomic.js +4 -17
- package/dist/pwa/routes/admin-operator-claims.js +280 -0
- package/dist/pwa/routes/admin-reports.js +4 -26
- package/dist/pwa/routes/admin-tokenomics.js +2 -76
- package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
- package/dist/pwa/routes/admin-users-query.js +23 -1
- package/dist/pwa/routes/admin-wallet-ops.js +1 -1
- package/dist/pwa/routes/agent-grants.js +255 -0
- package/dist/pwa/routes/auth-read.js +1 -5
- package/dist/pwa/routes/auth-register.js +3 -13
- package/dist/pwa/routes/build-task-quota.js +113 -0
- package/dist/pwa/routes/claim-verify.js +15 -11
- package/dist/pwa/routes/contribution-facts.js +18 -0
- package/dist/pwa/routes/dispute-cases.js +5 -4
- package/dist/pwa/routes/growth.js +3 -3
- package/dist/pwa/routes/orders-action.js +27 -10
- package/dist/pwa/routes/orders-create.js +1 -1
- package/dist/pwa/routes/products-meta.js +19 -6
- package/dist/pwa/routes/profile-placement.js +1 -1
- package/dist/pwa/routes/promoter.js +10 -29
- package/dist/pwa/routes/public-build-tasks.js +5 -1
- package/dist/pwa/routes/public-utils.js +9 -12
- package/dist/pwa/routes/referral.js +5 -26
- package/dist/pwa/routes/rewards-apply.js +3 -2
- package/dist/pwa/routes/share-redirects.js +1 -1
- package/dist/pwa/routes/shareables-interactions.js +2 -1
- package/dist/pwa/routes/task-proposals.js +85 -9
- package/dist/pwa/routes/users-public.js +1 -4
- package/dist/pwa/routes/wallet-read.js +2 -14
- package/dist/pwa/routes/webauthn.js +7 -2
- package/dist/pwa/server-schema.js +9 -0
- package/dist/pwa/server.js +319 -2034
- package/dist/runtime/agent-grant-scopes.js +128 -0
- package/dist/runtime/agent-grant-verifier.js +67 -0
- package/dist/runtime/agent-pairing.js +60 -0
- package/dist/runtime/apply-webaz-runtime-schema.js +15 -0
- package/dist/runtime/webaz-schema-helpers.js +1848 -0
- package/dist/settlement-math.js +3 -3
- package/dist/version.js +6 -4
- package/package.json +43 -8
- package/dist/index.js +0 -182
- package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
- package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
- package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
- package/dist/test-dispute.js +0 -153
- package/dist/test-manifest.js +0 -61
- package/dist/test-mcp-tools.js +0 -135
- package/dist/test-reputation.js +0 -116
- package/dist/test-skill-market.js +0 -101
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
// WebAZ — Account Settings domain (classic multi-script split, slice H / app-account.js)
|
|
2
|
+
//
|
|
3
|
+
// Loaded as a CLASSIC script in this order (index.html):
|
|
4
|
+
// i18n → app-admin → app-contribution → app-ai → app-discover → app-profile → app-account → app-shop → app-listings → app-seller → app.js (source of truth: index.html)
|
|
5
|
+
// Top-level functions / window.* handlers are global; pages run on route/click
|
|
6
|
+
// (after app.js loads), so cross-file globals (GET/POST/PATCH/DELETE/state/shell/
|
|
7
|
+
// escHtml/navigate/t/toast$/confirmModal/requireApiKeyPassword/openPasswordPromptModal/
|
|
8
|
+
// toggleApiKey/copyApiKey/switchRole/addRole/doRequestPersist/isAdmin/persistApiKey/
|
|
9
|
+
// renderLogin/...) resolve at call time. No import/export.
|
|
10
|
+
//
|
|
11
|
+
// Pure relocation of the #me/settings + #me/advanced surfaces: renderProfile (the
|
|
12
|
+
// settings page), renderMyAdvanced, the renderMySettings alias, Passkey-LIST UI
|
|
13
|
+
// actions (refreshPasskeyList/doAddPasskey/doDeletePasskey/doToggleWebAuthnRequired),
|
|
14
|
+
// and the profile-edit handlers (handle/name/social/feed-visibility/block/unblock/
|
|
15
|
+
// default-address/email-bind).
|
|
16
|
+
//
|
|
17
|
+
// INTENTIONALLY LEFT IN app.js (called cross-file): the auth-boot / sensitive
|
|
18
|
+
// layer — bootAuth, renderLogin/renderRecover, doLogin/doRegister, persistApiKey/
|
|
19
|
+
// clearPersistedApiKey, the WebAuthn gate helpers, and the SHARED sensitive
|
|
20
|
+
// helpers confirmModal / requireApiKeyPassword / openPasswordPromptModal (also used
|
|
21
|
+
// by wallet/reveal-key), doRequestPersist, the api-key visibility + password-modal +
|
|
22
|
+
// role middle-zone (apiKeyVisible/toggleApiKey/copyApiKey/switchRole/addRole), and
|
|
23
|
+
// useKey. No money/order/payment/wallet/status path. No UI/behavior change.
|
|
24
|
+
|
|
25
|
+
// ─── 个人资料 & 设置 ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
// 2026-05-24 重命名 #profile → #me/settings 的 alias;保留 renderProfile 名供旧路径兼容
|
|
28
|
+
async function renderMySettings(app) { return renderProfile(app) }
|
|
29
|
+
|
|
30
|
+
// 2026-05-24 高级 sub-tab:Agent / Skill / Timeline / Webhook / 治理(聚合协议级深度工具)
|
|
31
|
+
async function renderMyAdvanced(app) {
|
|
32
|
+
if (!state.user) { renderLogin(); return }
|
|
33
|
+
app.innerHTML = shell(loading$(), 'me')
|
|
34
|
+
const role = state.user.role
|
|
35
|
+
const isTrusted = ['admin', 'verifier', 'logistics', 'arbitrator'].includes(role)
|
|
36
|
+
const [agentRes, skillsRes, ocRes] = await Promise.all([
|
|
37
|
+
GET('/agents/me/reputation').catch(() => null),
|
|
38
|
+
GET('/skills/mine').catch(() => []),
|
|
39
|
+
GET('/me/operator-claims').catch(() => null),
|
|
40
|
+
])
|
|
41
|
+
const trustScore = Math.round(agentRes?.trust_score || 0)
|
|
42
|
+
const level = agentRes?.level || 'new'
|
|
43
|
+
const lvlColor = { legend: '#dc2626', quality: '#9333ea', trusted: '#4f46e5', new: '#9ca3af' }[level] || '#6b7280'
|
|
44
|
+
const skillCount = (Array.isArray(skillsRes) ? skillsRes : []).length
|
|
45
|
+
// 贡献归属入口:admin 常驻;普通用户仅当确有 operator-claim 关系(pending/active/history)时才显示,保持清爽
|
|
46
|
+
const hasOperatorClaim = !!(ocRes && Array.isArray(ocRes.relationships) && ocRes.relationships.length)
|
|
47
|
+
|
|
48
|
+
const card = (icon, label, sub, hash) => `
|
|
49
|
+
<div class="card" onclick="location.hash='${hash}'" style="padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px;min-height:64px">
|
|
50
|
+
<div style="font-size:24px;flex-shrink:0">${icon}</div>
|
|
51
|
+
<div style="flex:1;min-width:0">
|
|
52
|
+
<div style="font-weight:600;font-size:14px">${label}</div>
|
|
53
|
+
${sub ? `<div style="font-size:11px;color:#9ca3af;margin-top:2px">${sub}</div>` : ''}
|
|
54
|
+
</div>
|
|
55
|
+
<div style="color:#9ca3af">›</div>
|
|
56
|
+
</div>`
|
|
57
|
+
|
|
58
|
+
const heroAgent = `
|
|
59
|
+
<div class="card" style="padding:14px;margin-bottom:14px;background:linear-gradient(135deg,#eef2ff,#faf5ff);border:1px solid #c7d2fe">
|
|
60
|
+
<div style="display:flex;align-items:center;gap:12px">
|
|
61
|
+
<div style="font-size:32px">🤖</div>
|
|
62
|
+
<div style="flex:1">
|
|
63
|
+
<div style="font-size:13px;color:#6b7280">${t('我的 Agent 等级')}</div>
|
|
64
|
+
<div style="font-size:22px;font-weight:800;color:${lvlColor}">${trustScore} <span style="font-size:11px;color:#6b7280">trust</span> · ${level}</div>
|
|
65
|
+
</div>
|
|
66
|
+
<a href="#my-agents" style="font-size:12px;color:#4f46e5;text-decoration:none;white-space:nowrap">${t('详情')} ›</a>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
`
|
|
70
|
+
|
|
71
|
+
const sections = `
|
|
72
|
+
${mySubTabsHTML('advanced')}
|
|
73
|
+
<h2 style="font-size:18px;font-weight:700;margin-bottom:12px">🚀 ${t('高级')}</h2>
|
|
74
|
+
${heroAgent}
|
|
75
|
+
|
|
76
|
+
<div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">🤖 ${t('Agent 治理')}</div>
|
|
77
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
|
|
78
|
+
${card('🤖', t('我的 agents'), t('谁替我做事 · 撤销控制'), '#my-agents')}
|
|
79
|
+
${card('⚡', t('卖家自动化'), skillCount > 0 ? skillCount + ' ' + t('个') : t('未发布'), '#skills')}
|
|
80
|
+
${!isTrusted ? card('🪄', t('AI 推荐'), t('给我推商品'), '#ai-recommend') : ''}
|
|
81
|
+
${role === 'seller' ? card('🎯', t('Auto-bid'), t('RFQ 自动报价'), '#auto-bid') : ''}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">📜 ${t('Webhook / Timeline')}</div>
|
|
85
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
|
|
86
|
+
${card('📜', t('Timeline'), t('全部事件按时间排列'), '#me/timeline')}
|
|
87
|
+
${card('📡', t('Webhook'), t('订阅事件 push 到外部端点'), '#me/webhooks')}
|
|
88
|
+
${(role === 'admin' || hasOperatorClaim) ? card('🪪', t('贡献归属'), t('待确认的 admin 关联 / 关联记录'), '#me/operator-claims') : ''}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">🧠 ${t('技能市场')}</div>
|
|
92
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
|
|
93
|
+
${card('🧠', t('技能市场'), t('发布 / 购买知识技能'), '#skill-market')}
|
|
94
|
+
${card('📚', t('我的技能库'), t('已购买的技能'), '#skill-market/library')}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">🏛 ${t('协议参与')}</div>
|
|
98
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
|
|
99
|
+
${card('🏛', t('协议治理'), t('参数公开 · 变更可追溯'), '#governance')}
|
|
100
|
+
${card('⚖', t('判例库'), t('争议判决公开'), '#judgments')}
|
|
101
|
+
${card('🛠', t('我的共建'), t('贡献 / GitHub 认领 / 建设信誉 — 无购买门槛'), '#my-contributions')}
|
|
102
|
+
${card('📋', t('公开共建任务'), t('浏览可认领任务、提交建议、参与共建'), '#contribute/tasks')}
|
|
103
|
+
${card('🎁', t('分享分润管理'), t('分享佣金 / PV / escrow · 经济关系登记'), '#rewards-me')}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">📧 ${t('联系我们')}</div>
|
|
107
|
+
<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">
|
|
108
|
+
<div style="font-size:24px;flex-shrink:0">📧</div>
|
|
109
|
+
<div style="flex:1;min-width:0">
|
|
110
|
+
<div style="font-weight:600;font-size:14px">contact@webaz.xyz</div>
|
|
111
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:2px">${t('合作 / 反馈 / 合规咨询')}</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div style="color:#9ca3af">›</div>
|
|
114
|
+
</a>
|
|
115
|
+
`
|
|
116
|
+
app.innerHTML = shell(sections, 'me')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function renderProfile(app) {
|
|
120
|
+
app.innerHTML = shell(loading$(), 'me')
|
|
121
|
+
const [data, blocklist] = await Promise.all([
|
|
122
|
+
GET('/profile'),
|
|
123
|
+
GET('/blocklist/me').catch(() => ({ blocked: [] })),
|
|
124
|
+
])
|
|
125
|
+
if (data.error) return void (app.innerHTML = shell(alert$('error', data.error), 'me'))
|
|
126
|
+
|
|
127
|
+
const roles = data.roles || [data.role]
|
|
128
|
+
// 自助可加角色 = 只有 buyer/seller;其余需走申请流程;admin 不显示
|
|
129
|
+
const SELF_SERVE_ROLES = ['buyer', 'seller']
|
|
130
|
+
const APPLY_ROLES = ['verifier', 'logistics', 'arbitrator']
|
|
131
|
+
const allRoles = [...SELF_SERVE_ROLES, ...APPLY_ROLES]
|
|
132
|
+
const roleLabels = { buyer: t('买家'), seller: t('卖家'), logistics: t('物流'), arbitrator: t('仲裁员'), verifier: t('审核员'), admin: t('管理员') }
|
|
133
|
+
const roleIcons = { buyer: '🛍️', seller: '🏪', logistics: '🚚', arbitrator: '⚖️', verifier: '🔍', admin: '🛡' }
|
|
134
|
+
const addable = allRoles.filter(r => !roles.includes(r))
|
|
135
|
+
// 受信角色:admin / verifier / logistics / arbitrator — 隐藏交易/社交相关 UI
|
|
136
|
+
const TRUSTED_ROLES = ['admin', 'verifier', 'logistics', 'arbitrator']
|
|
137
|
+
const isTrustedRole = TRUSTED_ROLES.includes(data.role) || roles.some(r => ['admin','verifier'].includes(r))
|
|
138
|
+
// 受信角色显示的"已有角色" chip 只展示受信角色(隐藏 buyer/seller,即使账户有遗留多角色)
|
|
139
|
+
const visibleRoles = isTrustedRole ? roles.filter(r => TRUSTED_ROLES.includes(r)) : roles
|
|
140
|
+
|
|
141
|
+
app.innerHTML = shell(`
|
|
142
|
+
${mySubTabsHTML('settings')}
|
|
143
|
+
<div class="page-header"><h2>${t('👤 个人资料 & 设置')}</h2></div>
|
|
144
|
+
<div id="profile-msg"></div>
|
|
145
|
+
|
|
146
|
+
<!-- 账户(昵称 ✏️ + API Key)-->
|
|
147
|
+
<div class="card" style="margin-bottom:12px">
|
|
148
|
+
<div class="card-body">
|
|
149
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px">👤 ${t('账户')}</div>
|
|
150
|
+
|
|
151
|
+
<div style="font-size:13px;color:#6b7280;margin-bottom:4px">${t('昵称')}</div>
|
|
152
|
+
<div id="nick-view" style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
|
|
153
|
+
<div style="font-size:18px;font-weight:600;flex:1;min-width:0;word-break:break-word">${escHtml(data.name)}</div>
|
|
154
|
+
<button onclick="toggleNickEdit(true)" title="${t('修改昵称')}" style="background:none;border:none;cursor:pointer;font-size:16px;padding:4px 6px;border-radius:6px;color:#6366f1">✏️</button>
|
|
155
|
+
</div>
|
|
156
|
+
<div id="nick-edit" style="display:none;margin-bottom:14px">
|
|
157
|
+
<div style="display:flex;gap:6px">
|
|
158
|
+
<input class="form-control" id="new-name-inp" placeholder="${t('输入新昵称')}" style="flex:1;font-size:14px" value="${escHtml(data.name)}" maxlength="40">
|
|
159
|
+
<button class="btn btn-primary btn-sm" style="white-space:nowrap;padding:6px 12px" onclick="doChangeName()">${t('保存')}</button>
|
|
160
|
+
<button class="btn btn-outline btn-sm" style="white-space:nowrap;padding:6px 10px" onclick="toggleNickEdit(false)">${t('取消')}</button>
|
|
161
|
+
</div>
|
|
162
|
+
<p style="font-size:11px;color:#9ca3af;margin-top:4px">${t('昵称可重复,1–40 字符(公开身份请用下方用户名)')}</p>
|
|
163
|
+
<div id="change-name-msg" style="margin-top:6px"></div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<!-- 用户名 @handle -->
|
|
167
|
+
<div style="font-size:13px;color:#6b7280;margin-bottom:4px">${t('用户名')} <span style="color:#9ca3af;font-size:11px">${t('(公开唯一标识,7 天可改 1 次)')}</span></div>
|
|
168
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
|
|
169
|
+
<div style="flex:1;min-width:0;font-size:15px;font-weight:500;color:#3730a3;word-break:break-all">@${escHtml(data.handle || '—')}</div>
|
|
170
|
+
<button class="btn btn-outline btn-sm" onclick="openChangeHandleModal()" style="white-space:nowrap;font-size:11px;padding:5px 10px">${t('修改')}</button>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!-- 永久分享推荐码 — 受信角色不展示(无分享/邀请需求)-->
|
|
174
|
+
${!isTrustedRole ? `
|
|
175
|
+
<div style="font-size:13px;color:#6b7280;margin-bottom:4px">${t('永久分享推荐码')} <span style="color:#9ca3af;font-size:11px">${t('(不可改,用于邀请链 / agent 引用等)')}</span></div>
|
|
176
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
|
|
177
|
+
<code style="background:#fef3c7;color:#78350f;padding:6px 14px;border-radius:6px;font-size:18px;font-weight:700;letter-spacing:2px;font-family:monospace">${escHtml(data.permanent_code || '—')}</code>
|
|
178
|
+
<button class="btn btn-outline btn-sm" onclick="copyText('${escHtml(data.permanent_code || '')}').then(ok=>toast$(ok?t('已复制'):t('复制失败,请手动复制'),ok?'success':'error'))" style="white-space:nowrap;font-size:11px;padding:5px 10px">${t('复制')}</button>
|
|
179
|
+
</div>` : ''}
|
|
180
|
+
|
|
181
|
+
<div style="font-size:13px;color:#6b7280;margin-bottom:4px">API Key <span style="color:#9ca3af;font-size:11px">${t('(你的唯一身份凭证,请妥善保管)')}</span></div>
|
|
182
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
183
|
+
<code id="apikey-display" style="background:#f3f4f6;padding:6px 10px;border-radius:6px;font-size:12px;flex:1;word-break:break-all;filter:blur(4px);user-select:none">${data.api_key}</code>
|
|
184
|
+
<button class="btn btn-outline btn-sm" onclick="toggleApiKey()" id="btn-reveal" style="white-space:nowrap;font-size:11px;padding:5px 10px">${t('显示')}</button>
|
|
185
|
+
<button class="btn btn-outline btn-sm" onclick="copyApiKey('${data.api_key}')" style="white-space:nowrap;font-size:11px;padding:5px 10px">${t('复制')}</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<!-- 2026-05-24:删钱包卡 — 与面板首行 (#me 第 1 个 tile) 重复 -->
|
|
191
|
+
|
|
192
|
+
<!-- 角色管理 -->
|
|
193
|
+
<div class="card" style="margin-bottom:12px">
|
|
194
|
+
<div class="card-body">
|
|
195
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px">🎭 ${t('角色管理')}</div>
|
|
196
|
+
|
|
197
|
+
<div style="font-size:13px;color:#6b7280;margin-bottom:8px">${t('已有角色')}</div>
|
|
198
|
+
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px">
|
|
199
|
+
${visibleRoles.map(r => `
|
|
200
|
+
<button onclick="switchRole('${r}', this)" style="
|
|
201
|
+
display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;cursor:pointer;border:2px solid;
|
|
202
|
+
${r === data.role ? 'background:#eff6ff;border-color:#3b82f6;color:#1d4ed8;font-weight:600' : 'background:#f9fafb;border-color:#e5e7eb;color:#374151'}
|
|
203
|
+
" title="${r === data.role ? t('当前激活') : t('点击切换')}">
|
|
204
|
+
${roleIcons[r]} ${roleLabels[r]}
|
|
205
|
+
${r === data.role ? `<span style="font-size:11px;color:#3b82f6">${t('● 激活')}</span>` : ''}
|
|
206
|
+
</button>
|
|
207
|
+
`).join('')}
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
${roles.some(r => ['admin','verifier'].includes(r)) ? `
|
|
211
|
+
<!-- 受信角色锁:不展示添加按钮 + 明示理由 -->
|
|
212
|
+
<div style="padding:12px 14px;background:#fef9c3;border:1px solid #fde047;border-radius:8px;font-size:12px;color:#78350f;line-height:1.6">
|
|
213
|
+
🔒 <strong>${t('受信角色身份锁定')}</strong><br>
|
|
214
|
+
${t('权责分离原则:管理员 / 审核员不能自助添加 buyer / seller 等其他身份,避免利益冲突(如自卖自买、自审自核)。如需购买或销售,请用其他账号注册。')}
|
|
215
|
+
</div>
|
|
216
|
+
` : addable.length > 0 ? `
|
|
217
|
+
<div style="font-size:13px;color:#6b7280;margin-bottom:8px">${t('添加新角色')}</div>
|
|
218
|
+
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px">
|
|
219
|
+
${addable.filter(r => SELF_SERVE_ROLES.includes(r)).map(r => `
|
|
220
|
+
<button onclick="addRole('${r}', this)" style="
|
|
221
|
+
display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;cursor:pointer;
|
|
222
|
+
background:#f9fafb;border:2px dashed #d1d5db;color:#6b7280
|
|
223
|
+
">${roleIcons[r]} + ${roleLabels[r]}</button>
|
|
224
|
+
`).join('')}
|
|
225
|
+
</div>
|
|
226
|
+
${addable.filter(r => APPLY_ROLES.includes(r)).length > 0 ? `
|
|
227
|
+
<div style="font-size:13px;color:#6b7280;margin:12px 0 6px">${t('需通过申请获得')}</div>
|
|
228
|
+
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
|
229
|
+
${addable.filter(r => APPLY_ROLES.includes(r)).map(r => {
|
|
230
|
+
if (r === 'verifier') {
|
|
231
|
+
return `<button onclick="navigate('#apply-verifier')" style="
|
|
232
|
+
display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;cursor:pointer;
|
|
233
|
+
background:#eef2ff;border:2px solid #6366f1;color:#4338ca
|
|
234
|
+
">${roleIcons[r]} 📥 ${t('申请')} ${roleLabels[r]}</button>`
|
|
235
|
+
}
|
|
236
|
+
return `<button disabled title="${t('请联系管理员申请此角色')}" style="
|
|
237
|
+
display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:10px;font-size:14px;cursor:not-allowed;
|
|
238
|
+
background:#f9fafb;border:2px solid #e5e7eb;color:#9ca3af
|
|
239
|
+
">${roleIcons[r]} 🔒 ${roleLabels[r]} <span style="font-size:11px">(${t('联系管理员')})</span></button>`
|
|
240
|
+
}).join('')}
|
|
241
|
+
</div>
|
|
242
|
+
` : ''}
|
|
243
|
+
` : `<div style="font-size:13px;color:#6b7280">${t('已拥有全部可自助角色')}</div>`}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- 2026-05-24 社交资料/我的分享 入口移除 — Dashboard "👁 公开主页" tile 已覆盖(同 #u/:id 目标)-->
|
|
248
|
+
|
|
249
|
+
<!-- M7.2.7:A2 黑名单 — 降权到次要位置,默认折叠(卡片放在偏好之后,作为辅助管理项)-->
|
|
250
|
+
<!-- 内容下移见下方 -->
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
<!-- 默认配送地址 — 受信角色不展示(无下单需求) -->
|
|
254
|
+
${!isTrustedRole ? `
|
|
255
|
+
<div class="card" style="margin-bottom:12px">
|
|
256
|
+
<div class="card-body">
|
|
257
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
258
|
+
<div>
|
|
259
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px">📦 ${t('默认配送地址')}</div>
|
|
260
|
+
<a href="#addresses" style="font-size:10px;color:#6366f1;text-decoration:none">📍 ${t('地址簿(多地址管理)')} →</a>
|
|
261
|
+
</div>
|
|
262
|
+
<button class="btn btn-outline btn-sm" id="addr-edit-toggle" style="width:auto;font-size:12px;padding:4px 12px" onclick="toggleAddressEdit(true)">${addressSummaryText(data.default_address) ? t('编辑') : t('添加')}</button>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<!-- 折叠态摘要 -->
|
|
266
|
+
<div id="addr-summary">
|
|
267
|
+
${(() => {
|
|
268
|
+
const a = data.default_address || {}
|
|
269
|
+
const sum = addressSummaryText(a)
|
|
270
|
+
if (!sum) return `<div style="padding:12px;font-size:12px;color:#9ca3af;text-align:center;background:#f9fafb;border-radius:6px">${t('尚未设置默认地址,点击「添加」开始')}</div>`
|
|
271
|
+
const detail = [a.line1, a.line2].filter(Boolean).join(' · ')
|
|
272
|
+
return `<div style="padding:10px 12px;background:#f9fafb;border-radius:6px;font-size:13px;color:#374151;line-height:1.6;cursor:pointer" onclick="toggleAddressEdit(true)">
|
|
273
|
+
<div style="font-weight:500">${escHtml(sum)}</div>
|
|
274
|
+
${detail ? `<div style="font-size:12px;color:#6b7280;margin-top:2px">${escHtml(detail)}${a.postal_code ? ' · ' + escHtml(a.postal_code) : ''}</div>` : ''}
|
|
275
|
+
</div>`
|
|
276
|
+
})()}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<!-- 展开态完整表单 -->
|
|
280
|
+
<div id="addr-form" style="display:none;margin-top:14px;padding-top:14px;border-top:1px solid #f3f4f6">
|
|
281
|
+
<p style="font-size:11px;color:#6b7280;margin-bottom:10px">${t('智能下单按此地址过滤不可派送商品;下单页可临时改。带 * 为必填。')}</p>
|
|
282
|
+
|
|
283
|
+
<!-- 历史地址(最近 3 条,本地保存)-->
|
|
284
|
+
<div id="addr-history-wrap" style="margin-bottom:14px"></div>
|
|
285
|
+
|
|
286
|
+
<div style="margin-bottom:10px">
|
|
287
|
+
<button class="btn btn-outline btn-sm" style="font-size:11px;padding:3px 10px" onclick="addressPasteSmartFill()">📋 ${t('粘贴智能识别')}</button>
|
|
288
|
+
</div>
|
|
289
|
+
${(() => {
|
|
290
|
+
const a = data.default_address || {}
|
|
291
|
+
return `
|
|
292
|
+
<div style="margin-bottom:10px">
|
|
293
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('收件人姓名')} <span style="color:#dc2626">*</span></div>
|
|
294
|
+
<input class="form-control" id="addr-recipient-inp" style="font-size:13px" maxlength="40" value="${escHtml(a.recipient_name || '')}" placeholder="${t('例:陈小明')}">
|
|
295
|
+
</div>
|
|
296
|
+
<div style="margin-bottom:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
|
297
|
+
<div>
|
|
298
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('主要联系方式')} <span style="color:#dc2626">*</span></div>
|
|
299
|
+
<input class="form-control" id="addr-phone1-inp" style="font-size:13px" maxlength="30" value="${escHtml(a.phone1 || '')}" placeholder="${t('手机/电话')}">
|
|
300
|
+
</div>
|
|
301
|
+
<div>
|
|
302
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('备用联系方式')}</div>
|
|
303
|
+
<input class="form-control" id="addr-phone2-inp" style="font-size:13px" maxlength="30" value="${escHtml(a.phone2 || '')}" placeholder="${t('可选')}">
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
<div style="margin-bottom:10px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
|
|
307
|
+
<div>
|
|
308
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('国家/地区')} <span style="color:#dc2626">*</span></div>
|
|
309
|
+
<input class="form-control" id="addr-country-inp" style="font-size:13px" maxlength="40" value="${escHtml(a.country || '中国')}" placeholder="${t('如:中国')}">
|
|
310
|
+
</div>
|
|
311
|
+
<div>
|
|
312
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('省/州')} <span style="color:#dc2626">*</span></div>
|
|
313
|
+
<input class="form-control" id="addr-state-inp" style="font-size:13px" maxlength="40" value="${escHtml(a.state || '')}" placeholder="${t('如:上海/广东')}">
|
|
314
|
+
</div>
|
|
315
|
+
<div>
|
|
316
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('城市')} <span style="color:#dc2626">*</span></div>
|
|
317
|
+
<input class="form-control" id="addr-city-inp" style="font-size:13px" maxlength="40" value="${escHtml(a.city || '')}" placeholder="${t('如:浦东新区')}">
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
<div style="margin-bottom:10px">
|
|
321
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('地址行 1')} <span style="color:#dc2626">*</span></div>
|
|
322
|
+
<input class="form-control" id="addr-line1-inp" style="font-size:13px" maxlength="100" value="${escHtml(a.line1 || '')}" placeholder="${t('如:张江路 123 号')}">
|
|
323
|
+
</div>
|
|
324
|
+
<div style="margin-bottom:10px">
|
|
325
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('地址行 2')}</div>
|
|
326
|
+
<input class="form-control" id="addr-line2-inp" style="font-size:13px" maxlength="100" value="${escHtml(a.line2 || '')}" placeholder="${t('楼号/单元/房号(可选)')}">
|
|
327
|
+
</div>
|
|
328
|
+
<div style="margin-bottom:14px">
|
|
329
|
+
<div style="font-size:13px;color:#374151;margin-bottom:6px">${t('邮政编码')}</div>
|
|
330
|
+
<input class="form-control" id="addr-postal-inp" style="font-size:13px" maxlength="20" value="${escHtml(a.postal_code || '')}" placeholder="${t('如:201203(可选)')}">
|
|
331
|
+
</div>
|
|
332
|
+
`})()}
|
|
333
|
+
<p style="font-size:11px;color:#9ca3af;margin-bottom:8px">${t('「省/州」用于配送过滤匹配,需要与商品「发货地」一致(如「全国」或包含你的省份)')}</p>
|
|
334
|
+
<div style="display:flex;gap:8px">
|
|
335
|
+
<button class="btn btn-primary btn-sm" style="white-space:nowrap" onclick="saveDefaultAddress()">${t('保存')}</button>
|
|
336
|
+
<button class="btn btn-outline btn-sm" style="white-space:nowrap" onclick="toggleAddressEdit(false)">${t('取消')}</button>
|
|
337
|
+
</div>
|
|
338
|
+
<div id="addr-msg" style="margin-top:8px"></div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
</div>` : ''}
|
|
342
|
+
|
|
343
|
+
<!-- 2026-05-24 我的二手 入口移除 — Dashboard "♻️ 我的二手" tile 已覆盖(同 #secondhand/mine 目标)-->
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
<!-- 账户安全(密码 + 邮箱合并)-->
|
|
347
|
+
<div class="card" style="margin-bottom:12px">
|
|
348
|
+
<div class="card-body">
|
|
349
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:12px">🔐 ${t('账户安全')}</div>
|
|
350
|
+
|
|
351
|
+
<!-- 登录密码 -->
|
|
352
|
+
<div style="padding-bottom:14px;border-bottom:1px solid #f3f4f6;margin-bottom:14px">
|
|
353
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
|
354
|
+
<div style="font-size:13px;color:#374151;font-weight:500">🔒 ${t('登录密码')}</div>
|
|
355
|
+
<div style="font-size:12px;color:${data.has_password ? '#16a34a' : '#9ca3af'}">${data.has_password ? '✓ ' + t('已设置') : t('未设置')}</div>
|
|
356
|
+
</div>
|
|
357
|
+
${data.has_password ? `
|
|
358
|
+
<details>
|
|
359
|
+
<summary style="font-size:12px;color:#6366f1;cursor:pointer">${t('修改密码 / 移除密码')}</summary>
|
|
360
|
+
<div style="margin-top:10px">
|
|
361
|
+
<input class="form-control" id="pwd-old" type="password" placeholder="${t('原密码')}" style="margin-bottom:8px;font-size:13px">
|
|
362
|
+
<input class="form-control" id="pwd-new" type="password" placeholder="${t('新密码(至少 8 字符)')}" style="margin-bottom:8px;font-size:13px">
|
|
363
|
+
<input class="form-control" id="pwd-new2" type="password" placeholder="${t('再次输入新密码')}" style="margin-bottom:8px;font-size:13px">
|
|
364
|
+
<button class="btn btn-primary btn-sm" onclick="doSetPassword()">${t('修改密码')}</button>
|
|
365
|
+
<button class="btn btn-outline btn-sm" style="margin-left:6px;color:#dc2626;border-color:#dc2626" onclick="doRemovePassword()">${t('移除密码')}</button>
|
|
366
|
+
<div id="pwd-msg" style="margin-top:8px"></div>
|
|
367
|
+
</div>
|
|
368
|
+
</details>
|
|
369
|
+
` : `
|
|
370
|
+
<details>
|
|
371
|
+
<summary style="font-size:12px;color:#6366f1;cursor:pointer">${t('设置密码(可用「名称 + 密码」登录)')}</summary>
|
|
372
|
+
<div style="margin-top:10px">
|
|
373
|
+
<input class="form-control" id="pwd-new" type="password" placeholder="${t('新密码(至少 8 字符)')}" style="margin-bottom:8px;font-size:13px">
|
|
374
|
+
<input class="form-control" id="pwd-new2" type="password" placeholder="${t('再次输入新密码')}" style="margin-bottom:8px;font-size:13px">
|
|
375
|
+
<button class="btn btn-primary btn-sm" onclick="doSetPassword()">${t('设置密码')}</button>
|
|
376
|
+
<div id="pwd-msg" style="margin-top:8px"></div>
|
|
377
|
+
</div>
|
|
378
|
+
</details>
|
|
379
|
+
`}
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<!-- 邮箱 -->
|
|
383
|
+
<div>
|
|
384
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
|
385
|
+
<div style="font-size:13px;color:#374151;font-weight:500">📧 ${t('找回邮箱')}</div>
|
|
386
|
+
<div style="font-size:12px;color:${data.email_verified ? '#16a34a' : '#9ca3af'}">${data.email_verified ? '✓ ' + escHtml(data.email) : t('未绑定')}</div>
|
|
387
|
+
</div>
|
|
388
|
+
${data.email_verified ? `
|
|
389
|
+
<div style="font-size:11px;color:#9ca3af">${t('遗失密钥可通过此邮箱找回。修改/解绑功能即将上线。')}</div>
|
|
390
|
+
` : `
|
|
391
|
+
<details>
|
|
392
|
+
<summary style="font-size:12px;color:#6366f1;cursor:pointer">${t('绑定邮箱')}</summary>
|
|
393
|
+
<div style="margin-top:10px">
|
|
394
|
+
<div id="bind-step1">
|
|
395
|
+
<input class="form-control" id="bind-email-inp" placeholder="your@example.com" style="margin-bottom:8px;font-size:13px">
|
|
396
|
+
<button class="btn btn-outline btn-sm" onclick="doSendBindCode()">${t('发送验证码')}</button>
|
|
397
|
+
<div id="bind-msg1" style="margin-top:8px"></div>
|
|
398
|
+
</div>
|
|
399
|
+
<div id="bind-step2" style="display:none">
|
|
400
|
+
<div id="bind-target-hint" style="font-size:12px;color:#6b7280;margin-bottom:8px"></div>
|
|
401
|
+
<input class="form-control" id="bind-code-inp" placeholder="${t('6 位验证码')}" maxlength="6" style="margin-bottom:8px;font-size:13px">
|
|
402
|
+
<button class="btn btn-primary btn-sm" onclick="doConfirmBindEmail()">${t('确认绑定')}</button>
|
|
403
|
+
<button class="btn btn-outline btn-sm" onclick="bindBackToStep1()" style="margin-left:6px">${t('重发')}</button>
|
|
404
|
+
<div id="bind-msg2" style="margin-top:8px"></div>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</details>
|
|
408
|
+
`}
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<!-- 活跃会话 (P1 安全) -->
|
|
412
|
+
<div style="margin-top:14px;padding-top:14px;border-top:1px solid #f3f4f6">
|
|
413
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
|
414
|
+
<div style="font-size:13px;color:#374151;font-weight:500">🛡 ${t('活跃会话')}</div>
|
|
415
|
+
<button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px;color:#dc2626;border-color:#fca5a5" onclick="openLogoutAllModal()">${t('一键全登出')}</button>
|
|
416
|
+
</div>
|
|
417
|
+
<div style="font-size:11px;color:#9ca3af;margin-bottom:8px">${t('查看每个已登录设备/位置;可吊销可疑会话')}</div>
|
|
418
|
+
<details>
|
|
419
|
+
<summary style="font-size:12px;color:#6366f1;cursor:pointer">${t('查看所有活跃会话 →')}</summary>
|
|
420
|
+
<div id="sessions-list" style="margin-top:10px;font-size:12px;color:#9ca3af">${t('点击展开加载...')}</div>
|
|
421
|
+
</details>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<!-- 安全与存储 -->
|
|
427
|
+
<div class="card" style="margin-bottom:12px">
|
|
428
|
+
<div class="card-body">
|
|
429
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px">🔐 ${t('安全与存储')}</div>
|
|
430
|
+
<div id="storage-persist-row" style="font-size:12px;color:#6b7280;padding:8px 0">${t('检测存储持久化状态…')}</div>
|
|
431
|
+
<div style="font-size:11px;color:#9ca3af;line-height:1.6;margin-top:6px;margin-bottom:14px">
|
|
432
|
+
${t('iOS Safari 7 天未活跃可能清理本地数据。妥善记下永久码 + recovery_code(在 API Key 卡片),任何时候能找回账户。')}
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<div style="border-top:1px solid #f3f4f6;padding-top:12px">
|
|
436
|
+
<div style="font-size:13px;font-weight:600;color:#374151;margin-bottom:6px">🔑 ${t('Passkey / 生物识别')}</div>
|
|
437
|
+
<div style="font-size:11px;color:#9ca3af;line-height:1.6;margin-bottom:10px">
|
|
438
|
+
${t('提现等敏感操作可要求设备指纹 / Face ID 二次确认。私钥不离开你的手机,手机丢失也不会泄露。')}
|
|
439
|
+
</div>
|
|
440
|
+
<div id="passkey-list" style="font-size:12px;color:#6b7280">${t('加载中…')}</div>
|
|
441
|
+
<a onclick="navigate('#agents')" style="display:block;margin-top:12px;font-size:12px;color:#4f46e5;cursor:pointer">🔌 ${t('已连接的 Agent')} →</a>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<!-- 偏好 -->
|
|
447
|
+
<div class="card" style="margin-bottom:12px">
|
|
448
|
+
<div class="card-body">
|
|
449
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px">⚙️ ${t('偏好')}</div>
|
|
450
|
+
|
|
451
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #f3f4f6">
|
|
452
|
+
<div>
|
|
453
|
+
<div style="font-size:13px;color:#374151;font-weight:500">🌐 ${t('语言')}</div>
|
|
454
|
+
<div style="font-size:11px;color:#9ca3af">${t('UI 显示语言')}</div>
|
|
455
|
+
</div>
|
|
456
|
+
<button onclick="toggleLang()" class="btn btn-outline btn-sm" style="font-size:11px;padding:5px 12px;white-space:nowrap">${window._lang === 'en' ? '中文' : 'English'}</button>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
${!isTrustedRole ? `
|
|
460
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0 6px;gap:8px">
|
|
461
|
+
<div style="flex:1;min-width:0">
|
|
462
|
+
<div style="font-size:13px;color:#374151;font-weight:500">🌍 ${t('国家 / 地区')}</div>
|
|
463
|
+
<div style="font-size:11px;color:${state.user?.region ? '#9ca3af' : '#dc2626'}">${state.user?.region ? regionLabel(state.user.region) : t('未设置 — 请选择')}</div>
|
|
464
|
+
</div>
|
|
465
|
+
<select id="profile-region-select" class="form-control" onchange="doSaveRegion(this.value)" style="font-size:12px;padding:5px 8px;width:auto;min-width:140px">
|
|
466
|
+
<option value="">${t('请选择…')}</option>
|
|
467
|
+
<option value="china" ${state.user?.region === 'china' ? 'selected' : ''}>🇨🇳 ${t('中国')}</option>
|
|
468
|
+
<option value="us" ${state.user?.region === 'us' ? 'selected' : ''}>🇺🇸 ${t('美国')}</option>
|
|
469
|
+
<option value="eu" ${state.user?.region === 'eu' ? 'selected' : ''}>🇪🇺 ${t('欧盟')}</option>
|
|
470
|
+
<option value="india" ${state.user?.region === 'india' ? 'selected' : ''}>🇮🇳 ${t('印度')}</option>
|
|
471
|
+
<option value="singapore" ${state.user?.region === 'singapore' ? 'selected' : ''}>🇸🇬 ${t('新加坡')}</option>
|
|
472
|
+
<option value="global_north" ${state.user?.region === 'global_north' ? 'selected' : ''}>🌏 ${t('其他发达地区')}</option>
|
|
473
|
+
<option value="global" ${state.user?.region === 'global' ? 'selected' : ''}>🌐 ${t('其他地区')}</option>
|
|
474
|
+
</select>
|
|
475
|
+
</div>` : ''}
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<!-- M7.2.7:黑名单(社交功能;受信角色不展示)-->
|
|
480
|
+
${!isTrustedRole ? (() => {
|
|
481
|
+
const list = blocklist.blocked || []
|
|
482
|
+
return `<details class="card" style="margin-bottom:12px"><summary style="padding:12px 16px;font-size:13px;color:#6b7280;cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center">
|
|
483
|
+
<span>🚫 ${t('我的黑名单')} <span style="color:#9ca3af">(${list.length})</span></span>
|
|
484
|
+
<span style="font-size:11px;color:#9ca3af">${list.length === 0 ? t('暂无') : t('展开管理')} ▸</span>
|
|
485
|
+
</summary>
|
|
486
|
+
${list.length > 0 ? `<div class="card-body" style="border-top:1px solid #f3f4f6;padding-top:10px">
|
|
487
|
+
<p style="font-size:11px;color:#6b7280;margin-bottom:10px">${t('被拉黑的用户的商品和动态对你不可见')}</p>
|
|
488
|
+
${list.map(b => `
|
|
489
|
+
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f3f4f6">
|
|
490
|
+
<div style="flex:1;min-width:0">
|
|
491
|
+
<div style="font-size:13px;font-weight:500"><a href="#u/${b.blocked_id}" style="color:#374151">${escHtml(b.blocked_name || b.blocked_id)}</a></div>
|
|
492
|
+
<div style="font-size:11px;color:#9ca3af">${b.reason ? escHtml(b.reason) + ' · ' : ''}${fmtTime(b.created_at)}</div>
|
|
493
|
+
</div>
|
|
494
|
+
<button class="btn btn-outline btn-sm" style="width:auto;padding:4px 10px;font-size:11px" onclick="unblockUser('${b.blocked_id}')">${t('解除')}</button>
|
|
495
|
+
</div>`).join('')}
|
|
496
|
+
</div>` : ''}
|
|
497
|
+
</details>`
|
|
498
|
+
})() : ''}
|
|
499
|
+
|
|
500
|
+
<!-- 关于 -->
|
|
501
|
+
<div class="card" style="margin-bottom:12px">
|
|
502
|
+
<div class="card-body">
|
|
503
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px">ℹ️ ${t('关于')}</div>
|
|
504
|
+
|
|
505
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
|
|
506
|
+
<span style="color:#374151">${t('版本')}</span>
|
|
507
|
+
<span style="color:#9ca3af;font-family:monospace;font-size:12px">WebAZ ${window._version || '0.1.8'}</span>
|
|
508
|
+
</div>
|
|
509
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
|
|
510
|
+
<span style="color:#374151">${t('协议')}</span>
|
|
511
|
+
<a href="https://github.com/webaz-protocol/webaz" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px">${t('源码仓库')} ↗</a>
|
|
512
|
+
</div>
|
|
513
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #f3f4f6;font-size:13px">
|
|
514
|
+
<span style="color:#374151">🔔 ${t('推送通知')}</span>
|
|
515
|
+
<a href="#push-settings" style="color:#6366f1;text-decoration:none;font-size:12px">${t('订单 / 评价 / 降价')} →</a>
|
|
516
|
+
</div>
|
|
517
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:13px">
|
|
518
|
+
<span style="color:#374151">${t('帮助')}</span>
|
|
519
|
+
<a href="#promoter" style="color:#6366f1;text-decoration:none;font-size:12px">${t('成长任务指引')} →</a>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
<!-- 2026-05-24 危险区:退出 / 注销分组醒目化 -->
|
|
525
|
+
<div style="margin-top:20px;padding:14px;background:#fef2f2;border:1px solid #fecaca;border-radius:10px">
|
|
526
|
+
<div style="font-size:11px;font-weight:700;color:#991b1b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px">⚠ ${t('危险区')}</div>
|
|
527
|
+
<button class="btn" onclick="logout()" style="width:100%;background:#fff;color:#dc2626;border:2px solid #dc2626;padding:10px;font-weight:600;border-radius:8px;margin-bottom:8px;cursor:pointer">🚪 ${t('退出登录')}</button>
|
|
528
|
+
<div style="font-size:11px;color:#6b7280;text-align:center;line-height:1.5">${t('退出后可重新登录;本机 api_key 缓存会清除')}</div>
|
|
529
|
+
</div>
|
|
530
|
+
`, 'me')
|
|
531
|
+
|
|
532
|
+
// 异步:检测并尝试申请 persistent storage,更新提示行
|
|
533
|
+
;(async () => {
|
|
534
|
+
const row = document.getElementById('storage-persist-row')
|
|
535
|
+
if (!row) return
|
|
536
|
+
const supported = !!navigator.storage?.persisted
|
|
537
|
+
if (!supported) {
|
|
538
|
+
row.innerHTML = `<span style="color:#9ca3af">${t('浏览器不支持持久化 API(极旧浏览器)— 数据可能随时清理')}</span>`
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
let persisted = await isStoragePersistent()
|
|
542
|
+
if (!persisted) {
|
|
543
|
+
// 当前未授权 → 主动申请一次
|
|
544
|
+
try { persisted = await navigator.storage.persist() } catch {}
|
|
545
|
+
}
|
|
546
|
+
if (persisted) {
|
|
547
|
+
row.innerHTML = `<span style="color:#16a34a">✓ ${t('存储已持久化 — 系统不会自动清理')}</span>`
|
|
548
|
+
} else {
|
|
549
|
+
row.innerHTML = `<span style="color:#d97706">⚠ ${t('存储非持久化 — iOS Safari 长期未打开可能清理。建议常用,并 +PWA 装到桌面提高优先级')}</span>
|
|
550
|
+
<a href="#" onclick="event.preventDefault(); doRequestPersist()" style="margin-left:6px;color:#4f46e5;font-size:11px">${t('再次申请')}</a>`
|
|
551
|
+
}
|
|
552
|
+
})()
|
|
553
|
+
|
|
554
|
+
// 加载 Passkey 列表
|
|
555
|
+
refreshPasskeyList()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function refreshPasskeyList() {
|
|
559
|
+
const el = document.getElementById('passkey-list')
|
|
560
|
+
if (!el) return
|
|
561
|
+
if (!isWebAuthnSupported()) {
|
|
562
|
+
el.innerHTML = `<span style="color:#9ca3af">${t('当前设备不支持 Passkey(请用支持 WebAuthn 的浏览器)')}</span>`
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
const data = await GET('/webauthn/credentials').catch(() => ({ credentials: [], settings: {} }))
|
|
566
|
+
const creds = data.credentials || []
|
|
567
|
+
const required = !!data.settings?.required_for_withdraw
|
|
568
|
+
|
|
569
|
+
const list = creds.length === 0
|
|
570
|
+
? `<div style="color:#9ca3af;font-size:11px;padding:8px 0">${t('还未注册任何 Passkey')}</div>`
|
|
571
|
+
: creds.map(c => `
|
|
572
|
+
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;margin-bottom:6px;font-size:11px">
|
|
573
|
+
<span style="font-size:18px">🔑</span>
|
|
574
|
+
<div style="flex:1;min-width:0">
|
|
575
|
+
<div style="font-weight:600;color:#374151;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(c.device_label || c.id.slice(0, 18) + '…')}</div>
|
|
576
|
+
<div style="color:#9ca3af;font-size:10px">${t('注册于')} ${fmtTime(c.created_at)}${c.last_used_at ? ` · ${t('上次用于')} ${fmtTime(c.last_used_at)}` : ''}</div>
|
|
577
|
+
</div>
|
|
578
|
+
<button onclick="doDeletePasskey('${c.id}')" style="background:none;border:none;color:#dc2626;cursor:pointer;font-size:12px;padding:0 6px">${t('删除')}</button>
|
|
579
|
+
</div>`).join('')
|
|
580
|
+
|
|
581
|
+
el.innerHTML = `
|
|
582
|
+
${list}
|
|
583
|
+
<button class="btn btn-outline btn-sm" style="width:auto;padding:5px 12px;font-size:11px;margin-top:4px" onclick="doAddPasskey()">+ ${t('注册新 Passkey')}</button>
|
|
584
|
+
${creds.length > 0 ? `
|
|
585
|
+
<div style="margin-top:10px;padding-top:10px;border-top:1px dashed #e5e7eb">
|
|
586
|
+
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#374151;cursor:pointer">
|
|
587
|
+
<input type="checkbox" id="wac-require-withdraw" ${required ? 'checked' : ''} onchange="doToggleWebAuthnRequired(this.checked)" style="width:14px;height:14px">
|
|
588
|
+
${t('提现操作需 Passkey 二次确认')}
|
|
589
|
+
</label>
|
|
590
|
+
</div>` : ''}
|
|
591
|
+
`
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
window.doAddPasskey = async () => {
|
|
595
|
+
const label = prompt(t('给这个设备起个名(例:iPhone 15 / 工作机)'), '') || ''
|
|
596
|
+
const ok = await doRegisterPasskey(label.trim())
|
|
597
|
+
if (ok) refreshPasskeyList()
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
window.doDeletePasskey = async (id) => {
|
|
601
|
+
if (!confirm(t('删除后该 Passkey 不能用于二次确认,确定?'))) return
|
|
602
|
+
// #1044 — 删 Passkey 自身需要先用同账号下任意一把 Passkey ceremony 拿 token,堵"失窃 Passkey 不需 Passkey 即可删它"漏洞
|
|
603
|
+
let token
|
|
604
|
+
try {
|
|
605
|
+
token = await requestPasskeyGate('delete_passkey', { credential_id: id })
|
|
606
|
+
} catch (e) {
|
|
607
|
+
alert(t('需要先用 Passkey 验证身份才能删除:') + (e?.message || e))
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
const r = await api('DELETE', '/webauthn/credentials/' + encodeURIComponent(id), { webauthn_token: token })
|
|
611
|
+
if (r.error) { alert(r.error); return }
|
|
612
|
+
refreshPasskeyList()
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
window.doToggleWebAuthnRequired = async (enabled) => {
|
|
616
|
+
const r = await POST('/webauthn/settings', { required_for_withdraw: !!enabled })
|
|
617
|
+
if (r.error) { alert(r.error); refreshPasskeyList(); return }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
window.openChangeHandleModal = async () => {
|
|
621
|
+
const profile = await GET('/profile')
|
|
622
|
+
if (profile.error) return toast$(profile.error, 'error')
|
|
623
|
+
const log = profile.handle_change_log || []
|
|
624
|
+
const N = log.length // 累计已改名次数
|
|
625
|
+
const lastAt = profile.handle_last_changed_at
|
|
626
|
+
const nextRequiredMonths = N * 12 // 本次改名需距上次至少 N × 12 个月(第 1 次 = 0)
|
|
627
|
+
const afterThisRequiredMonths = (N + 1) * 12 // 本次改名成功后,下次需等的月数
|
|
628
|
+
|
|
629
|
+
// 冷却状态
|
|
630
|
+
let cooldownInfo = ''
|
|
631
|
+
let cooldownActive = false
|
|
632
|
+
if (lastAt && N > 0) {
|
|
633
|
+
const lastMs = new Date(lastAt).getTime()
|
|
634
|
+
const sinceMs = Date.now() - lastMs
|
|
635
|
+
const requiredMs = nextRequiredMonths * 30 * 86400_000
|
|
636
|
+
if (sinceMs < requiredMs) {
|
|
637
|
+
cooldownActive = true
|
|
638
|
+
const remainMs = requiredMs - sinceMs
|
|
639
|
+
const remainMonths = Math.ceil(remainMs / (30 * 86400_000))
|
|
640
|
+
cooldownInfo = `<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:6px;padding:10px 12px;font-size:11px;color:#92400e;margin-bottom:10px;line-height:1.6">
|
|
641
|
+
⏳ <strong>${t('冷却中')}</strong>${t(':第')} ${N + 1} ${t('次改名需距上次至少')} <strong>${nextRequiredMonths}</strong> ${t('个月')}<br>
|
|
642
|
+
${t('还差约')} <strong>${remainMonths}</strong> ${t('个月')}
|
|
643
|
+
</div>`
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// 累进规则提示(即使当前可改,也告知未来代价)
|
|
648
|
+
const policyHint = `
|
|
649
|
+
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;padding:10px 12px;font-size:11px;color:#1e40af;margin-bottom:10px;line-height:1.6">
|
|
650
|
+
<strong>📋 ${t('累进式冷却规则')}</strong><br>
|
|
651
|
+
${t('第 1 次改名:随时')}<br>
|
|
652
|
+
${t('第 2 次:距第 1 次至少 12 个月')}<br>
|
|
653
|
+
${t('第 3 次:距第 2 次至少 24 个月')}<br>
|
|
654
|
+
${t('第 N 次:距上次至少 (N-1) × 12 个月')}<br>
|
|
655
|
+
<span style="color:#475569">${t('原因:handle 是流量口令前缀,频繁改名会让累计推广信誉断层。')}</span>
|
|
656
|
+
</div>`
|
|
657
|
+
|
|
658
|
+
// 现状摘要
|
|
659
|
+
const summary = `
|
|
660
|
+
<div style="background:#f9fafb;border-radius:6px;padding:8px 10px;font-size:11px;color:#374151;margin-bottom:10px;line-height:1.6">
|
|
661
|
+
${t('你已改名')} <strong>${N}</strong> ${t('次')}${lastAt ? ` · ${t('上次')} ${fmtTime(lastAt)}` : ''}<br>
|
|
662
|
+
${cooldownActive ? '' : `<span style="color:#16a34a">✓ ${t('当前可以改名')}</span> · ${t('改名后下次需等')} <strong>${afterThisRequiredMonths}</strong> ${t('个月')}`}
|
|
663
|
+
</div>`
|
|
664
|
+
|
|
665
|
+
_openModal(`
|
|
666
|
+
<h2 style="font-size:16px;font-weight:600;margin-bottom:8px">${t('修改用户名')}</h2>
|
|
667
|
+
<p style="font-size:12px;color:#6b7280;margin-bottom:10px">${t('公开唯一标识,3–20 字符,仅小写字母 / 数字 / . _')}</p>
|
|
668
|
+
${policyHint}
|
|
669
|
+
${summary}
|
|
670
|
+
${cooldownInfo}
|
|
671
|
+
<div class="form-group">
|
|
672
|
+
<label class="form-label">${t('当前')}</label>
|
|
673
|
+
<code style="display:block;padding:8px;background:#f9fafb;border-radius:6px;font-size:14px">@${escHtml(profile.handle || '')}</code>
|
|
674
|
+
</div>
|
|
675
|
+
<div class="form-group">
|
|
676
|
+
<label class="form-label">${t('新用户名')}</label>
|
|
677
|
+
<input id="new-handle-inp" class="form-control" placeholder="${t('例如 season_2026')}" maxlength="20" ${cooldownActive ? 'disabled' : ''}>
|
|
678
|
+
</div>
|
|
679
|
+
<div id="ch-handle-msg"></div>
|
|
680
|
+
<div style="display:flex;gap:8px;margin-top:8px">
|
|
681
|
+
<button class="btn btn-outline" style="flex:1" onclick="closeModal()">${t('取消')}</button>
|
|
682
|
+
<button class="btn btn-primary" style="flex:1" onclick="doChangeHandle()" ${cooldownActive ? 'disabled style="opacity:0.5;flex:1"' : ''}>${cooldownActive ? '⏳ ' + t('冷却中') : t('保存')}</button>
|
|
683
|
+
</div>
|
|
684
|
+
`)
|
|
685
|
+
if (!cooldownActive) setTimeout(() => document.getElementById('new-handle-inp')?.focus(), 50)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
window.doChangeHandle = async () => {
|
|
689
|
+
const handle = document.getElementById('new-handle-inp')?.value?.trim() || ''
|
|
690
|
+
const msg = document.getElementById('ch-handle-msg')
|
|
691
|
+
if (!handle) { if (msg) msg.innerHTML = alert$('error', t('请填写新用户名')); return }
|
|
692
|
+
// 二次确认:累进冷却的代价要在用户主动点 OK 之前再清晰一次
|
|
693
|
+
const profile = await GET('/profile').catch(() => null)
|
|
694
|
+
if (profile) {
|
|
695
|
+
const N = (profile.handle_change_log || []).length
|
|
696
|
+
const nextWait = (N + 1) * 12
|
|
697
|
+
const confirmMsg = t('确认改名为 @') + handle + '?\n\n'
|
|
698
|
+
+ t('这是你的第 ') + (N + 1) + t(' 次改名。\n')
|
|
699
|
+
+ t('改名后,下次改名需距本次至少 ') + nextWait + t(' 个月。\n\n')
|
|
700
|
+
+ t('handle 是你的流量口令前缀 — 已发布到外站视频/帖子的旧 anchor 仍然有效,但新 anchor 必须使用新 handle。')
|
|
701
|
+
if (!confirm(confirmMsg)) return
|
|
702
|
+
}
|
|
703
|
+
// handle 是公开身份,泄露后果显著 — 强制密码二次验证
|
|
704
|
+
const ok = await requireApiKeyPassword()
|
|
705
|
+
if (!ok) return
|
|
706
|
+
if (msg) msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('保存中...')}</div>`
|
|
707
|
+
const res = await POST('/profile/change-handle', { handle })
|
|
708
|
+
if (res.error) { if (msg) msg.innerHTML = alert$('error', res.error); return }
|
|
709
|
+
closeModal()
|
|
710
|
+
state.user = null
|
|
711
|
+
const nextHint = res.next_change_required_months ? ` · ${t('下次改名需等')} ${res.next_change_required_months} ${t('个月')}` : ''
|
|
712
|
+
toast$(t('用户名已更新') + nextHint)
|
|
713
|
+
setTimeout(() => renderProfile(document.getElementById('app')), 500)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
window.toggleNickEdit = (editing) => {
|
|
717
|
+
const view = document.getElementById('nick-view')
|
|
718
|
+
const edit = document.getElementById('nick-edit')
|
|
719
|
+
if (!view || !edit) return
|
|
720
|
+
view.style.display = editing ? 'none' : 'flex'
|
|
721
|
+
edit.style.display = editing ? '' : 'none'
|
|
722
|
+
if (editing) setTimeout(() => document.getElementById('new-name-inp')?.focus(), 50)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
window.doChangeName = async () => {
|
|
726
|
+
const newName = document.getElementById('new-name-inp')?.value?.trim()
|
|
727
|
+
const msgEl = document.getElementById('change-name-msg')
|
|
728
|
+
if (!msgEl) return
|
|
729
|
+
if (!newName) { msgEl.innerHTML = alert$('error', t('请填写新昵称')); return }
|
|
730
|
+
msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('保存中...')}</div>`
|
|
731
|
+
const res = await POST('/profile/change-name', { name: newName })
|
|
732
|
+
if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
|
|
733
|
+
state.user = null
|
|
734
|
+
msgEl.innerHTML = alert$('success', t('昵称已更新'))
|
|
735
|
+
setTimeout(() => renderProfile(document.getElementById('app')), 800)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// P14.5 社交资料保存
|
|
739
|
+
window.saveSocialProfile = async () => {
|
|
740
|
+
const bio = document.getElementById('bio-inp')?.value || ''
|
|
741
|
+
const anchor = document.getElementById('anchor-inp')?.value || ''
|
|
742
|
+
const msgEl = document.getElementById('social-msg')
|
|
743
|
+
if (!msgEl) return
|
|
744
|
+
msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('保存中...')}</div>`
|
|
745
|
+
const res = await api('PATCH', '/profile', { bio, search_anchor: anchor })
|
|
746
|
+
if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
|
|
747
|
+
msgEl.innerHTML = alert$('success', t('已保存'))
|
|
748
|
+
setTimeout(() => { msgEl.innerHTML = '' }, 1500)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
window.toggleFeedVisible = async (checked) => {
|
|
752
|
+
const res = await api('PATCH', '/profile', { feed_visible: checked ? 1 : 0 })
|
|
753
|
+
if (res.error) toast$(res.error, 'error')
|
|
754
|
+
else toast$(checked ? t('已开启动态展示') : t('已隐藏动态'))
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// A2:拉黑/解除拉黑
|
|
758
|
+
window.toggleBlock = async (userId, currentlyBlocked) => {
|
|
759
|
+
if (currentlyBlocked) {
|
|
760
|
+
if (!confirm(t('确认解除拉黑?将再次看到该用户的商品和动态。'))) return
|
|
761
|
+
const r = await DELETE(`/blocklist/${userId}`)
|
|
762
|
+
if (r.error) return toast$(r.error, 'error')
|
|
763
|
+
toast$(t('已解除拉黑'))
|
|
764
|
+
} else {
|
|
765
|
+
const reason = prompt(t('拉黑原因(可选)'), '')
|
|
766
|
+
if (reason === null) return
|
|
767
|
+
const r = await POST(`/blocklist/${userId}`, { reason })
|
|
768
|
+
if (r.error) return toast$(r.error, 'error')
|
|
769
|
+
toast$(t('已拉黑'))
|
|
770
|
+
}
|
|
771
|
+
renderUserProfile(document.getElementById('app'), userId)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
window.unblockUser = async (userId) => {
|
|
775
|
+
if (!confirm(t('解除对该用户的拉黑?'))) return
|
|
776
|
+
const r = await DELETE(`/blocklist/${userId}`)
|
|
777
|
+
if (r.error) return toast$(r.error, 'error')
|
|
778
|
+
toast$(t('已解除'))
|
|
779
|
+
renderProfile(document.getElementById('app'))
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// 地址摘要:用于折叠态显示
|
|
783
|
+
function addressSummaryText(a) {
|
|
784
|
+
if (!a) return ''
|
|
785
|
+
const hasAny = ['recipient_name','line1','country','state','city','phone1'].some(k => (a[k] || '').trim())
|
|
786
|
+
if (!hasAny) return ''
|
|
787
|
+
const parts = []
|
|
788
|
+
if (a.recipient_name) parts.push(a.recipient_name)
|
|
789
|
+
const region = [a.country, a.state, a.city].filter(Boolean).join('·')
|
|
790
|
+
if (region) parts.push(region)
|
|
791
|
+
if (a.phone1) {
|
|
792
|
+
const p = String(a.phone1).replace(/\s/g, '')
|
|
793
|
+
parts.push(p.length >= 8 ? p.replace(/(\d{3,4})\d+(\d{3,4})/, '$1****$2') : p)
|
|
794
|
+
}
|
|
795
|
+
return parts.join(' · ')
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// 历史地址:localStorage 最近 3 条
|
|
799
|
+
function addressHistoryGet() {
|
|
800
|
+
try { return JSON.parse(localStorage.getItem('webaz_addr_history') || '[]') } catch { return [] }
|
|
801
|
+
}
|
|
802
|
+
function addressHistoryPush(a) {
|
|
803
|
+
if (!a || !a.recipient_name || !a.line1) return
|
|
804
|
+
const list = addressHistoryGet()
|
|
805
|
+
const key = `${a.recipient_name}|${a.line1}|${a.city || ''}`
|
|
806
|
+
const filtered = list.filter(h => `${h.recipient_name}|${h.line1}|${h.city || ''}` !== key)
|
|
807
|
+
filtered.unshift({ ...a, ts: Date.now() })
|
|
808
|
+
localStorage.setItem('webaz_addr_history', JSON.stringify(filtered.slice(0, 3)))
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function renderAddressHistory() {
|
|
812
|
+
const wrap = document.getElementById('addr-history-wrap')
|
|
813
|
+
if (!wrap) return
|
|
814
|
+
const list = addressHistoryGet()
|
|
815
|
+
if (!list.length) { wrap.innerHTML = ''; return }
|
|
816
|
+
wrap.innerHTML = `
|
|
817
|
+
<div style="font-size:12px;color:#6b7280;margin-bottom:6px">📜 ${t('历史地址')}</div>
|
|
818
|
+
<div style="display:flex;flex-direction:column;gap:6px">
|
|
819
|
+
${list.map((h, i) => `
|
|
820
|
+
<div style="display:flex;align-items:center;gap:8px;padding:8px 10px;background:#f9fafb;border-radius:6px;border:1px solid #f3f4f6;cursor:pointer" onclick="applyAddressHistory(${i})" title="${t('点击填入')}">
|
|
821
|
+
<div style="flex:1;min-width:0">
|
|
822
|
+
<div style="font-size:12px;color:#374151;font-weight:500">${escHtml(addressSummaryText(h) || h.line1 || '')}</div>
|
|
823
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escHtml([h.line1, h.line2].filter(Boolean).join(' · '))}</div>
|
|
824
|
+
</div>
|
|
825
|
+
<button onclick="event.stopPropagation();removeAddressHistory(${i})" title="${t('删除')}" style="background:none;border:none;cursor:pointer;color:#9ca3af;font-size:14px;padding:2px 6px">✕</button>
|
|
826
|
+
</div>`).join('')}
|
|
827
|
+
</div>
|
|
828
|
+
`
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
window.applyAddressHistory = (idx) => {
|
|
832
|
+
const list = addressHistoryGet()
|
|
833
|
+
const h = list[idx]; if (!h) return
|
|
834
|
+
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v || '' }
|
|
835
|
+
set('addr-recipient-inp', h.recipient_name)
|
|
836
|
+
set('addr-phone1-inp', h.phone1)
|
|
837
|
+
set('addr-phone2-inp', h.phone2)
|
|
838
|
+
set('addr-country-inp', h.country || '中国')
|
|
839
|
+
set('addr-state-inp', h.state)
|
|
840
|
+
set('addr-city-inp', h.city)
|
|
841
|
+
set('addr-line1-inp', h.line1)
|
|
842
|
+
set('addr-line2-inp', h.line2)
|
|
843
|
+
set('addr-postal-inp', h.postal_code)
|
|
844
|
+
toast$(t('已填入历史地址,可继续编辑或直接保存'))
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
window.removeAddressHistory = (idx) => {
|
|
848
|
+
const list = addressHistoryGet()
|
|
849
|
+
list.splice(idx, 1)
|
|
850
|
+
localStorage.setItem('webaz_addr_history', JSON.stringify(list))
|
|
851
|
+
renderAddressHistory()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
window.toggleAddressEdit = (editing) => {
|
|
855
|
+
const sum = document.getElementById('addr-summary')
|
|
856
|
+
const form = document.getElementById('addr-form')
|
|
857
|
+
const btn = document.getElementById('addr-edit-toggle')
|
|
858
|
+
if (!sum || !form) return
|
|
859
|
+
sum.style.display = editing ? 'none' : ''
|
|
860
|
+
form.style.display = editing ? '' : 'none'
|
|
861
|
+
if (btn) btn.textContent = editing ? t('收起') : (sum.querySelector('div[onclick]') ? t('编辑') : t('添加'))
|
|
862
|
+
if (btn) btn.setAttribute('onclick', editing ? 'toggleAddressEdit(false)' : 'toggleAddressEdit(true)')
|
|
863
|
+
if (editing) renderAddressHistory()
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// P-Polish 2:保存默认配送地址(结构化字段)
|
|
867
|
+
window.saveDefaultAddress = async () => {
|
|
868
|
+
const get = (id) => document.getElementById(id)?.value?.trim() || ''
|
|
869
|
+
const msg = document.getElementById('addr-msg')
|
|
870
|
+
if (!msg) return
|
|
871
|
+
msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('保存中...')}</div>`
|
|
872
|
+
const payload = {
|
|
873
|
+
line1: get('addr-line1-inp'),
|
|
874
|
+
line2: get('addr-line2-inp'),
|
|
875
|
+
country: get('addr-country-inp'),
|
|
876
|
+
state: get('addr-state-inp'),
|
|
877
|
+
city: get('addr-city-inp'),
|
|
878
|
+
recipient_name: get('addr-recipient-inp'),
|
|
879
|
+
phone1: get('addr-phone1-inp'),
|
|
880
|
+
phone2: get('addr-phone2-inp'),
|
|
881
|
+
postal_code: get('addr-postal-inp'),
|
|
882
|
+
}
|
|
883
|
+
const res = await POST('/profile/default-address', payload)
|
|
884
|
+
if (res.error) { msg.innerHTML = alert$('error', res.error); return }
|
|
885
|
+
state.profileMini = null
|
|
886
|
+
addressHistoryPush(payload)
|
|
887
|
+
msg.innerHTML = alert$('success', t('已保存'))
|
|
888
|
+
setTimeout(() => renderProfile(document.getElementById('app')), 800)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// P-Polish 2:粘贴智能识别(提取电话/邮编/省市 + 剩余进 line1)
|
|
892
|
+
window.addressPasteSmartFill = async () => {
|
|
893
|
+
let pasted = ''
|
|
894
|
+
try { pasted = await navigator.clipboard.readText() } catch {}
|
|
895
|
+
if (!pasted) {
|
|
896
|
+
pasted = prompt(t('粘贴完整地址(如电商站点复制的多行地址)'), '') || ''
|
|
897
|
+
}
|
|
898
|
+
if (!pasted.trim()) return toast$(t('未读到内容'), 'error')
|
|
899
|
+
|
|
900
|
+
let remaining = pasted.replace(/\r\n/g, '\n').trim()
|
|
901
|
+
const fill = (id, v) => { const el = document.getElementById(id); if (el && !el.value.trim()) el.value = v }
|
|
902
|
+
|
|
903
|
+
// 电话识别(中国 11 位 / 国际 +xx 形式 / 座机 区号-号码)
|
|
904
|
+
const phones = []
|
|
905
|
+
remaining = remaining.replace(/(\+?\d[\d\s\-]{7,}\d)/g, (m) => { phones.push(m.replace(/\s/g, '')); return ' ' })
|
|
906
|
+
if (phones[0]) fill('addr-phone1-inp', phones[0])
|
|
907
|
+
if (phones[1]) fill('addr-phone2-inp', phones[1])
|
|
908
|
+
|
|
909
|
+
// 邮政编码(中国 6 位 / 美国 5 位 / 国际带连字符)
|
|
910
|
+
const postal = remaining.match(/(?<!\d)(\d{6}|\d{5}(?:-\d{4})?)(?!\d)/)
|
|
911
|
+
if (postal) { fill('addr-postal-inp', postal[1]); remaining = remaining.replace(postal[1], ' ') }
|
|
912
|
+
|
|
913
|
+
// 省/市识别(含 省/市/自治区/特别行政区)
|
|
914
|
+
const provinceMatch = remaining.match(/([一-龥]{2,15}(?:省|自治区|特别行政区|市))/)
|
|
915
|
+
if (provinceMatch) { fill('addr-state-inp', provinceMatch[1].replace(/(省|市)$/,'')); remaining = remaining.replace(provinceMatch[1], ' ') }
|
|
916
|
+
const cityMatch = remaining.match(/([一-龥]{2,15}(?:市|区|县|州|镇))/)
|
|
917
|
+
if (cityMatch) { fill('addr-city-inp', cityMatch[1]); remaining = remaining.replace(cityMatch[1], ' ') }
|
|
918
|
+
|
|
919
|
+
// 默认国家 = 中国(若已有省市可推断)
|
|
920
|
+
fill('addr-country-inp', '中国')
|
|
921
|
+
|
|
922
|
+
// 收件人识别(独立短行 2-6 字符纯汉字)
|
|
923
|
+
const lines = remaining.split(/\n+/).map(l => l.trim()).filter(Boolean)
|
|
924
|
+
for (const line of lines) {
|
|
925
|
+
if (!document.getElementById('addr-recipient-inp').value && /^[一-龥]{2,6}$/.test(line)) {
|
|
926
|
+
fill('addr-recipient-inp', line)
|
|
927
|
+
remaining = remaining.replace(line, ' ')
|
|
928
|
+
break
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// 剩余整段塞 line1
|
|
933
|
+
const cleaned = remaining.replace(/\s+/g, ' ').trim()
|
|
934
|
+
if (cleaned) {
|
|
935
|
+
if (cleaned.length <= 100) fill('addr-line1-inp', cleaned)
|
|
936
|
+
else {
|
|
937
|
+
fill('addr-line1-inp', cleaned.slice(0, 100))
|
|
938
|
+
fill('addr-line2-inp', cleaned.slice(100, 200))
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
toast$(t('已智能填充,请检查必填项并保存'))
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
window.bindBackToStep1 = () => {
|
|
945
|
+
document.getElementById('bind-step1').style.display = ''
|
|
946
|
+
document.getElementById('bind-step2').style.display = 'none'
|
|
947
|
+
const m = document.getElementById('bind-msg1'); if (m) m.innerHTML = ''
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
window.doSendBindCode = async () => {
|
|
951
|
+
const email = document.getElementById('bind-email-inp')?.value?.trim()
|
|
952
|
+
const msg = document.getElementById('bind-msg1')
|
|
953
|
+
if (!email) { msg.innerHTML = alert$('error', t('请填写邮箱')); return }
|
|
954
|
+
msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('发送中...')}</div>`
|
|
955
|
+
const res = await POST('/profile/bind-email', { email })
|
|
956
|
+
if (res.error) { msg.innerHTML = alert$('error', res.error); return }
|
|
957
|
+
msg.innerHTML = ''
|
|
958
|
+
document.getElementById('bind-step1').style.display = 'none'
|
|
959
|
+
document.getElementById('bind-step2').style.display = ''
|
|
960
|
+
const hint = document.getElementById('bind-target-hint')
|
|
961
|
+
hint.textContent = t('验证码已发送至 ') + res.target_hint + (res.dev_code ? ` (dev: ${res.dev_code})` : '')
|
|
962
|
+
document.getElementById('bind-code-inp').dataset.email = email
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
window.doConfirmBindEmail = async () => {
|
|
966
|
+
const codeInp = document.getElementById('bind-code-inp')
|
|
967
|
+
const email = codeInp?.dataset?.email
|
|
968
|
+
const code = codeInp?.value?.trim()
|
|
969
|
+
const msg = document.getElementById('bind-msg2')
|
|
970
|
+
if (!code) { msg.innerHTML = alert$('error', t('请填写验证码')); return }
|
|
971
|
+
msg.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('验证中...')}</div>`
|
|
972
|
+
const res = await POST('/profile/confirm-email', { email, code })
|
|
973
|
+
if (res.error) { msg.innerHTML = alert$('error', res.error); return }
|
|
974
|
+
msg.innerHTML = alert$('success', t('邮箱绑定成功'))
|
|
975
|
+
state.user = null
|
|
976
|
+
setTimeout(() => renderProfile(document.getElementById('app')), 800)
|
|
977
|
+
}
|