@seasonkoh/webaz 0.1.27 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,836 @@
1
+ // WebAZ — contribution / admin-intake workflows (classic multi-script split, slice D)
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 function declarations are global; window.* handlers are global; the
6
+ // blocks here run only on route/click (after app.js loads), so cross-file globals
7
+ // (GET/POST/api/state/escHtml/requestPasskeyGate/render*/toast$/...) resolve at
8
+ // call time. No import/export.
9
+ //
10
+ // NB: `_qT` is declared `var` (not const) here so it is a global property — the
11
+ // contribution-facts read surface in app.js also uses _qT, so it must be shared
12
+ // cross-file. _qStatusBadge / _ocStatusBadge are used only within this file and
13
+ // stay file-local.
14
+ //
15
+ // Pure relocation: public-ideas intake, task-proposal inbox/draft flow,
16
+ // build-task quota request/review, and operator-claim workflow. No money/order/
17
+ // payment/wallet/settlement/fund/protocol-param path.
18
+
19
+ // Wave F-1: 协议指标看板
20
+ // 2026-05-25 admin 查看 #welcome 提交:sub-tab 切换 建议 / 邮箱订阅(独立表)
21
+ async function renderAdminPublicIdeas(app) {
22
+ if (!state.user) { renderLogin(); return }
23
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
24
+ app.innerHTML = shell(loading$(), 'admin')
25
+ const tab = state._adminIdeasTab || 'ideas' // 'ideas' | 'emails'
26
+ const stat = state._adminIdeasStatus || ''
27
+ const showUnsub = !!state._adminIdeasShowUnsub
28
+ const estat = state._adminEmailStatus || '' // 申请处理状态过滤:pending/contacted/invited/done
29
+ const qs = new URLSearchParams()
30
+ if (stat && tab === 'ideas') qs.set('status', stat)
31
+ if (showUnsub && tab === 'emails') qs.set('include_unsubscribed', '1')
32
+ if (estat && tab === 'emails') qs.set('handle_status', estat)
33
+ const url = tab === 'emails' ? '/admin/email-subscriptions' : '/admin/public-ideas'
34
+ const r = await GET(url + (qs.toString() ? '?' + qs.toString() : ''))
35
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'admin'); return }
36
+ const items = r.items || []
37
+ const c = r.counts || {}
38
+
39
+ const chip = (val, label, current, group) =>
40
+ `<button onclick="setAdminIdeasFilter('${group}','${val}')" style="padding:5px 12px;border-radius:99px;font-size:11px;cursor:pointer;border:1px solid ${current===val?'#6366f1':'#e5e7eb'};background:${current===val?'#eef2ff':'#fff'};color:${current===val?'#4338ca':'#6b7280'};font-weight:600">${label}</button>`
41
+
42
+ const statusBadge = (s) => {
43
+ const cfg = {
44
+ new: { bg: '#fef3c7', fg: '#92400e', label: t('新') },
45
+ triaged: { bg: '#dbeafe', fg: '#1e40af', label: t('已查看') },
46
+ resolved: { bg: '#dcfce7', fg: '#166534', label: t('已处理') },
47
+ spam: { bg: '#fee2e2', fg: '#991b1b', label: t('Spam') },
48
+ }[s] || { bg: '#f3f4f6', fg: '#6b7280', label: s }
49
+ return `<span style="font-size:10px;background:${cfg.bg};color:${cfg.fg};padding:2px 8px;border-radius:99px;font-weight:600">${cfg.label}</span>`
50
+ }
51
+
52
+ const subTab = (k, label, n) =>
53
+ `<button onclick="setAdminIdeasFilter('tab','${k}')" style="flex:1;padding:10px;border:1px solid ${tab===k?'#6366f1':'#e5e7eb'};background:${tab===k?'#eef2ff':'#fff'};color:${tab===k?'#4338ca':'#6b7280'};border-radius:8px;cursor:pointer;font-size:13px;font-weight:600">${label} <span style="color:${tab===k?'#6366f1':'#9ca3af'};font-weight:400">(${n})</span></button>`
54
+
55
+ // 计数(两 tab 各显示自身的 counts)
56
+ const counts = tab === 'emails'
57
+ ? [
58
+ { l: t('全部'), v: c.total || 0, color: '#6b7280' },
59
+ { l: t('待处理'), v: c.st_pending || 0, color: '#d97706' },
60
+ { l: t('已联系'), v: c.st_contacted || 0, color: '#1e40af' },
61
+ { l: t('已邀请'), v: c.st_invited || 0, color: '#7c3aed' },
62
+ { l: t('已完成'), v: c.st_done || 0, color: '#16a34a' },
63
+ ]
64
+ : [
65
+ { l: t('全部'), v: c.total || 0, color: '#6b7280' },
66
+ { l: t('待处理'), v: c.st_new || 0, color: '#d97706' },
67
+ { l: t('已查看'), v: c.st_triaged || 0, color: '#1e40af' },
68
+ { l: t('已处理'), v: c.st_resolved || 0, color: '#16a34a' },
69
+ { l: 'Spam', v: c.st_spam || 0, color: '#dc2626' },
70
+ ]
71
+
72
+ app.innerHTML = shell(`
73
+ <div style="padding:14px;max-width:920px;margin:0 auto">
74
+ <h1 class="page-title">📨 ${t('Welcome 提交(邮箱订阅 + 留言/建议)')}</h1>
75
+ <div style="font-size:11px;color:#9ca3af;margin-bottom:12px">⚠️ ${t('PII 数据:查看本页将被审计记录。请勿截屏外传。')}</div>
76
+
77
+ <div style="display:flex;gap:8px;margin-bottom:14px">
78
+ ${subTab('ideas', '💬 ' + t('建议'), c.total || 0)}
79
+ ${subTab('emails', '📧 ' + t('邮箱订阅'), 0)}
80
+ </div>
81
+
82
+ <div style="display:grid;grid-template-columns:repeat(${counts.length},1fr);gap:8px;margin-bottom:14px">
83
+ ${counts.map(s => `<div style="background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:10px;text-align:center"><div style="font-size:18px;font-weight:700;color:${s.color}">${s.v}</div><div style="font-size:10px;color:#9ca3af;margin-top:2px">${s.l}</div></div>`).join('')}
84
+ </div>
85
+
86
+ ${tab === 'emails' ? `
87
+ <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;align-items:center">
88
+ <div style="font-size:11px;color:#6b7280;margin-right:4px">${t('处理状态')}:</div>
89
+ ${chip('', t('全部'), estat, 'estatus')}
90
+ ${chip('pending', t('待处理'), estat, 'estatus')}
91
+ ${chip('contacted', t('已联系'), estat, 'estatus')}
92
+ ${chip('invited', t('已邀请'), estat, 'estatus')}
93
+ ${chip('done', t('已完成'), estat, 'estatus')}
94
+ </div>
95
+ <div style="margin-bottom:14px">
96
+ <label style="font-size:12px;color:#6b7280;cursor:pointer">
97
+ <input type="checkbox" ${showUnsub?'checked':''} onchange="setAdminIdeasFilter('show-unsub',this.checked?'1':'')" style="margin-right:6px;vertical-align:middle">
98
+ ${t('显示已退订')}
99
+ </label>
100
+ </div>
101
+ ` : `
102
+ <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px">
103
+ <div style="font-size:11px;color:#6b7280;align-self:center;margin-right:4px">${t('状态')}:</div>
104
+ ${chip('', t('全部'), stat, 'status')}
105
+ ${chip('new', t('待处理'), stat, 'status')}
106
+ ${chip('triaged', t('已查看'), stat, 'status')}
107
+ ${chip('resolved', t('已处理'), stat, 'status')}
108
+ ${chip('spam', 'Spam', stat, 'status')}
109
+ </div>
110
+ `}
111
+
112
+ ${items.length === 0 ? `<div class="empty" style="padding:40px;text-align:center;color:#9ca3af">${t('暂无记录')}</div>` : tab === 'emails' ? (() => {
113
+ const roleLabel = { buyer: t('买家'), seller: t('卖家'), creator: t('创作者'), verifier: t('审核员'), arbitrator: t('仲裁员'), other: t('其他') }
114
+ const roleColor = { buyer: '#0891b2', seller: '#d97706', creator: '#7c3aed', verifier: '#16a34a', arbitrator: '#dc2626', other: '#6b7280' }
115
+ const ES = {
116
+ pending: { label: t('待处理'), bg: '#fef3c7', fg: '#92400e' },
117
+ contacted: { label: t('已联系'), bg: '#dbeafe', fg: '#1e40af' },
118
+ invited: { label: t('已邀请'), bg: '#ede9fe', fg: '#6d28d9' },
119
+ done: { label: t('已完成'), bg: '#dcfce7', fg: '#166534' },
120
+ }
121
+ const estSwitch = (it) => {
122
+ const cur = it.handle_status || 'pending'
123
+ const btns = ['pending', 'contacted', 'invited', 'done'].map(s => {
124
+ const on = s === cur
125
+ return `<button onclick="setEmailHandleStatus('${it.id}','${s}')" style="padding:3px 9px;border-radius:99px;font-size:10px;cursor:pointer;border:1px solid ${on ? ES[s].fg : '#e5e7eb'};background:${on ? ES[s].bg : '#fff'};color:${on ? ES[s].fg : '#9ca3af'};font-weight:${on ? 600 : 400}">${ES[s].label}</button>`
126
+ }).join('')
127
+ return `<div style="display:flex;gap:5px;flex-wrap:wrap;align-items:center;margin-top:8px;padding-top:8px;border-top:1px solid #f3f4f6"><span style="font-size:10px;color:#9ca3af;margin-right:2px">${t('处理')}:</span>${btns}</div>`
128
+ }
129
+ return items.map(it => `
130
+ <div class="card" style="padding:14px;margin-bottom:10px">
131
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px">
132
+ <div style="flex:1;min-width:0;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
133
+ <code style="font-size:13px;background:#f3f4f6;padding:3px 8px;border-radius:4px;color:#18181B">${escHtml(it.email)}</code>
134
+ ${it.role_preference ? `<span style="font-size:10px;background:#fff;color:${roleColor[it.role_preference]||'#6b7280'};border:1px solid ${roleColor[it.role_preference]||'#e5e7eb'};padding:2px 8px;border-radius:99px;font-weight:600">${roleLabel[it.role_preference] || it.role_preference}</span>` : `<span style="font-size:10px;color:#9ca3af">${t('未选身份')}</span>`}
135
+ ${it.unsubscribed_at
136
+ ? `<span style="font-size:10px;background:#f3f4f6;color:#9ca3af;padding:2px 8px;border-radius:99px">${t('已退订')}</span>`
137
+ : `<span style="font-size:10px;background:#dcfce7;color:#166534;padding:2px 8px;border-radius:99px;font-weight:600">${t('订阅中')}</span>`}
138
+ </div>
139
+ <button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px;flex-shrink:0" onclick="navigator.clipboard?.writeText('${escAttr(it.email)}').then(()=>toast('${t('已复制')}'))">${t('复制')}</button>
140
+ </div>
141
+ ${it.note ? `<div style="font-size:12px;color:#374151;line-height:1.6;white-space:pre-wrap;word-break:break-word;background:#f9fafb;padding:8px 10px;border-radius:6px;margin-bottom:6px">💬 ${escHtml(it.note)}</div>` : ''}
142
+ <div style="font-size:11px;color:#9ca3af">${it.source} · ${fmtTime(it.consent_at)}${it.user_id ? ' · 👤 ' + it.user_id.slice(0,12) : ''}${it.unsubscribed_at ? ' · ' + t('退订于') + ' ' + fmtTime(it.unsubscribed_at) : ''}</div>
143
+ ${estSwitch(it)}
144
+ </div>`).join('')
145
+ })() : items.map(it => `
146
+ <div class="card" style="padding:14px;margin-bottom:10px">
147
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px">
148
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
149
+ <span style="font-size:11px;background:#ecfeff;color:#0891b2;padding:2px 8px;border-radius:99px;font-weight:600">💬 ${t('建议')}</span>
150
+ ${statusBadge(it.status)}
151
+ <span style="font-size:11px;color:#9ca3af">${fmtTime(it.created_at)}</span>
152
+ ${it.user_id ? `<span style="font-size:11px;color:#6366f1">👤 <a href="#admin/users/${it.user_id}" style="color:inherit">${it.user_id.slice(0,12)}</a></span>` : `<span style="font-size:11px;color:#9ca3af">${t('匿名')}</span>`}
153
+ </div>
154
+ </div>
155
+ <div style="font-size:13px;color:#1f2937;line-height:1.6;white-space:pre-wrap;word-break:break-word;background:#f9fafb;padding:10px 12px;border-radius:6px;margin-bottom:8px">${escHtml(it.content)}</div>
156
+ ${it.contact ? `<div style="font-size:12px;color:#6b7280;margin-bottom:8px">📞 ${t('联系方式')}:<code style="background:#f3f4f6;padding:2px 6px;border-radius:4px">${escHtml(it.contact)}</code></div>` : ''}
157
+ <div style="display:flex;gap:6px;font-size:11px">
158
+ <button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px" onclick="setAdminIdeaStatus('${it.id}','triaged')">${t('标记已查看')}</button>
159
+ <button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px;color:#16a34a;border-color:#bbf7d0" onclick="setAdminIdeaStatus('${it.id}','resolved')">${t('标记已处理')}</button>
160
+ <button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px;color:#dc2626;border-color:#fecaca" onclick="setAdminIdeaStatus('${it.id}','spam')">${t('标 Spam')}</button>
161
+ </div>
162
+ </div>`
163
+ ).join('')}
164
+ </div>
165
+ `, 'admin')
166
+ }
167
+ window.setAdminIdeasFilter = (group, val) => {
168
+ if (group === 'tab') { state._adminIdeasTab = val; state._adminIdeasStatus = ''; state._adminIdeasShowUnsub = false; state._adminEmailStatus = '' }
169
+ else if (group === 'status') state._adminIdeasStatus = val
170
+ else if (group === 'estatus') state._adminEmailStatus = val
171
+ else if (group === 'show-unsub') state._adminIdeasShowUnsub = !!val
172
+ renderAdminPublicIdeas(document.getElementById('app'))
173
+ }
174
+ window.setEmailHandleStatus = async (id, status) => {
175
+ const r = await api('PATCH', `/admin/email-subscriptions/${id}/status`, { status })
176
+ if (r?.error) { toast$(r.error, 'error'); return }
177
+ toast$(t('已更新'))
178
+ setTimeout(() => renderAdminPublicIdeas(document.getElementById('app')), 300)
179
+ }
180
+ window.setAdminIdeaStatus = async (id, status) => {
181
+ const r = await api('PATCH', `/admin/public-ideas/${id}`, { status })
182
+ if (r?.error) { toast(r.error); return }
183
+ toast(t('已更新'))
184
+ setTimeout(() => renderAdminPublicIdeas(document.getElementById('app')), 300)
185
+ }
186
+
187
+ // PR9I — Task Proposal Inbox admin review (maintainer-only). Calls the #331 admin endpoints. A proposal is
188
+ // a SUGGESTION, never a contribution fact / reward / participation; "Convert" only records the review
189
+ // decision + the proposer→reviewer→ref evidence chain — it does NOT auto-create a build_task.
190
+ async function renderAdminTaskProposals(app) {
191
+ if (!state.user) { renderLogin(); return }
192
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
193
+ const en = window._lang === 'en'
194
+ const T = (zh, e) => en && e ? e : zh
195
+ app.innerHTML = shell(loading$(), 'admin')
196
+ const sf = state._proposalStatus || '' // '' | new | needs_info | rejected | converted
197
+ const [r, dr] = await Promise.all([
198
+ GET('/admin/task-proposals' + (sf ? '?status=' + encodeURIComponent(sf) : '')),
199
+ GET('/admin/build-task-drafts'),
200
+ ])
201
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'admin'); return }
202
+ const proposals = r.proposals || []
203
+ const drafts = (dr && dr.drafts) || []
204
+ const draftedIds = new Set(drafts.map((d) => d.source_proposal_id).filter(Boolean)) // proposals that already have an unpublished draft
205
+ const notice = en ? (r.value_boundary?.notice_en || '') : (r.value_boundary?.notice_zh || '')
206
+ const STATUS = {
207
+ new: { bg: '#fef3c7', fg: '#92400e', label: T('待审', 'New') },
208
+ needs_info: { bg: '#dbeafe', fg: '#1e40af', label: T('待补充', 'Needs info') },
209
+ rejected: { bg: '#fee2e2', fg: '#991b1b', label: T('已拒绝', 'Rejected') },
210
+ converted: { bg: '#dcfce7', fg: '#166534', label: T('已转任务', 'Converted') },
211
+ }
212
+ const badge = (s) => { const c = STATUS[s] || { bg: '#f3f4f6', fg: '#6b7280', label: s }; return `<span style="font-size:10px;background:${c.bg};color:${c.fg};padding:2px 8px;border-radius:99px;font-weight:600">${c.label}</span>` }
213
+ const chip = (val, label) => `<button onclick="setProposalStatusFilter('${val}')" style="padding:5px 12px;border-radius:99px;font-size:11px;cursor:pointer;border:1px solid ${sf === val ? '#6366f1' : '#e5e7eb'};background:${sf === val ? '#eef2ff' : '#fff'};color:${sf === val ? '#4338ca' : '#6b7280'};font-weight:600">${label}</button>`
214
+ const field = (label, val) => val ? `<div style="font-size:12px;color:#374151;margin-top:4px"><b>${label}:</b> ${escHtml(String(val))}</div>` : ''
215
+ // inline "create formal task draft" form (prefilled from the proposal; AI can also prefill it). All list
216
+ // fields are newline-separated. These are the agent-handoff fields the formal task model requires.
217
+ const ta = (id, ph, val, h) => `<textarea id="${id}" placeholder="${ph}" style="width:100%;box-sizing:border-box;min-height:${h || 38}px;padding:6px 8px;border:1px solid #d4d4d8;border-radius:6px;font-size:12px;margin-top:6px">${val ? escHtml(String(val)) : ''}</textarea>`
218
+ const draftForm = (p) => `<div id="df-${escHtml(p.id)}" style="display:none;margin-top:10px;border:1px dashed #c7d2fe;background:#f5f7ff;border-radius:8px;padding:10px">
219
+ <div style="font-size:11px;color:#4338ca;font-weight:600;margin-bottom:2px">${T('建正式任务草稿(未发布)', 'Create formal task draft (unpublished)')}</div>
220
+ <div style="font-size:10px;color:#6b7280;margin-bottom:4px">${T('草稿默认隐藏不可认领;填齐 agent 交接字段后由人工显式「发布」才进任务板。', 'A draft is hidden + unclaimable; only an explicit human “Publish” (after the agent-handoff fields are filled) puts it on the board.')}</div>
221
+ ${ta('df-title-' + escHtml(p.id), T('标题', 'Title'), p.title)}
222
+ ${ta('df-area-' + escHtml(p.id), T('领域(可选)', 'Area (optional)'), p.suggested_area, 30)}
223
+ ${ta('df-source-' + escHtml(p.id), T('来源引用(文件 / RFC / issue,可选)', 'Source ref (file / RFC / issue, optional)'), p.source_ref, 30)}
224
+ ${ta('df-desc-' + escHtml(p.id), T('说明 / 原因', 'Summary / reason'), p.summary, 48)}
225
+ ${ta('df-allowed-' + escHtml(p.id), T('允许路径(每行一条)', 'Allowed paths (one per line)'), '')}
226
+ ${ta('df-fpaths-' + escHtml(p.id), T('禁止路径(每行一条)', 'Forbidden paths (one per line)'), '')}
227
+ ${ta('df-forbidden-' + escHtml(p.id), T('禁止动作(每行一条)', 'Forbidden actions (one per line)'), '')}
228
+ ${ta('df-accept-' + escHtml(p.id), T('验收标准(每行一条)', 'Acceptance criteria (one per line)'), p.expected_outcome)}
229
+ ${ta('df-verify-' + escHtml(p.id), T('验证命令(每行一条)', 'Verification commands (one per line)'), '')}
230
+ ${ta('df-deliver-' + escHtml(p.id), T('交付物(每行一条)', 'Deliverables (one per line)'), '')}
231
+ ${ta('df-dod-' + escHtml(p.id), T('完成定义', 'Definition of done'), '')}
232
+ ${ta('df-expect-' + escHtml(p.id), T('预期结果(留空则用说明)', 'Expected results (blank = use summary)'), '')}
233
+ <button onclick="createTaskDraft('${escHtml(p.id)}')" style="margin-top:8px;padding:7px 14px;border:none;background:#4338ca;color:#fff;border-radius:6px;font-size:12px;cursor:pointer;font-weight:600">${T('保存草稿', 'Save draft')}</button>
234
+ </div>`
235
+ const row = (p) => {
236
+ const terminal = p.status === 'rejected' || p.status === 'converted'
237
+ return `<div class="card" style="padding:14px;margin-bottom:10px">
238
+ <div style="display:flex;justify-content:space-between;gap:8px;align-items:flex-start">
239
+ <div style="font-weight:600;font-size:14px">${escHtml(p.title)}</div>${badge(p.status)}
240
+ </div>
241
+ <div style="font-family:monospace;font-size:11px;color:#6b7280;margin-top:3px">${T('案件 ID', 'Case ID')}: ${escHtml(p.case_id || p.id)}</div>
242
+ <div style="font-size:13px;color:#52525B;line-height:1.5;margin-top:6px;white-space:pre-wrap">${escHtml(p.summary)}</div>
243
+ ${field(T('建议领域', 'Area'), p.suggested_area)}
244
+ ${field(T('预期结果', 'Outcome'), p.expected_outcome)}
245
+ ${field(T('参考', 'Source ref'), p.source_ref)}
246
+ ${field('GitHub', p.proposer_github_login)}
247
+ ${field(T('提交时间', 'Created'), p.created_at)}
248
+ ${field(T('审阅备注', 'Review note'), p.review_note)}
249
+ ${field(T('已关联', 'Converted ref'), p.converted_ref)}
250
+ ${terminal
251
+ ? `<div style="font-size:11px;color:#9ca3af;margin-top:8px">${T('终态,不可再审', 'Terminal — locked')}${p.reviewer_id ? ' · ' + escHtml(String(p.reviewer_id)) : ''}</div>`
252
+ : `<div style="margin-top:10px;border-top:1px solid #f1f1f4;padding-top:10px">
253
+ <textarea id="pr-note-${escHtml(p.id)}" placeholder="${T('审阅备注(可选)', 'Review note (optional)')}" style="width:100%;box-sizing:border-box;min-height:44px;padding:6px 8px;border:1px solid #d4d4d8;border-radius:6px;font-size:12px"></textarea>
254
+ <input id="pr-ref-${escHtml(p.id)}" placeholder="${T('转任务时:关联正式 task / PR / release(可选)', 'On convert: link the real task / PR / release (optional)')}" style="width:100%;box-sizing:border-box;margin-top:6px;padding:6px 8px;border:1px solid #d4d4d8;border-radius:6px;font-size:12px">
255
+ <div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
256
+ <button onclick="aiAssistProposal('${escHtml(p.id)}')" style="padding:6px 12px;border:1px solid #8b5cf6;background:#fff;color:#6d28d9;border-radius:6px;font-size:12px;cursor:pointer">🤖 ${T('AI 建议', 'AI suggest')}</button>
257
+ ${draftedIds.has(p.id)
258
+ ? `<span style="padding:6px 10px;font-size:11px;color:#4338ca;background:#eef2ff;border-radius:6px">📝 ${T('已建草稿(在上方草稿区发布;发布即接受)', 'Draft created — publish it in the drafts panel above (publish = accept)')}</span>`
259
+ : `<button onclick="toggleDraftForm('${escHtml(p.id)}')" style="padding:6px 12px;border:1px solid #6366f1;background:#fff;color:#4338ca;border-radius:6px;font-size:12px;cursor:pointer">${T('建任务草稿', 'Create task draft')}</button>
260
+ <button onclick="reviewProposal('${escHtml(p.id)}','needs_info')" style="padding:6px 12px;border:1px solid #3b82f6;background:#fff;color:#1e40af;border-radius:6px;font-size:12px;cursor:pointer">${T('需补充', 'Needs info')}</button>
261
+ <button onclick="reviewProposal('${escHtml(p.id)}','rejected')" style="padding:6px 12px;border:1px solid #ef4444;background:#fff;color:#991b1b;border-radius:6px;font-size:12px;cursor:pointer">${T('拒绝', 'Reject')}</button>
262
+ <button onclick="reviewProposal('${escHtml(p.id)}','converted')" style="padding:6px 12px;border:1px solid #16a34a;background:#fff;color:#166534;border-radius:6px;font-size:12px;cursor:pointer">${T('仅记审阅决定', 'Mark reviewed')}</button>`}
263
+ </div>
264
+ <div id="ai-${escHtml(p.id)}"></div>
265
+ ${draftedIds.has(p.id) ? '' : draftForm(p)}
266
+ </div>`}
267
+ </div>`
268
+ }
269
+ const draftRow = (d) => `<div class="card" style="padding:12px;margin-bottom:8px;border-left:3px solid #6366f1">
270
+ <div style="display:flex;justify-content:space-between;gap:8px;align-items:flex-start">
271
+ <div style="font-weight:600;font-size:13px">${escHtml(d.title)}</div>
272
+ <span style="font-size:10px;background:#eef2ff;color:#4338ca;padding:2px 8px;border-radius:99px;font-weight:600">${T('未发布草稿', 'Unpublished draft')}</span>
273
+ </div>
274
+ ${field(T('风险', 'Risk'), d.risk_level)}${field(T('可自助认领', 'Auto-claimable'), d.auto_claimable === 1 || d.auto_claimable === true ? T('是', 'yes') : T('否(需真人)', 'no (human)'))}
275
+ ${field(T('来源建议', 'Source proposal'), d.source_proposal_id)}${field(T('创建人', 'Created by'), d.created_by)}
276
+ <div id="draft-preview-${escHtml(d.id)}" style="display:none;margin-top:8px;border-top:1px dashed #c7d2fe;padding-top:8px;font-size:12px;color:#52525B;line-height:1.5"></div>
277
+ <div style="font-size:10px;color:#9ca3af;margin-top:6px">${T('发布前会校验交接字段;发布后进入正常任务板,可被参与者 agent 发现 / 认领 / 提交 PR。', 'Publish validates the handoff fields; once published it enters the normal task board — discoverable / claimable / PR-submittable by participant agents.')}</div>
278
+ <div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap;align-items:center">
279
+ <button onclick="previewDraft('${escHtml(d.id)}')" style="padding:6px 12px;border:1px solid #6366f1;background:#fff;color:#4338ca;border-radius:6px;font-size:12px;cursor:pointer;font-weight:600">👁 ${T('预览将发布内容', 'Preview what will be published')}</button>
280
+ <button id="pub-btn-${escHtml(d.id)}" disabled title="${T('请先预览将发布的存储内容', 'Preview the stored content first')}" onclick="publishDraft('${escHtml(d.id)}')" style="padding:6px 14px;border:none;background:#d1d5db;color:#6b7280;border-radius:6px;font-size:12px;cursor:not-allowed;font-weight:600">${T('发布到任务板', 'Publish to board')}</button>
281
+ </div>
282
+ </div>`
283
+ app.innerHTML = shell(`
284
+ <div style="padding:14px;max-width:920px;margin:0 auto">
285
+ <h1 class="page-title">🛠️ ${T('任务建议收件箱', 'Task Proposal Inbox')}</h1>
286
+ <div style="background:#f4f4f5;border:1px solid #e4e4e7;border-radius:8px;padding:10px;font-size:11px;color:#52525B;line-height:1.6;margin-bottom:12px">
287
+ ${T('建议是陌生人 / agent 提交的想法,不是贡献事实 / 奖励 / 正式参与。「转为正式任务」只记录评审决定与证据链(proposer → reviewer → 关联引用),不会自动创建 build_task。', 'A proposal is a stranger / agent suggestion — NOT a contribution fact / reward / participation. “Convert” only records the review decision + the proposer → reviewer → ref evidence chain; it does NOT auto-create a build_task.')}
288
+ ${notice ? `<br>${escHtml(notice)}` : ''}
289
+ </div>
290
+ ${drafts.length ? `<div style="margin-bottom:14px">
291
+ <div style="font-size:12px;font-weight:700;color:#4338ca;margin-bottom:6px">📝 ${T('未发布任务草稿', 'Unpublished task drafts')} (${drafts.length})</div>
292
+ ${drafts.map(draftRow).join('')}
293
+ </div>` : ''}
294
+ <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
295
+ ${chip('', T('全部', 'All'))}${chip('new', STATUS.new.label)}${chip('needs_info', STATUS.needs_info.label)}${chip('rejected', STATUS.rejected.label)}${chip('converted', STATUS.converted.label)}
296
+ </div>
297
+ ${proposals.length === 0 ? `<div style="color:#a1a1aa;text-align:center;padding:30px;font-size:14px">${T('收件箱为空', 'Inbox is empty')}</div>` : proposals.map(row).join('')}
298
+ </div>
299
+ `, 'admin')
300
+ }
301
+ window.setProposalStatusFilter = (s) => { state._proposalStatus = s; renderAdminTaskProposals(document.getElementById('app')) }
302
+ window.reviewProposal = async (id, status) => {
303
+ const note = (document.getElementById('pr-note-' + id)?.value || '').trim()
304
+ const ref = (document.getElementById('pr-ref-' + id)?.value || '').trim()
305
+ const body = { status }
306
+ if (note) body.note = note
307
+ if (status === 'converted' && ref) body.converted_ref = ref
308
+ const r = await POST('/admin/task-proposals/' + encodeURIComponent(id) + '/review', body)
309
+ if (r.error) { toast$(r.error || r.error_code || (window._lang === 'en' ? 'failed' : '操作失败')); return }
310
+ toast$(window._lang === 'en' ? 'Updated' : '已更新')
311
+ renderAdminTaskProposals(document.getElementById('app'))
312
+ }
313
+ window.toggleDraftForm = (id) => { const el = document.getElementById('df-' + id); if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none' }
314
+ // AI-assist is ASSISTANT-ONLY: it renders a suggestion + prefills the draft form; it never publishes/decides.
315
+ window.aiAssistProposal = async (id) => {
316
+ const en = window._lang === 'en'
317
+ const box = document.getElementById('ai-' + id); if (box) box.innerHTML = `<div style="font-size:11px;color:#8b5cf6;margin-top:8px">🤖 ${en ? 'thinking…' : '分析中…'}</div>`
318
+ const r = await POST('/admin/task-proposals/' + encodeURIComponent(id) + '/ai-assist', {})
319
+ if (r.error) { if (box) box.innerHTML = ''; toast$(r.error || 'failed'); return }
320
+ const s = r.ai_suggestion || {}
321
+ window._aiSuggest = window._aiSuggest || {}; window._aiSuggest[id] = s.suggested || {}
322
+ const list = (arr) => (arr || []).map(x => '• ' + escHtml(String(x))).join('<br>')
323
+ if (box) box.innerHTML = `<div style="margin-top:8px;border:1px solid #ddd6fe;background:#faf5ff;border-radius:8px;padding:10px">
324
+ <div style="font-size:11px;font-weight:700;color:#6d28d9">🤖 ${en ? 'AI suggestion' : 'AI 建议'} <span style="font-weight:400;color:#9ca3af">(${escHtml(String(r.model || ''))})</span></div>
325
+ <div style="font-size:10px;color:#b45309;margin:2px 0 6px">${escHtml(String(r.ai_notice || ''))}</div>
326
+ <div style="font-size:12px;color:#374151;line-height:1.6">
327
+ <b>${en ? 'Category' : '分类'}:</b> ${escHtml(String(s.category || ''))} · <b>${en ? 'Risk' : '风险'}:</b> ${escHtml(String(s.risk || ''))} · <b>${en ? 'Effort' : '工作量'}:</b> ${escHtml(String(s.effort || ''))} · <b>${en ? 'Duplicate' : '疑似重复'}:</b> ${escHtml(String(s.duplicate_likelihood || ''))}
328
+ ${(s.missing_info && s.missing_info.length) ? `<div style="margin-top:4px"><b>${en ? 'Missing info' : '缺失信息'}:</b><br>${list(s.missing_info)}</div>` : ''}
329
+ </div>
330
+ <button onclick="applyAiToDraft('${id}')" style="margin-top:8px;padding:5px 12px;border:1px solid #8b5cf6;background:#fff;color:#6d28d9;border-radius:6px;font-size:11px;cursor:pointer">${en ? 'Fill draft form with this' : '用此填充草稿表单'}</button>
331
+ </div>`
332
+ }
333
+ window.applyAiToDraft = (id) => {
334
+ const suggested = (window._aiSuggest && window._aiSuggest[id]) || {}
335
+ document.getElementById('df-' + id).style.display = 'block'
336
+ const set = (sfx, v) => { const el = document.getElementById('df-' + sfx + '-' + id); if (el && v != null && v !== '') el.value = v }
337
+ set('title', suggested.title); set('area', suggested.area); set('desc', suggested.description)
338
+ set('accept', (suggested.acceptance_criteria || []).join('\n')); set('verify', (suggested.verification_commands || []).join('\n'))
339
+ toast$(window._lang === 'en' ? 'Draft prefilled (review before saving)' : '已填充草稿(保存前请人工核对)')
340
+ }
341
+ window.createTaskDraft = async (id) => {
342
+ const en = window._lang === 'en'
343
+ const v = (sfx) => (document.getElementById('df-' + sfx + '-' + id)?.value || '').trim()
344
+ const lines = (sfx) => v(sfx).split('\n').map(x => x.trim()).filter(Boolean)
345
+ const body = {
346
+ title: v('title'), area: v('area') || null, source_ref: v('source') || null, description: v('desc'),
347
+ allowed_paths: lines('allowed'), forbidden_paths: lines('fpaths'), forbidden_actions: lines('forbidden'),
348
+ acceptance_criteria: lines('accept'), verification_commands: lines('verify'), deliverables: lines('deliver'),
349
+ definition_of_done: v('dod'), expected_results: v('expect'),
350
+ }
351
+ const r = await POST('/admin/task-proposals/' + encodeURIComponent(id) + '/create-task-draft', body)
352
+ if (r && r.error_code === 'RATE_LIMITED') { showRateLimitAffordance(r); return }
353
+ if (r.error) { toast$((r.missing && r.missing.length) ? ((en ? 'Missing: ' : '缺少:') + r.missing.join(', ')) : (r.error || 'failed')); return }
354
+ toast$(en ? 'Draft saved (unpublished)' : '草稿已保存(未发布)')
355
+ renderAdminTaskProposals(document.getElementById('app'))
356
+ }
357
+ // Pre-publish preview: load the FULL stored draft body so publish is a decision against visible content.
358
+ // Opening the preview also un-gates the (initially disabled) Publish button for this draft.
359
+ window.previewDraft = async (taskId) => {
360
+ // bilingual helper — T is local to renderAdminTaskProposals, NOT a global, so this top-level fn defines its own
361
+ const T = (zh, en) => (window._lang === 'en' ? en : zh)
362
+ const box = document.getElementById('draft-preview-' + taskId)
363
+ if (!box) return
364
+ box.style.display = ''
365
+ box.innerHTML = t('加载中...')
366
+ const r = await GET('/admin/build-task-drafts/' + encodeURIComponent(taskId)).catch(() => null)
367
+ const d = r && r.draft
368
+ if (!d) { box.innerHTML = `<span style="color:#dc2626">${T('预览加载失败', 'Preview failed to load')}</span>`; return }
369
+ const m = d.agent_metadata || {}
370
+ const li = (arr) => (Array.isArray(arr) && arr.length) ? `<ul style="margin:2px 0 6px 16px;padding:0">${arr.map((x) => `<li>${escHtml(String(x))}</li>`).join('')}</ul>` : `<div style="color:#9ca3af;margin-bottom:6px">—</div>`
371
+ const txtBlock = (s) => `<div style="white-space:pre-wrap;margin-bottom:6px">${escHtml(String(s || '')) || '<span style="color:#9ca3af">—</span>'}</div>`
372
+ const sec = (label, html) => `<div style="margin-top:6px"><div style="font-weight:600;color:#374151">${escHtml(label)}</div>${html}</div>`
373
+ box.innerHTML = `<div style="font-size:11px;color:#6366f1;font-weight:600;margin-bottom:4px">${T('将要发布的存储内容(发布对此生效)', 'Stored content that will be published (publish acts on this)')}</div>`
374
+ + sec(T('说明', 'Description'), txtBlock(d.description))
375
+ + sec(T('验收标准', 'Acceptance criteria'), li(m.acceptance_criteria))
376
+ + sec(T('验证命令', 'Verification commands'), li(m.verification_commands))
377
+ + sec(T('允许路径', 'Allowed paths'), li(m.allowed_paths))
378
+ + sec(T('禁止路径', 'Forbidden paths'), li(m.forbidden_paths))
379
+ + sec(T('禁止动作', 'Forbidden actions'), li(m.prohibited_actions))
380
+ + sec(T('交付物', 'Deliverables'), li(m.deliverables))
381
+ + sec(T('完成定义', 'Definition of done'), txtBlock(m.definition_of_done))
382
+ + sec(T('预期结果', 'Expected results'), txtBlock(m.expected_results))
383
+ const btn = document.getElementById('pub-btn-' + taskId)
384
+ if (btn) { btn.disabled = false; btn.title = ''; btn.style.cssText = 'padding:6px 14px;border:none;background:#16a34a;color:#fff;border-radius:6px;font-size:12px;cursor:pointer;font-weight:600' }
385
+ }
386
+
387
+ window.publishDraft = async (taskId) => {
388
+ const en = window._lang === 'en'
389
+ const r = await POST('/admin/build-task-drafts/' + encodeURIComponent(taskId) + '/publish', {})
390
+ if (r.error) { toast$((r.missing && r.missing.length) ? ((en ? 'Fill before publish: ' : '发布前请填齐:') + r.missing.join(', ')) : (r.error || 'failed')); return }
391
+ toast$(en ? 'Published to task board' : '已发布到任务板')
392
+ renderAdminTaskProposals(document.getElementById('app'))
393
+ }
394
+
395
+ // ── PR #18 build-task quota-increase requests ─────────────────────────────────
396
+ var _qT = (zh, en) => (window._lang === 'en' ? en : zh)
397
+ const _qStatusBadge = (s) => {
398
+ const map = {
399
+ pending: ['#fef9c3', '#854d0e', _qT('待审核', 'Pending')],
400
+ approved: ['#dcfce7', '#166534', _qT('已批准', 'Approved')],
401
+ rejected: ['#fee2e2', '#991b1b', _qT('已拒绝', 'Rejected')],
402
+ expired: ['#f3f4f6', '#6b7280', _qT('已过期', 'Expired')],
403
+ exhausted: ['#e0e7ff', '#3730a3', _qT('已用完', 'Exhausted')],
404
+ revoked: ['#fae8ff', '#86198f', _qT('已撤销', 'Revoked')],
405
+ }
406
+ const [bg, fg, label] = map[s] || ['#f3f4f6', '#6b7280', s]
407
+ return `<span style="font-size:11px;background:${bg};color:${fg};padding:2px 8px;border-radius:99px;font-weight:600">${escHtml(label)}</span>`
408
+ }
409
+
410
+ // RATE_LIMITED affordance — shown when build-task creation is capped (structured 429 response).
411
+ window.showRateLimitAffordance = (r) => {
412
+ const limit = (r && r.limit) != null ? r.limit : '?'
413
+ const used = (r && r.used) != null ? r.used : '?'
414
+ document.getElementById('quota-rl-overlay')?.remove()
415
+ const ov = document.createElement('div')
416
+ ov.id = 'quota-rl-overlay'
417
+ ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px'
418
+ ov.innerHTML = `
419
+ <div style="background:#fff;border-radius:12px;max-width:420px;width:100%;padding:20px;box-shadow:0 10px 40px rgba(0,0,0,.2)">
420
+ <div style="font-size:16px;font-weight:700;color:#991b1b;margin-bottom:8px">⚠️ ${_qT('已达每日建任务上限', 'Daily task-creation limit reached')}</div>
421
+ <div style="font-size:13px;color:#374151;line-height:1.6;margin-bottom:14px">
422
+ ${_qT('当前上限', 'Current limit')}: <b>${escHtml(String(limit))}</b> ${_qT('个 / 24 小时', 'tasks / 24h')} · ${_qT('已用', 'Used')}: <b>${escHtml(String(used))}</b><br>
423
+ ${_qT('需要更多额度需经根管理员批准。', 'More headroom requires root-admin approval.')}
424
+ </div>
425
+ <div style="display:flex;gap:8px;justify-content:flex-end">
426
+ <button onclick="document.getElementById('quota-rl-overlay').remove()" style="padding:8px 14px;border:1px solid #d1d5db;background:#fff;color:#374151;border-radius:8px;font-size:13px;cursor:pointer">${_qT('关闭', 'Close')}</button>
427
+ <button onclick="document.getElementById('quota-rl-overlay').remove();navigate('#me/quota-requests')" style="padding:8px 14px;border:none;background:#4338ca;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer">${_qT('申请增加额度', 'Request extra quota')}</button>
428
+ </div>
429
+ </div>`
430
+ document.body.appendChild(ov)
431
+ }
432
+
433
+ // Requester view — own quota requests + a new-request form.
434
+ async function renderMyQuotaRequests(app) {
435
+ if (!state.user) { renderLogin(); return }
436
+ app.innerHTML = shell(loading$(), 'me')
437
+ const r = await GET('/me/quota-requests').catch(() => null)
438
+ if (!r || r.error) { app.innerHTML = shell(alert$('error', (r && r.error) || _qT('加载失败', 'Failed to load')), 'me'); return }
439
+ const reqs = r.requests || []
440
+ const hasPending = reqs.some(x => x.status === 'pending')
441
+ const field = (label, html) => `<div style="margin-bottom:10px"><label style="display:block;font-size:12px;color:#6b7280;margin-bottom:4px">${escHtml(label)}</label>${html}</div>`
442
+ const inputStyle = 'width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;box-sizing:border-box'
443
+
444
+ const form = hasPending
445
+ ? `<div class="card" style="padding:14px;margin-bottom:14px;background:#fffbeb;border:1px solid #fde68a">
446
+ <div style="font-size:13px;color:#854d0e">${_qT('你已有一个待审核的申请 — 每种额度类型同时只能有一个待审核申请。', 'You already have a pending request — only one pending request per quota type is allowed.')}</div>
447
+ </div>`
448
+ : `<div class="card" style="padding:16px;margin-bottom:16px">
449
+ <div style="font-size:14px;font-weight:700;margin-bottom:12px">📝 ${_qT('申请增加建任务额度', 'Request extra build-task quota')}</div>
450
+ ${field(_qT('额外任务数(必填,正整数)', 'Extra tasks (required, positive integer)'), `<input id="q-count" type="number" min="1" placeholder="10" style="${inputStyle}">`)}
451
+ ${field(_qT('理由(必填)', 'Reason (required)'), `<textarea id="q-reason" rows="3" placeholder="${_qT('为什么需要更多额度', 'Why you need more quota')}" style="${inputStyle}"></textarea>`)}
452
+ ${field(_qT('关联任务/提案/PR(每行一个,可选)', 'Linked task/proposal/PR refs (one per line, optional)'), `<textarea id="q-refs" rows="2" placeholder="#17\\ntp_..." style="${inputStyle}"></textarea>`)}
453
+ ${field(_qT('紧急程度', 'Urgency'), `<select id="q-urgency" style="${inputStyle}"><option value="normal">${_qT('普通', 'Normal')}</option><option value="low">${_qT('低', 'Low')}</option><option value="high">${_qT('高', 'High')}</option></select>`)}
454
+ ${field(_qT('期望有效期(小时,可选)', 'Requested duration (hours, optional)'), `<input id="q-duration" type="number" min="1" placeholder="72" style="${inputStyle}">`)}
455
+ <button onclick="submitQuotaRequest()" style="margin-top:6px;padding:9px 16px;border:none;background:#4338ca;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer">${_qT('提交申请', 'Submit request')}</button>
456
+ </div>`
457
+
458
+ const card = (x) => {
459
+ const granted = x.granted_count != null ? x.granted_count : null
460
+ const remaining = x.remaining != null ? x.remaining : null
461
+ return `<div class="card" style="padding:14px;margin-bottom:10px">
462
+ <div style="display:flex;justify-content:space-between;gap:8px;align-items:center">
463
+ <div style="font-size:13px;font-weight:600">${escHtml(_qT('额外', 'Extra'))} ${escHtml(String(x.requested_extra_count))} · ${escHtml(x.urgency || 'normal')}</div>
464
+ ${_qStatusBadge(x.status)}
465
+ </div>
466
+ <div style="font-size:12px;color:#374151;margin-top:6px;white-space:pre-wrap">${escHtml(x.reason || '')}</div>
467
+ ${(x.linked_refs && x.linked_refs.length) ? `<div style="font-size:11px;color:#6b7280;margin-top:4px">${_qT('关联', 'Refs')}: ${x.linked_refs.map(escHtml).join(', ')}</div>` : ''}
468
+ ${x.status === 'approved' ? `<div style="font-size:12px;color:#166534;margin-top:6px">${_qT('授权', 'Granted')}: ${escHtml(String(granted))} · ${_qT('剩余', 'Remaining')}: <b>${escHtml(String(remaining))}</b>${x.expires_at ? ` · ${_qT('到期', 'Expires')}: ${escHtml(x.expires_at)}` : ''}</div>` : ''}
469
+ ${x.status === 'exhausted' ? `<div style="font-size:12px;color:#3730a3;margin-top:6px">${_qT('授权已用完', 'Grant fully used')} (${escHtml(String(granted))})</div>` : ''}
470
+ ${x.status === 'rejected' && x.decision_note ? `<div style="font-size:12px;color:#991b1b;margin-top:6px">${_qT('拒绝原因', 'Rejection reason')}: ${escHtml(x.decision_note)}</div>` : ''}
471
+ <div style="font-size:10px;color:#9ca3af;margin-top:6px">${escHtml(x.created_at || '')}</div>
472
+ </div>`
473
+ }
474
+
475
+ const body = `
476
+ <div style="max-width:560px;margin:0 auto">
477
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
478
+ <div style="font-size:18px;font-weight:700">🎟️ ${_qT('我的额度申请', 'My quota requests')}</div>
479
+ <a href="#me" style="font-size:12px;color:#4338ca;text-decoration:none">← ${_qT('返回', 'Back')}</a>
480
+ </div>
481
+ <div class="card" style="padding:12px;margin-bottom:14px;background:linear-gradient(135deg,#eef2ff,#fff)">
482
+ <div style="font-size:12px;color:#6b7280">${_qT('当前可用临时额度', 'Current temporary quota available')}</div>
483
+ <div style="font-size:22px;font-weight:700;color:#4338ca">${escHtml(String(r.remaining_quota || 0))}</div>
484
+ </div>
485
+ ${form}
486
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">${_qT('历史申请', 'Request history')}</div>
487
+ ${reqs.length ? reqs.map(card).join('') : `<div style="font-size:13px;color:#9ca3af">${_qT('暂无申请', 'No requests yet')}</div>`}
488
+ </div>`
489
+ app.innerHTML = shell(body, 'me')
490
+ }
491
+
492
+ window.submitQuotaRequest = async () => {
493
+ const v = (id) => (document.getElementById(id)?.value || '').trim()
494
+ const count = Number(v('q-count'))
495
+ const reason = v('q-reason')
496
+ if (!count || count <= 0) { toast$(_qT('请填写正整数额外任务数', 'Enter a positive extra-task count')); return }
497
+ if (reason.length < 5) { toast$(_qT('请填写理由(至少 5 字)', 'Reason required (>= 5 chars)')); return }
498
+ const refs = v('q-refs').split('\n').map(s => s.trim()).filter(Boolean)
499
+ const duration = v('q-duration')
500
+ const body = { requested_extra_count: count, reason, linked_refs: refs, urgency: v('q-urgency') || 'normal' }
501
+ if (duration) body.requested_duration_hours = Number(duration)
502
+ const r = await POST('/me/quota-requests', body)
503
+ if (r && r.error) { toast$(r.error_code === 'ALREADY_PENDING' ? _qT('你已有一个待审核申请', 'You already have a pending request') : (r.error || _qT('提交失败', 'Submit failed'))); return }
504
+ toast$(_qT('申请已提交,等待根管理员审核', 'Submitted — awaiting root-admin review'))
505
+ renderMyQuotaRequests(document.getElementById('app'))
506
+ }
507
+
508
+ // ── Admin operator-claim workflow (Phase 2): link an admin SEAT → a personal contributor account ──
509
+ function _ocStatusBadge(s) {
510
+ const map = {
511
+ proposed: ['#fef9c3', '#854d0e', _qT('待贡献人确认', 'Awaiting contributor')],
512
+ confirmed: ['#dbeafe', '#1e40af', _qT('待 root 审批', 'Awaiting root approval')],
513
+ rejected_by_contributor: ['#fee2e2', '#991b1b', _qT('贡献人已拒绝', 'Rejected by contributor')],
514
+ approved: ['#dcfce7', '#166534', _qT('已生效', 'Active')],
515
+ rejected_by_root: ['#fee2e2', '#991b1b', _qT('root 已拒绝', 'Rejected by root')],
516
+ revoked: ['#fae8ff', '#86198f', _qT('已撤销', 'Revoked')],
517
+ superseded: ['#f3f4f6', '#6b7280', _qT('已被取代', 'Superseded')],
518
+ }
519
+ const [bg, fg, label] = map[s] || ['#f3f4f6', '#6b7280', s]
520
+ return `<span style="background:${bg};color:${fg};padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600">${escHtml(label)}</span>`
521
+ }
522
+
523
+ // Page for any user: (a) if admin — their seat + a "link personal contributor account" form;
524
+ // (b) for everyone — claims pointing at ME awaiting my accept/reject.
525
+ async function renderMyOperatorClaims(app) {
526
+ if (!state.user) { renderLogin(); return }
527
+ app.innerHTML = shell(loading$(), 'me')
528
+ const isAdmin = state.user.role === 'admin' || (Array.isArray(state.user.roles) && state.user.roles.includes('admin'))
529
+ const pend = await GET('/me/operator-claim-confirmations').catch(() => null)
530
+ const rel = await GET('/me/operator-claims').catch(() => null) // ALL relationships pointing at me (active/history)
531
+ const mine = isAdmin ? await GET('/admin/operator-claims/me').catch(() => null) : null
532
+ const inputStyle = 'width:100%;padding:8px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;box-sizing:border-box'
533
+ const field = (label, html) => `<div style="margin-bottom:10px"><label style="display:block;font-size:12px;color:#6b7280;margin-bottom:4px">${escHtml(label)}</label>${html}</div>`
534
+
535
+ // active approved claim → either an "申请解除" button or a pending-review note. Either PARTY may
536
+ // request unlink (admin-seat owner OR contributor), so both claimRow and relCard reuse this.
537
+ const unlinkAreaFor = (c) => {
538
+ const active = c.status === 'approved' && c.approved
539
+ if (!active) return ''
540
+ return c.unlink_pending
541
+ ? `<div style="font-size:11px;color:#b45309;margin-top:8px">⏳ ${_qT('解除申请审批中(待 root)', 'Unlink request pending root review')}</div>`
542
+ : `<button onclick="requestUnlinkOperatorClaim('${escHtml(c.approved.event_id)}')" style="margin-top:8px;padding:6px 12px;border:1px solid #d1d5db;background:#fff;color:#b91c1c;border-radius:8px;font-size:12px;cursor:pointer">${_qT('申请解除', 'Request unlink')}</button>`
543
+ }
544
+
545
+ const claimRow = (c) => `<div class="card" style="padding:12px;margin-bottom:8px">
546
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
547
+ <div style="font-size:12px;font-weight:600">→ ${escHtml(c.contributor_account_id)}</div>${_ocStatusBadge(c.status)}
548
+ </div>
549
+ <div style="font-size:10px;color:#9ca3af;margin-top:4px">${escHtml(c.proposed_at || '')} · ${escHtml(c.claimed_event_id)}</div>${unlinkAreaFor(c)}
550
+ </div>`
551
+
552
+ const adminBlock = isAdmin ? `
553
+ <div class="card" style="padding:16px;margin-bottom:16px">
554
+ <div style="font-size:14px;font-weight:700;margin-bottom:4px">🔗 ${_qT('关联个人贡献账号', 'Link a personal contributor account')}</div>
555
+ <div style="font-size:12px;color:#6b7280;margin-bottom:12px">${_qT('把这个管理席位的协调贡献,归属到你的真实个人账号。需对方确认 + 根管理员审批。', 'Attribute this admin seat\'s coordination work to your real personal account. Requires the contributor to accept + root approval.')}</div>
556
+ ${field(_qT('贡献人账号 ID(必填)', 'Contributor account ID (required)'), `<input id="oc-contributor" placeholder="usr_..." style="${inputStyle}">`)}
557
+ ${field(_qT('理由(可选)', 'Rationale (optional)'), `<input id="oc-rationale" placeholder="${_qT('为何关联', 'why')}" style="${inputStyle}">`)}
558
+ <button onclick="submitOperatorClaim()" style="margin-top:4px;padding:9px 16px;border:none;background:#4338ca;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer">${_qT('发起关联', 'Propose link')}</button>
559
+ <div style="font-size:13px;font-weight:600;margin:16px 0 8px">${_qT('本席位的关联记录', 'This seat\'s claims')}</div>
560
+ ${(mine && mine.claims && mine.claims.length) ? mine.claims.map(claimRow).join('') : `<div style="font-size:13px;color:#9ca3af">${_qT('暂无', 'None yet')}</div>`}
561
+ </div>` : '' /* adminBlock */
562
+
563
+ const pendList = (pend && pend.pending) || []
564
+ const confirmCard = (c) => `<div class="card" style="padding:14px;margin-bottom:10px;background:#fffbeb;border:1px solid #fde68a">
565
+ <div style="font-size:13px">${_qT('管理席位', 'Admin seat')} <b>${escHtml(c.admin_account_id)}</b> ${_qT('请求关联到你的账号作为贡献归属。', 'requests to attribute its coordination work to your account.')}</div>
566
+ <div style="font-size:10px;color:#9ca3af;margin:6px 0">${escHtml(c.claimed_event_id)}</div>
567
+ <div style="display:flex;gap:8px">
568
+ <button onclick="confirmOperatorClaim('${escHtml(c.claimed_event_id)}','accepted')" style="padding:8px 14px;border:none;background:#166534;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer">${_qT('接受', 'Accept')}</button>
569
+ <button onclick="confirmOperatorClaim('${escHtml(c.claimed_event_id)}','rejected')" style="padding:8px 14px;border:1px solid #d1d5db;background:#fff;color:#991b1b;border-radius:8px;font-size:13px;cursor:pointer">${_qT('拒绝', 'Reject')}</button>
570
+ </div>
571
+ </div>`
572
+
573
+ // 我的贡献归属关系(已生效/历史)+ approved 关系可「申请解除」(不是直接撤销;需 Passkey + root 审批)
574
+ const relList = (rel && rel.relationships) || []
575
+ const relCard = (c) => {
576
+ return `<div class="card" style="padding:12px;margin-bottom:8px">
577
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
578
+ <div style="font-size:12px;font-weight:600">${escHtml(c.admin_account_id)} → ${escHtml(c.contributor_account_id)}</div>${_ocStatusBadge(c.status)}
579
+ </div>
580
+ <div style="font-size:10px;color:#9ca3af;margin-top:4px">${escHtml(c.proposed_at || '')}</div>${unlinkAreaFor(c)}
581
+ </div>`
582
+ }
583
+
584
+ const body = `<div style="max-width:560px;margin:0 auto">
585
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
586
+ <div style="font-size:18px;font-weight:700">🪪 ${_qT('贡献归属', 'Contribution attribution')}</div>
587
+ <a href="#me" style="font-size:12px;color:#4338ca;text-decoration:none">← ${_qT('返回', 'Back')}</a>
588
+ </div>
589
+ ${adminBlock}
590
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">${_qT('待我确认的关联', 'Awaiting my confirmation')}</div>
591
+ ${pendList.length ? pendList.map(confirmCard).join('') : `<div style="font-size:13px;color:#9ca3af;margin-bottom:8px">${_qT('没有待确认的关联', 'No pending links')}</div>`}
592
+ <div style="font-size:13px;font-weight:600;margin:16px 0 8px">${_qT('我的贡献归属关系 / 历史', 'My contribution-attribution relationships / history')}</div>
593
+ ${relList.length ? relList.map(relCard).join('') : `<div style="font-size:13px;color:#9ca3af">${_qT('暂无关系', 'No relationships yet')}</div>`}
594
+ </div>`
595
+ app.innerHTML = shell(body, 'me')
596
+ }
597
+
598
+ window.requestUnlinkOperatorClaim = async (approvedEventId) => {
599
+ if (!confirm(_qT('确认申请解除该贡献归属关系?需 Passkey 验证,且最终由 root 审批。', 'Request to unlink this attribution relationship? Requires Passkey, then root approval.'))) return
600
+ let token
601
+ try { token = await requestPasskeyGate('operator_claim_unlink', { approved_event_id: approvedEventId }) }
602
+ catch (e) { toast$(e.message || _qT('Passkey 验证失败', 'Passkey verification failed')); return }
603
+ const reason = (prompt(_qT('解除理由(可选)', 'Reason (optional)')) || '').trim() || undefined
604
+ const r = await POST('/me/operator-claims/' + encodeURIComponent(approvedEventId) + '/request-unlink', { webauthn_token: token, reason })
605
+ if (r && r.error) { toast$(r.message || r.error || _qT('提交失败', 'Failed')); return }
606
+ toast$(_qT('解除申请已提交,等待 root 审批', 'Unlink request submitted — awaiting root review'))
607
+ renderMyOperatorClaims(document.getElementById('app'))
608
+ }
609
+
610
+ window.submitOperatorClaim = async () => {
611
+ const contributor = (document.getElementById('oc-contributor')?.value || '').trim()
612
+ const rationale = (document.getElementById('oc-rationale')?.value || '').trim()
613
+ if (!contributor) { toast$(_qT('请填写贡献人账号 ID', 'Enter contributor account ID')); return }
614
+ const r = await POST('/admin/operator-claims', { contributor_account_id: contributor, rationale })
615
+ if (r && r.error) { toast$(r.message || r.error || _qT('发起失败', 'Failed')); return }
616
+ toast$(_qT('已发起,等待对方确认 + root 审批', 'Proposed — awaiting contributor + root'))
617
+ renderMyOperatorClaims(document.getElementById('app'))
618
+ }
619
+ window.confirmOperatorClaim = async (claimedEventId, decision) => {
620
+ const r = await POST('/me/operator-claim-confirmations/' + encodeURIComponent(claimedEventId), { decision })
621
+ if (r && r.error) { toast$(r.message || r.error || _qT('操作失败', 'Failed')); return }
622
+ toast$(decision === 'accepted' ? _qT('已接受', 'Accepted') : _qT('已拒绝', 'Rejected'))
623
+ renderMyOperatorClaims(document.getElementById('app'))
624
+ }
625
+
626
+ // ROOT review queue for operator claims.
627
+ async function renderAdminOperatorClaims(app, statusFilter) {
628
+ if (!state.user) { renderLogin(); return }
629
+ const isRoot = (state.user.admin_type || 'root') === 'root' && (state.user.role === 'admin' || (Array.isArray(state.user.roles) && state.user.roles.includes('admin')))
630
+ if (!isRoot) { app.innerHTML = shell(`<div class="alert alert-danger">${_qT('仅限根管理员', 'Root admin only')}</div>`, 'admin'); return }
631
+ app.innerHTML = shell(loading$(), 'admin')
632
+ const sf = statusFilter || 'confirmed'
633
+ const r = await GET('/admin/operator-claims' + (sf === 'all' ? '' : '?status=' + encodeURIComponent(sf))).catch(() => null)
634
+ if (!r || r.error) { app.innerHTML = shell(alert$('error', (r && r.error) || _qT('加载失败', 'Failed to load')), 'admin'); return }
635
+ const claims = r.claims || []
636
+ const unlinkRes = await GET('/admin/operator-claims/unlink/requests').catch(() => null)
637
+ const unlinkReqs = (unlinkRes && unlinkRes.requests) || []
638
+ const inputStyle = 'width:100%;padding:7px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;box-sizing:border-box'
639
+ const filterBtn = (s, label) => `<button onclick="renderAdminOperatorClaims(document.getElementById('app'),'${s}')" style="padding:5px 10px;border:1px solid ${sf === s ? '#4338ca' : '#d1d5db'};background:${sf === s ? '#4338ca' : '#fff'};color:${sf === s ? '#fff' : '#374151'};border-radius:6px;font-size:12px;cursor:pointer">${escHtml(label)}</button>`
640
+ const unlinkCard = (u) => {
641
+ const rid = u.request_event_id
642
+ // When THIS root is a party to the relationship/request (self-or-related), root may still decide it
643
+ // but MUST mark the conflict honestly: approval_kind ∈ {root_approval, founder_bootstrap_override}
644
+ // (never independent_governance) + conflict_disclosure = self_or_related. Mirrors approveClaim.
645
+ const markingForm = u.self_or_related ? `
646
+ <div style="margin-top:8px;padding-top:8px;border-top:1px dashed #fed7aa">
647
+ <div style="font-size:11px;color:#b45309;margin-bottom:6px">⚠️ ${_qT('你是该关系/申请的关联方:必须如实标记(不可 independent_governance)', 'You are a party to this relationship/request: mark honestly (independent_governance not allowed)')}</div>
648
+ <div style="display:flex;gap:6px">
649
+ <select id="uak-${rid}" style="${inputStyle}">
650
+ <option value="root_approval">root_approval</option>
651
+ <option value="founder_bootstrap_override">founder_bootstrap_override</option>
652
+ </select>
653
+ <select id="ucd-${rid}" style="${inputStyle}">
654
+ <option value="self_or_related" selected>self_or_related</option>
655
+ </select>
656
+ </div>
657
+ </div>` : ''
658
+ return `<div class="card" style="padding:12px;margin-bottom:8px;background:#fff7ed;border:1px solid #fed7aa">
659
+ <div style="font-size:12px;font-weight:600">🔓 ${escHtml(u.admin_account_id)} → ${escHtml(u.contributor_account_id)}${u.self_or_related ? ' 🪞' : ''}</div>
660
+ <div style="font-size:11px;color:#6b7280;margin-top:4px">${_qT('申请人', 'Requested by')}: ${escHtml(u.requested_by)} (${escHtml(u.requester_role)})${u.reason ? ' · ' + escHtml(u.reason) : ''}</div>
661
+ ${markingForm}
662
+ <div style="display:flex;gap:8px;margin-top:8px">
663
+ <button onclick="approveUnlinkReq('${escHtml(rid)}')" style="padding:6px 12px;border:none;background:#b91c1c;color:#fff;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer">${_qT('批准解除', 'Approve unlink')}</button>
664
+ <button onclick="rejectUnlinkReq('${escHtml(rid)}')" style="padding:6px 12px;border:1px solid #d1d5db;background:#fff;color:#374151;border-radius:8px;font-size:12px;cursor:pointer">${_qT('驳回', 'Reject')}</button>
665
+ </div>
666
+ </div>`
667
+ }
668
+
669
+ const card = (c) => {
670
+ const selfLink = c.admin_account_id === c.contributor_account_id
671
+ const id = c.claimed_event_id
672
+ const approveForm = (c.status === 'confirmed' || (selfLink && c.status === 'proposed')) ? `
673
+ <div style="margin-top:10px;padding-top:10px;border-top:1px solid #eee">
674
+ ${selfLink ? `<div style="font-size:11px;color:#b45309;margin-bottom:6px">⚠️ ${_qT('自链(席位=贡献人):必须 founder_bootstrap_override + self_or_related', 'Self-link (seat == contributor): must be founder_bootstrap_override + self_or_related')}</div>` : ''}
675
+ <div style="display:flex;gap:6px;margin-bottom:6px">
676
+ <select id="ak-${id}" style="${inputStyle}">
677
+ ${selfLink ? '' : `<option value="independent_governance">independent_governance</option>`}
678
+ <option value="root_approval">root_approval</option>
679
+ <option value="founder_bootstrap_override">founder_bootstrap_override</option>
680
+ </select>
681
+ <select id="cd-${id}" style="${inputStyle}">
682
+ <option value="none">none</option>
683
+ <option value="self_or_related"${selfLink ? ' selected' : ''}>self_or_related</option>
684
+ </select>
685
+ </div>
686
+ <div style="display:flex;gap:8px">
687
+ <button onclick="approveOperatorClaim('${escHtml(id)}')" style="padding:7px 14px;border:none;background:#166534;color:#fff;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer">${_qT('批准', 'Approve')}</button>
688
+ <button onclick="rejectOperatorClaim('${escHtml(id)}')" style="padding:7px 14px;border:1px solid #d1d5db;background:#fff;color:#991b1b;border-radius:8px;font-size:12px;cursor:pointer">${_qT('拒绝', 'Reject')}</button>
689
+ </div>
690
+ </div>` : ''
691
+ const revokeBtn = (c.status === 'approved' && c.approved) ? `<button onclick="revokeOperatorClaim('${escHtml(c.approved.event_id)}')" style="margin-top:8px;padding:6px 12px;border:1px solid #d1d5db;background:#fff;color:#86198f;border-radius:8px;font-size:12px;cursor:pointer">${_qT('撤销', 'Revoke')}</button>` : ''
692
+ return `<div class="card" style="padding:14px;margin-bottom:10px">
693
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
694
+ <div style="font-size:12px;font-weight:600">${escHtml(c.admin_account_id)} → ${escHtml(c.contributor_account_id)}${selfLink ? ' 🪞' : ''}</div>${_ocStatusBadge(c.status)}
695
+ </div>
696
+ <div style="font-size:11px;color:#6b7280;margin-top:4px">${c.confirmation ? `${_qT('贡献人', 'Contributor')}: ${escHtml(c.confirmation.decision)}` : _qT('未确认', 'not confirmed')} · ${escHtml(c.proposed_at || '')}</div>
697
+ ${approveForm}${revokeBtn}
698
+ </div>`
699
+ }
700
+
701
+ const body = `<div style="max-width:620px;margin:0 auto">
702
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
703
+ <div style="font-size:18px;font-weight:700">🪪 ${_qT('操作席位关联审批', 'Operator-claim review')}</div>
704
+ <a href="#admin/protocol" style="font-size:12px;color:#4338ca;text-decoration:none">← ${_qT('返回', 'Back')}</a>
705
+ </div>
706
+ ${unlinkReqs.length ? `<div style="font-size:13px;font-weight:700;color:#b91c1c;margin-bottom:8px">🔓 ${_qT('待审批的解除申请', 'Pending unlink requests')} (${unlinkReqs.length})</div>${unlinkReqs.map(unlinkCard).join('')}<div style="height:14px"></div>` : ''}
707
+ <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px">
708
+ ${filterBtn('confirmed', _qT('待审批', 'Awaiting approval'))}${filterBtn('proposed', _qT('待确认', 'Proposed'))}${filterBtn('approved', _qT('已生效', 'Active'))}${filterBtn('all', _qT('全部', 'All'))}
709
+ </div>
710
+ ${claims.length ? claims.map(card).join('') : `<div style="font-size:13px;color:#9ca3af">${_qT('暂无', 'None')}</div>`}
711
+ </div>`
712
+ app.innerHTML = shell(body, 'admin')
713
+ }
714
+
715
+ window.approveOperatorClaim = async (id) => {
716
+ const ak = document.getElementById('ak-' + id)?.value
717
+ const cd = document.getElementById('cd-' + id)?.value
718
+ const r = await POST('/admin/operator-claims/' + encodeURIComponent(id) + '/approve', { approval_kind: ak, conflict_disclosure: cd })
719
+ if (r && r.error) { toast$(r.message || r.error || _qT('审批失败', 'Approve failed')); return }
720
+ toast$(_qT('已批准', 'Approved')); renderAdminOperatorClaims(document.getElementById('app'))
721
+ }
722
+ window.rejectOperatorClaim = async (id) => {
723
+ const r = await POST('/admin/operator-claims/' + encodeURIComponent(id) + '/reject', {})
724
+ if (r && r.error) { toast$(r.message || r.error || _qT('操作失败', 'Failed')); return }
725
+ toast$(_qT('已拒绝', 'Rejected')); renderAdminOperatorClaims(document.getElementById('app'))
726
+ }
727
+ window.revokeOperatorClaim = async (approvedId) => {
728
+ const r = await POST('/admin/operator-claims/' + encodeURIComponent(approvedId) + '/revoke', {})
729
+ if (r && r.error) { toast$(r.message || r.error || _qT('撤销失败', 'Revoke failed')); return }
730
+ toast$(_qT('已撤销', 'Revoked')); renderAdminOperatorClaims(document.getElementById('app'))
731
+ }
732
+ window.approveUnlinkReq = async (requestId) => {
733
+ if (!confirm(_qT('批准后将解除该贡献归属关系,确认?', 'Approving will unlink (revoke) this attribution. Confirm?'))) return
734
+ // marking selectors only render when root is self-or-related; pass them through when present.
735
+ const ak = document.getElementById('uak-' + requestId)?.value
736
+ const cd = document.getElementById('ucd-' + requestId)?.value
737
+ const body = ak ? { approval_kind: ak, conflict_disclosure: cd } : {}
738
+ const r = await POST('/admin/operator-claims/unlink/' + encodeURIComponent(requestId) + '/approve', body)
739
+ if (r && r.error) { toast$(r.message || r.error || _qT('操作失败', 'Failed')); return }
740
+ toast$(_qT('已批准解除', 'Unlink approved')); renderAdminOperatorClaims(document.getElementById('app'))
741
+ }
742
+ window.rejectUnlinkReq = async (requestId) => {
743
+ const ak = document.getElementById('uak-' + requestId)?.value
744
+ const cd = document.getElementById('ucd-' + requestId)?.value
745
+ const body = ak ? { approval_kind: ak, conflict_disclosure: cd } : {}
746
+ const r = await POST('/admin/operator-claims/unlink/' + encodeURIComponent(requestId) + '/reject', body)
747
+ if (r && r.error) { toast$(r.message || r.error || _qT('操作失败', 'Failed')); return }
748
+ toast$(_qT('已驳回,关系仍有效', 'Rejected — relationship stays active')); renderAdminOperatorClaims(document.getElementById('app'))
749
+ }
750
+
751
+ // ROOT admin review page.
752
+ async function renderAdminBuildTaskQuota(app, statusFilter) {
753
+ if (!state.user) { renderLogin(); return }
754
+ const isRoot = (state.user.admin_type || 'root') === 'root' && (state.user.role === 'admin' || (Array.isArray(state.user.roles) && state.user.roles.includes('admin')))
755
+ if (!isRoot) { app.innerHTML = shell(`<div class="alert alert-danger">${_qT('仅限根管理员', 'Root admin only')}</div>`, 'admin'); return }
756
+ app.innerHTML = shell(loading$(), 'admin')
757
+ const sf = statusFilter || 'pending'
758
+ const r = await GET('/admin/quota-requests' + (sf === 'all' ? '' : '?status=' + encodeURIComponent(sf))).catch(() => null)
759
+ if (!r || r.error) { app.innerHTML = shell(alert$('error', (r && r.error) || _qT('加载失败', 'Failed to load')), 'admin'); return }
760
+ const reqs = r.requests || []
761
+ const inputStyle = 'width:100%;padding:7px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;box-sizing:border-box'
762
+ const filterBtn = (s, label) => `<button onclick="renderAdminBuildTaskQuota(document.getElementById('app'),'${s}')" style="padding:5px 10px;border:1px solid ${sf === s ? '#4338ca' : '#d1d5db'};background:${sf === s ? '#4338ca' : '#fff'};color:${sf === s ? '#fff' : '#374151'};border-radius:6px;font-size:12px;cursor:pointer">${escHtml(label)}</button>`
763
+
764
+ const card = (x) => {
765
+ const pending = x.status === 'pending'
766
+ return `<div class="card" style="padding:14px;margin-bottom:10px">
767
+ <div style="display:flex;justify-content:space-between;gap:8px;align-items:center">
768
+ <div style="font-size:13px;font-weight:600">${escHtml(x.requester_user_id)} · ${_qT('请求', 'Wants')} <b>${escHtml(String(x.requested_extra_count))}</b> · ${escHtml(x.urgency || 'normal')}</div>
769
+ ${_qStatusBadge(x.status)}
770
+ </div>
771
+ <div style="font-size:12px;color:#374151;margin-top:6px;white-space:pre-wrap">${escHtml(x.reason || '')}</div>
772
+ ${(x.linked_refs && x.linked_refs.length) ? `<div style="font-size:11px;color:#6b7280;margin-top:4px">${_qT('关联', 'Refs')}: ${x.linked_refs.map(escHtml).join(', ')}</div>` : ''}
773
+ <div style="font-size:10px;color:#9ca3af;margin-top:4px">${escHtml(x.created_at || '')} · ${escHtml(x.id)}</div>
774
+ <div id="usage-${escHtml(x.id)}" style="font-size:11px;color:#6b7280;margin-top:4px"><button onclick="loadQuotaUsage('${escHtml(x.id)}')" style="padding:3px 8px;border:1px solid #d1d5db;background:#fff;border-radius:6px;font-size:11px;cursor:pointer">${_qT('查看申请人近 24h 用量', 'Load requester 24h usage')}</button></div>
775
+ ${x.status === 'approved' ? `<div style="font-size:12px;color:#166534;margin-top:6px">${_qT('授权', 'Granted')}: ${escHtml(String(x.granted_count))} · ${_qT('剩余', 'Remaining')}: ${escHtml(String(x.remaining))}${x.expires_at ? ` · ${_qT('到期', 'Expires')}: ${escHtml(x.expires_at)}` : ''}
776
+ <button onclick="revokeQuotaReq('${escHtml(x.id)}')" style="margin-left:8px;padding:3px 8px;border:1px solid #c026d3;background:#fff;color:#86198f;border-radius:6px;font-size:11px;cursor:pointer">${_qT('撤销', 'Revoke')}</button></div>` : ''}
777
+ ${(x.decision_note && (x.status === 'rejected' || x.status === 'approved' || x.status === 'revoked')) ? `<div style="font-size:11px;color:#6b7280;margin-top:4px">${_qT('备注', 'Note')}: ${escHtml(x.decision_note)}</div>` : ''}
778
+ ${pending ? `
779
+ <div style="margin-top:10px;border-top:1px solid #f1f1f4;padding-top:10px;display:grid;gap:8px">
780
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
781
+ <input id="ap-count-${escHtml(x.id)}" type="number" min="1" value="${escHtml(String(x.requested_extra_count))}" placeholder="${_qT('授权数', 'Grant count')}" style="${inputStyle}">
782
+ <input id="ap-dur-${escHtml(x.id)}" type="number" min="1" value="${escHtml(String(x.requested_duration_hours || 72))}" placeholder="${_qT('有效期(小时)', 'Duration (h)')}" style="${inputStyle}">
783
+ </div>
784
+ <input id="ap-note-${escHtml(x.id)}" placeholder="${_qT('批准备注(可选)', 'Approval note (optional)')}" style="${inputStyle}">
785
+ <input id="rj-note-${escHtml(x.id)}" placeholder="${_qT('拒绝原因(可选)', 'Rejection note (optional)')}" style="${inputStyle}">
786
+ <div style="display:flex;gap:8px">
787
+ <button onclick="approveQuotaReq('${escHtml(x.id)}')" style="padding:7px 14px;border:none;background:#16a34a;color:#fff;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer">${_qT('批准', 'Approve')}</button>
788
+ <button onclick="rejectQuotaReq('${escHtml(x.id)}')" style="padding:7px 14px;border:1px solid #ef4444;background:#fff;color:#991b1b;border-radius:6px;font-size:12px;cursor:pointer">${_qT('拒绝', 'Reject')}</button>
789
+ </div>
790
+ </div>` : ''}
791
+ </div>`
792
+ }
793
+
794
+ const body = `
795
+ <div style="max-width:640px;margin:0 auto">
796
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
797
+ <div style="font-size:18px;font-weight:700">🎟️ ${_qT('建任务额度审核', 'Build-task quota review')}</div>
798
+ <a href="#admin" style="font-size:12px;color:#4338ca;text-decoration:none">← ${_qT('返回', 'Back')}</a>
799
+ </div>
800
+ <div style="display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap">
801
+ ${filterBtn('pending', _qT('待审核', 'Pending'))}${filterBtn('approved', _qT('已批准', 'Approved'))}${filterBtn('rejected', _qT('已拒绝', 'Rejected'))}${filterBtn('all', _qT('全部', 'All'))}
802
+ </div>
803
+ ${reqs.length ? reqs.map(card).join('') : `<div style="font-size:13px;color:#9ca3af">${_qT('暂无申请', 'No requests')}</div>`}
804
+ </div>`
805
+ app.innerHTML = shell(body, 'admin')
806
+ }
807
+
808
+ window.loadQuotaUsage = async (id) => {
809
+ const box = document.getElementById('usage-' + id)
810
+ if (box) box.innerHTML = t('加载中...')
811
+ const r = await GET('/admin/quota-requests/' + encodeURIComponent(id)).catch(() => null)
812
+ if (box) box.innerHTML = (r && !r.error)
813
+ ? `${_qT('申请人近 24h 已建任务', 'Requester tasks in last 24h')}: <b>${escHtml(String(r.requester_usage_24h))}</b>`
814
+ : ((r && r.error) || _qT('加载失败', 'Failed'))
815
+ }
816
+ window.approveQuotaReq = async (id) => {
817
+ const v = (p) => (document.getElementById(p + '-' + id)?.value || '').trim()
818
+ const body = { extra_count: Number(v('ap-count')) || undefined, duration_hours: Number(v('ap-dur')) || undefined, approval_note: v('ap-note') || undefined }
819
+ const r = await POST('/admin/quota-requests/' + encodeURIComponent(id) + '/approve', body)
820
+ if (r && r.error) { toast$(r.error_code === 'SELF_DECISION' ? _qT('不能审核自己的申请', 'Cannot decide your own request') : (r.error || _qT('批准失败', 'Approve failed'))); return }
821
+ toast$(_qT('已批准', 'Approved'))
822
+ renderAdminBuildTaskQuota(document.getElementById('app'), 'pending')
823
+ }
824
+ window.rejectQuotaReq = async (id) => {
825
+ const note = (document.getElementById('rj-note-' + id)?.value || '').trim()
826
+ const r = await POST('/admin/quota-requests/' + encodeURIComponent(id) + '/reject', { rejection_note: note || undefined })
827
+ if (r && r.error) { toast$(r.error_code === 'SELF_DECISION' ? _qT('不能审核自己的申请', 'Cannot decide your own request') : (r.error || _qT('拒绝失败', 'Reject failed'))); return }
828
+ toast$(_qT('已拒绝', 'Rejected'))
829
+ renderAdminBuildTaskQuota(document.getElementById('app'), 'pending')
830
+ }
831
+ window.revokeQuotaReq = async (id) => {
832
+ const r = await POST('/admin/quota-requests/' + encodeURIComponent(id) + '/revoke', {})
833
+ if (r && r.error) { toast$(r.error || _qT('撤销失败', 'Revoke failed')); return }
834
+ toast$(_qT('已撤销', 'Revoked'))
835
+ renderAdminBuildTaskQuota(document.getElementById('app'), 'approved')
836
+ }