@seasonkoh/webaz 0.1.31 → 0.1.32
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/README.md +3 -1
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +4 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +133 -2
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +96 -0
- package/dist/layer3-trust/L3-1-dispute-engine/decline-contest-resolve.js +110 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +109 -0
- package/dist/pwa/agent-order-minimal-view.js +29 -0
- package/dist/pwa/order-action-exec.js +158 -0
- package/dist/pwa/order-action-request.js +57 -0
- package/dist/pwa/public/app-admin-disputes.js +1 -1
- package/dist/pwa/public/app-agent-approvals-order.js +18 -0
- package/dist/pwa/public/app-agent-approvals.js +6 -7
- package/dist/pwa/public/app-agent-pair.js +2 -2
- package/dist/pwa/public/app-arbitrator-admin.js +1 -1
- package/dist/pwa/public/app-decline-contest-ruling.js +53 -0
- package/dist/pwa/public/app-decline-contest-ui.js +44 -0
- package/dist/pwa/public/app-grant-duration.js +20 -0
- package/dist/pwa/public/app.js +9 -9
- package/dist/pwa/public/i18n.js +24 -1
- package/dist/pwa/public/index.html +4 -0
- package/dist/pwa/public/openapi.json +68 -23
- package/dist/pwa/routes/admin-reports.js +40 -3
- package/dist/pwa/routes/agent-grants.js +117 -12
- package/dist/pwa/routes/disputes-read.js +17 -1
- package/dist/pwa/routes/disputes-write.js +42 -73
- package/dist/pwa/routes/orders-action.js +46 -3
- package/dist/pwa/server.js +3 -3
- package/dist/runtime/agent-grant-scopes.js +26 -8
- package/dist/runtime/webaz-schema-helpers.js +28 -0
- package/package.json +13 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { transition } from '../layer0-foundation/L0-2-state-machine/engine.js';
|
|
2
|
+
/** I4 占位符黑名单:N/A、无、test、全 0、纯重复字符(≥8 长度的多为全 0/纯重复;短的由长度/正则先挡)。从严。 */
|
|
3
|
+
function isPlaceholderTracking(s) {
|
|
4
|
+
return /^n\/?a$/i.test(s) || s === '无' || /^test$/i.test(s) || /^0+$/.test(s) || /^(.)\1+$/.test(s);
|
|
5
|
+
}
|
|
6
|
+
/** I4 tracking 内容校验:非空 + trim≥8 + ^[A-Za-z0-9-]+$ + 非占位符。宁可误拒。 */
|
|
7
|
+
export function validateTrackingContent(tracking) {
|
|
8
|
+
const t = (tracking ?? '').trim();
|
|
9
|
+
if (t.length < 8)
|
|
10
|
+
return { ok: false, error: 'tracking 长度不足(需 ≥8)' };
|
|
11
|
+
if (!/^[A-Za-z0-9-]+$/.test(t))
|
|
12
|
+
return { ok: false, error: 'tracking 含非法字符(仅字母/数字/连字符)' };
|
|
13
|
+
if (isPlaceholderTracking(t))
|
|
14
|
+
return { ok: false, error: 'tracking 疑似占位符,请填真实单号' };
|
|
15
|
+
return { ok: true };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 执行 accept/ship。守卫全内置;任一不满足 → {ok:false}(不 transition)。成功 → 订单状态跃迁 + evidence(ship)。
|
|
19
|
+
* 幂等/executed_at 由调用方(approve 路径)在外层 CAS;本函数只做守卫 + transition。
|
|
20
|
+
*/
|
|
21
|
+
export function executeSellerOrderAction(db, opts) {
|
|
22
|
+
const { orderId, action, actorId, nowIso, path } = opts;
|
|
23
|
+
// P2 fail-safe:漏传/undefined → strict;仅显式 false 放宽(自履行/无单号 api_key 路径)。
|
|
24
|
+
const strictTracking = opts.strictTracking !== false;
|
|
25
|
+
const fail = (error_code, http, error) => ({ ok: false, error_code, http, error });
|
|
26
|
+
// SLA 比较用 datetime() 归一化(SQLite datetime 空格格式 vs JS ISO 'T' 直比会错 → engine.ts:223、direct-pay-timeouts 全用 datetime()<datetime()。
|
|
27
|
+
// *_expired = 1 已过 / 0 未过 / NULL 该 deadline 缺失(fail-closed 用)。
|
|
28
|
+
const order = db.prepare(`SELECT seller_id, status, accept_deadline, ship_deadline,
|
|
29
|
+
CASE WHEN accept_deadline IS NULL THEN NULL WHEN datetime(accept_deadline) < datetime(?) THEN 1 ELSE 0 END AS accept_expired,
|
|
30
|
+
CASE WHEN ship_deadline IS NULL THEN NULL WHEN datetime(ship_deadline) < datetime(?) THEN 1 ELSE 0 END AS ship_expired
|
|
31
|
+
FROM orders WHERE id = ?`).get(nowIso, nowIso, orderId);
|
|
32
|
+
if (!order)
|
|
33
|
+
return fail('ORDER_NOT_FOUND', 404, '订单不存在');
|
|
34
|
+
// 核心守卫①:归属(actor 必须是本单卖家)
|
|
35
|
+
if (order.seller_id !== actorId)
|
|
36
|
+
return fail('NOT_ORDER_SELLER', 403, '该订单不属于你');
|
|
37
|
+
// 核心守卫②③:状态前置 + SLA。P1-c(a) fail-closed:需要 SLA 的状态遇 deadline=NULL → 拒绝放行(绝不 skip),
|
|
38
|
+
// 防任何"忘写 deadline"的下单路径(如缺 ship_deadline 的 direct_p2p)静默绕过判责钟。
|
|
39
|
+
if (action === 'accept') {
|
|
40
|
+
if (order.status !== 'paid')
|
|
41
|
+
return fail('WRONG_STATUS', 409, '仅 paid 订单可接单');
|
|
42
|
+
if (order.accept_deadline == null)
|
|
43
|
+
return fail('SLA_DEADLINE_MISSING', 409, '订单缺 accept_deadline,fail-closed 拒绝执行');
|
|
44
|
+
if (order.accept_expired === 1)
|
|
45
|
+
return fail('SLA_EXPIRED', 409, '接单窗口已过');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (order.status !== 'accepted')
|
|
49
|
+
return fail('WRONG_STATUS', 409, '仅 accepted 订单可发货');
|
|
50
|
+
if (order.ship_deadline == null)
|
|
51
|
+
return fail('SLA_DEADLINE_MISSING', 409, '订单缺 ship_deadline,fail-closed 拒绝执行');
|
|
52
|
+
if (order.ship_expired === 1)
|
|
53
|
+
return fail('SLA_EXPIRED', 409, '发货窗口已过');
|
|
54
|
+
// I4(仅 strictTracking=true,即 agent-approve 路径):tracking 内容重校,不信任 request 内容
|
|
55
|
+
if (strictTracking) {
|
|
56
|
+
const v = validateTrackingContent(opts.tracking);
|
|
57
|
+
if (!v.ok)
|
|
58
|
+
return fail('INVALID_TRACKING', 400, v.error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// 核心守卫④:evidence(ship 须证据;transition 内部再校非空 → 保持 api_key "无 evidence 即拒" 现状)
|
|
62
|
+
const evidenceIds = [];
|
|
63
|
+
if (action === 'ship') {
|
|
64
|
+
const desc = strictTracking
|
|
65
|
+
? `快递单号:${String(opts.tracking)}${opts.evidenceRef ? ` · 凭证:${String(opts.evidenceRef)}` : ''}`
|
|
66
|
+
: (opts.evidenceDescription || null); // 非严格(api_key):仅当卖家提供 evidence_description 才建;缺则 transition 因证据空而拒(保持现状)
|
|
67
|
+
if (desc) {
|
|
68
|
+
const eid = opts.generateId('evt');
|
|
69
|
+
db.prepare("INSERT INTO evidence (id, order_id, uploader_id, type, description, file_hash) VALUES (?,?,?,'description',?,?)").run(eid, orderId, actorId, desc, `h_${nowIso}_${eid}`);
|
|
70
|
+
evidenceIds.push(eid);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ⑤ transition(I3:绝不 UPDATE accept_deadline/ship_deadline;transition 只改 status + 事件链)
|
|
74
|
+
const toStatus = action === 'accept' ? 'accepted' : 'shipped';
|
|
75
|
+
const r = transition(db, orderId, toStatus, actorId, evidenceIds, `${action} via executor (${path})`);
|
|
76
|
+
if (!r.success)
|
|
77
|
+
return fail('TRANSITION_FAILED', 409, r.error);
|
|
78
|
+
return { ok: true, fromStatus: order.status, toStatus };
|
|
79
|
+
}
|
|
80
|
+
class AlreadyExecErr extends Error {
|
|
81
|
+
}
|
|
82
|
+
class NotPendingErr extends Error {
|
|
83
|
+
}
|
|
84
|
+
class ExecFailErr extends Error {
|
|
85
|
+
exec;
|
|
86
|
+
constructor(e) { super(e.error_code); this.exec = e; }
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* approve 路径:CAS pending→approved(若 pending)→ 执行(strictTracking=true)→ executed_at CAS(I5 幂等键)。
|
|
90
|
+
* 执行成功:executed_at + execution_result 写入(与 transition 同一事务,并发只一次)。
|
|
91
|
+
* 执行失败(守卫/transition):【不】写 executed_at,请求保持 approved 可重试;写失败 execution_result(注解)。
|
|
92
|
+
* executed_at 已置位:幂等直接返回 already_executed。
|
|
93
|
+
* strictTracking=true 硬编码在此(agent 路径),不接受任何外部入参。
|
|
94
|
+
* P1-b 审计 fail-closed:approve / execute 的 agent_grant_auth_log 写入与状态改动【同一事务】—— 审计失败即回滚,
|
|
95
|
+
* 动作不发生(与 PR2 提交侧审计一致,绝不 fail-open 吞异常)。
|
|
96
|
+
*/
|
|
97
|
+
export function approveAndExecuteOrderAction(db, requestId, actorId, grantId, nowIso, generateId) {
|
|
98
|
+
const auditRow = (cap, outcome) => db.prepare('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)').run(grantId, actorId, cap, outcome, null);
|
|
99
|
+
const r = db.prepare("SELECT status, kind, order_id, order_action, action_params, executed_at, expires_at FROM agent_permission_requests WHERE id = ?").get(requestId);
|
|
100
|
+
if (!r || r.kind !== 'order_action')
|
|
101
|
+
return { ok: false, error_code: 'NOT_ORDER_ACTION', http: 404 };
|
|
102
|
+
if (r.executed_at)
|
|
103
|
+
return { ok: true, already_executed: true }; // 幂等:已执行
|
|
104
|
+
if (r.status !== 'pending' && r.status !== 'approved')
|
|
105
|
+
return { ok: false, error_code: 'REQUEST_NOT_APPROVABLE', http: 409 };
|
|
106
|
+
// step 1:CAS→approved + 审计,同一事务 fail-closed(仅 pending;单独提交 → 执行失败仍保持 approved 可重试)。expires_at 原子守卫。
|
|
107
|
+
if (r.status === 'pending') {
|
|
108
|
+
try {
|
|
109
|
+
db.transaction(() => {
|
|
110
|
+
const c = db.prepare("UPDATE agent_permission_requests SET status='approved', approved_at=? WHERE id=? AND status='pending' AND expires_at > ?").run(nowIso, requestId, nowIso).changes;
|
|
111
|
+
if (c !== 1)
|
|
112
|
+
throw new NotPendingErr();
|
|
113
|
+
auditRow(`order_action:approve:${r.order_id}:${r.order_action}`, 'allow'); // 审计写失败 → 抛 → 回滚 CAS(未 approve)
|
|
114
|
+
})();
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (e instanceof NotPendingErr)
|
|
118
|
+
return { ok: false, error_code: 'REQUEST_NOT_PENDING_OR_EXPIRED', http: 409 };
|
|
119
|
+
return { ok: false, error_code: 'AUDIT_WRITE_FAILED', error: e.message, http: 500 }; // 审计 fail-closed:保持 pending,未 approve/未执行
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// step 2:执行 + executed_at + 执行审计,同一事务原子 fail-closed(并发/重放/审计失败 → 回滚)
|
|
123
|
+
let params = {};
|
|
124
|
+
try {
|
|
125
|
+
params = r.action_params ? JSON.parse(r.action_params) : {};
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
params = {};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const out = db.transaction(() => {
|
|
132
|
+
const cur = db.prepare('SELECT executed_at FROM agent_permission_requests WHERE id = ?').get(requestId);
|
|
133
|
+
if (cur.executed_at)
|
|
134
|
+
throw new AlreadyExecErr();
|
|
135
|
+
const exec = executeSellerOrderAction(db, { orderId: r.order_id, action: r.order_action, actorId, nowIso, strictTracking: true, tracking: params.tracking, evidenceRef: params.evidence_ref, generateId, path: 'approve' });
|
|
136
|
+
if (!exec.ok)
|
|
137
|
+
throw new ExecFailErr(exec);
|
|
138
|
+
const e = db.prepare("UPDATE agent_permission_requests SET executed_at=?, execution_result=? WHERE id=? AND executed_at IS NULL").run(nowIso, JSON.stringify({ ok: true, from: exec.fromStatus, to: exec.toStatus }), requestId).changes;
|
|
139
|
+
if (e !== 1)
|
|
140
|
+
throw new AlreadyExecErr();
|
|
141
|
+
auditRow(`order_action:execute:${r.order_id}:${r.order_action}`, 'allow'); // 审计写失败 → 抛 → 回滚执行 + executed_at(未执行)
|
|
142
|
+
return exec;
|
|
143
|
+
})();
|
|
144
|
+
return { ok: true, order_status: out.toStatus };
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (err instanceof AlreadyExecErr)
|
|
148
|
+
return { ok: true, already_executed: true };
|
|
149
|
+
if (err instanceof ExecFailErr) {
|
|
150
|
+
try {
|
|
151
|
+
db.prepare("UPDATE agent_permission_requests SET execution_result=? WHERE id=? AND executed_at IS NULL").run(JSON.stringify({ ok: false, error_code: err.exec.error_code }), requestId);
|
|
152
|
+
}
|
|
153
|
+
catch { /* 注解 best-effort */ }
|
|
154
|
+
return { ok: false, error_code: err.exec.error_code, error: err.exec.error, http: err.exec.http }; // 请求保持 approved 可重试
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, error_code: 'EXECUTE_FAILED', error: err.message, http: 500 }; // 含执行审计写失败 → 已回滚 → 未执行,可重试
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const TTL_HOURS = 24; // 请求短 TTL(I5)
|
|
3
|
+
/** I6:仅 ship 保留 {tracking, evidence_ref};accept 无参;其它键(含任何地址)一律丢弃。 */
|
|
4
|
+
export function sanitizeOrderActionParams(action, raw) {
|
|
5
|
+
if (action !== 'ship')
|
|
6
|
+
return {};
|
|
7
|
+
const r = (raw ?? {});
|
|
8
|
+
const out = {};
|
|
9
|
+
if (r.tracking != null)
|
|
10
|
+
out.tracking = String(r.tracking);
|
|
11
|
+
if (r.evidence_ref != null)
|
|
12
|
+
out.evidence_ref = String(r.evidence_ref);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
export function computeParamsHash(orderId, action, params) {
|
|
16
|
+
return createHash('sha256').update(JSON.stringify({ order_id: orderId, action, params })).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
/** 写 pending 请求;不执行、不改订单。归属校验(seller 本人)+ D2 拒 decline + I4 提交侧 ship tracking presence + 唯一索引防双 pending。 */
|
|
19
|
+
export function createOrderActionRequest(db, opts) {
|
|
20
|
+
const { orderId, action } = opts;
|
|
21
|
+
if (action === 'decline')
|
|
22
|
+
return { ok: false, error_code: 'DECLINE_NOT_DELEGATED', error: 'decline 不可委托;请人工在 PWA 处理', http: 400 }; // D2
|
|
23
|
+
if (action !== 'accept' && action !== 'ship')
|
|
24
|
+
return { ok: false, error_code: 'BAD_ACTION', error: "action 必须为 'accept' | 'ship'", http: 400 };
|
|
25
|
+
const order = db.prepare('SELECT seller_id FROM orders WHERE id = ?').get(orderId);
|
|
26
|
+
if (!order)
|
|
27
|
+
return { ok: false, error_code: 'ORDER_NOT_FOUND', error: '订单不存在', http: 404 };
|
|
28
|
+
if (order.seller_id !== opts.humanId)
|
|
29
|
+
return { ok: false, error_code: 'NOT_ORDER_SELLER', error: '该订单不属于你', http: 403 }; // 归属
|
|
30
|
+
const params = sanitizeOrderActionParams(action, opts.rawParams); // I6:地址等被丢弃
|
|
31
|
+
if (action === 'ship' && (!params.tracking?.trim() || !params.evidence_ref?.trim())) {
|
|
32
|
+
return { ok: false, error_code: 'SHIP_TRACKING_REQUIRED', error: 'ship 请求必须带 tracking + evidence_ref', http: 400 }; // I4 提交侧(presence;内容重校在 PR3)
|
|
33
|
+
}
|
|
34
|
+
const paramsHash = computeParamsHash(orderId, action, params);
|
|
35
|
+
const id = opts.generateId('apr');
|
|
36
|
+
const expiresAt = new Date(Date.now() + TTL_HOURS * 3600_000).toISOString();
|
|
37
|
+
try {
|
|
38
|
+
db.transaction(() => {
|
|
39
|
+
db.prepare(`INSERT INTO agent_permission_requests
|
|
40
|
+
(id, human_id, grant_id, agent_label, requested_scopes, risk_level, duration, status, expires_at, kind, order_id, order_action, params_hash, action_params)
|
|
41
|
+
VALUES (?,?,?,?, '[]', 'medium', 'once', 'pending', ?, 'order_action', ?, ?, ?, ?)`)
|
|
42
|
+
.run(id, opts.humanId, opts.grantId, opts.agentLabel, expiresAt, orderId, action, paramsHash, JSON.stringify(params));
|
|
43
|
+
// I7:创建审计(不含地址;capability 只带 order_id/action/hash 前缀)
|
|
44
|
+
db.prepare('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)')
|
|
45
|
+
.run(opts.grantId, opts.humanId, `order_action:request:${orderId}:${action}:${paramsHash.slice(0, 12)}`, 'allow', null);
|
|
46
|
+
})();
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
const dup = db.prepare("SELECT 1 FROM agent_permission_requests WHERE order_id=? AND order_action=? AND kind='order_action' AND status IN ('pending','approved')").get(orderId, action);
|
|
50
|
+
if (dup)
|
|
51
|
+
return { ok: false, error_code: 'DUPLICATE_ACTION_REQUEST', error: '该订单该动作已有待批准请求', http: 409 };
|
|
52
|
+
return { ok: false, error_code: 'REQUEST_FAILED', error: e.message, http: 500 };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, request_id: id, params_hash: paramsHash };
|
|
55
|
+
}
|
|
56
|
+
// PR3:approve→执行 已移入 order-action-exec.ts::approveAndExecuteOrderAction(CAS approved + 执行 + executed_at CAS)。
|
|
57
|
+
// 本模块自 PR3 起【只负责 submit】(createOrderActionRequest);approve/execute 不在此。
|
|
@@ -35,7 +35,7 @@ window.renderAdminDisputes = async function (app, opts = {}) {
|
|
|
35
35
|
<div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.product_title || '—')}</div>
|
|
36
36
|
<div style="font-size:12px;color:#6b7280;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml((d.reason || '').slice(0, 60))}</div>
|
|
37
37
|
<div style="font-size:11px;color:#6b7280;margin-top:6px">${t('原告')}: ${escHtml(d.initiator_name || '—')} → ${t('被告')}: ${escHtml(d.defendant_name || '—')}</div>
|
|
38
|
-
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-top:6px">${railBadge(d.payment_rail)}${assignChip(d)}${urgencyChip(d)}${verdictChip(d)}</div
|
|
38
|
+
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-top:6px">${window.dcChip ? window.dcChip(d) : ''}${railBadge(d.payment_rail)}${assignChip(d)}${urgencyChip(d)}${verdictChip(d)}</div>${window.dcAdminResolveBtn ? window.dcAdminResolveBtn(d) : ''}
|
|
39
39
|
<div style="font-size:11px;color:#9ca3af;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.id)} · ${fmtTime(d.created_at)}</div>
|
|
40
40
|
</div>
|
|
41
41
|
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end;flex-shrink:0">
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// RFC-021 PR2 — order-action 审批卡正文(app-agent-approvals.js 在 LOC ceiling 上,故拆出)。
|
|
2
|
+
// 渲染"对订单 <order_id> 执行 <accept|ship>(+tracking)"。不含任何买家地址/PII(list 端已 sanitize)。
|
|
3
|
+
// Passkey 三元组由 aaApprove 从 card 的 data-* 读取:{request_id, order_id, action, params_hash}。
|
|
4
|
+
(function () {
|
|
5
|
+
window.aaOrderWhat = function (r) {
|
|
6
|
+
var ap = r.action_params || {}
|
|
7
|
+
var actionLabel = r.order_action === 'ship' ? t('发货') : r.order_action === 'accept' ? t('接单') : String(r.order_action || '')
|
|
8
|
+
var trackingLine = (r.order_action === 'ship' && ap.tracking)
|
|
9
|
+
? '<div style="font-size:12px;color:#6b7280;margin-top:4px">' + t('物流单号') + ': <code>' + escHtml(String(ap.tracking)) + '</code></div>'
|
|
10
|
+
: ''
|
|
11
|
+
return '' +
|
|
12
|
+
'<div style="font-size:13px;color:#374151;line-height:1.7">⚡ ' + t('对订单') +
|
|
13
|
+
' <code style="font-size:12px">' + escHtml(String(r.order_id || '')) + '</code> ' + t('执行') +
|
|
14
|
+
' <b style="color:#6b21a8">' + escHtml(actionLabel) + '</b></div>' +
|
|
15
|
+
trackingLine +
|
|
16
|
+
'<div style="font-size:11px;color:#9ca3af;margin-top:2px">' + t('批准后由服务端在你授权下执行;agent 不直接执行') + '</div>'
|
|
17
|
+
}
|
|
18
|
+
})()
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// 经典脚本,全局函数;globals(GET/POST/state/shell/t/escHtml/fmtTime/navigate/loading$/toast$/requestPasskeyGate)调用时解析。
|
|
4
4
|
// 商家视角:不需要懂 scope/complete/verify —— 只看【哪个 agent · 要做什么 · 风险 · 多久】。批准需 Passkey(扩权=提权)。
|
|
5
5
|
;(function () {
|
|
6
|
-
const DURATION_LABEL = { once: '一次性', '1h': '1 小时', '24h': '24 小时', '7d': '7 天', '30d': '30 天' }
|
|
7
6
|
const RISK = {
|
|
8
7
|
low: { label: '低风险', color: '#16a34a', bg: '#dcfce7' },
|
|
9
8
|
medium: { label: '中风险', color: '#b45309', bg: '#fef3c7' },
|
|
@@ -50,11 +49,10 @@
|
|
|
50
49
|
const risk = RISK[r.risk_level] || RISK.low
|
|
51
50
|
const scopes = Array.isArray(r.requested_scopes) ? r.requested_scopes : []
|
|
52
51
|
// Prefer the human-readable bundle summary; otherwise show the individual safe-scope chips.
|
|
53
|
-
const what = r.human_summary
|
|
52
|
+
const what = r.kind === 'order_action' ? (window.aaOrderWhat ? window.aaOrderWhat(r) : '') : r.human_summary
|
|
54
53
|
? `<div style="font-size:13px;color:#374151;line-height:1.7">${escHtml(r.human_summary)}</div>`
|
|
55
54
|
: `<div style="margin-top:2px">${scopes.map(s => `<span style="display:inline-block;font-size:11px;color:#4f46e5;background:#eef2ff;padding:2px 8px;border-radius:4px;margin:2px 4px 2px 0">${escHtml(String(s))}</span>`).join('') || `<span style="font-size:12px;color:#9ca3af">${t('(无 —— 仅基础只读)')}</span>`}</div>`
|
|
56
|
-
|
|
57
|
-
return `<div class="card" style="margin-bottom:12px;padding:16px;border:1px solid #e5e7eb" data-aa-id="${escHtml(String(r.id))}">
|
|
55
|
+
return `<div class="card" style="margin-bottom:12px;padding:16px;border:1px solid #e5e7eb" data-aa-id="${escHtml(String(r.id))}" data-aa-order-id="${escHtml(String(r.order_id||''))}" data-aa-action="${escHtml(String(r.order_action||''))}" data-aa-hash="${escHtml(String(r.params_hash||''))}">
|
|
58
56
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
59
57
|
<div style="font-weight:600">${escHtml(r.agent_label || t('未命名 Agent'))} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>
|
|
60
58
|
<span style="font-size:11px;color:${risk.color};background:${risk.bg};padding:2px 8px;border-radius:999px">${t(risk.label)}</span>
|
|
@@ -62,7 +60,8 @@
|
|
|
62
60
|
<div style="font-size:11px;color:#6b7280;margin-bottom:2px">${t('它想做什么')}:</div>
|
|
63
61
|
${what}
|
|
64
62
|
${r.reason ? `<div style="font-size:12px;color:#6b7280;margin-top:8px">${t('用途(agent 自述)')}: ${escHtml(r.reason)} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>` : ''}
|
|
65
|
-
|
|
63
|
+
${window.grantDurationSelect(r.allowed_durations, r.duration, 'aa-dur-' + escHtml(String(r.id)))}
|
|
64
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:6px">${t('请求于')} ${r.created_at ? fmtTime(r.created_at) : '—'}</div>
|
|
66
65
|
<div style="display:flex;gap:8px;margin-top:12px">
|
|
67
66
|
<button class="btn btn-outline" style="flex:1;color:#dc2626;border-color:#fecaca" onclick="aaReject('${escHtml(String(r.id))}')">${t('拒绝')}</button>
|
|
68
67
|
<button class="btn btn-primary" style="flex:2" onclick="aaApprove('${escHtml(String(r.id))}')">🔑 ${t('用 Passkey 批准')}</button>
|
|
@@ -75,9 +74,9 @@
|
|
|
75
74
|
const btn = card ? card.querySelector('.btn-primary') : null; if (btn) btn.disabled = true
|
|
76
75
|
let token
|
|
77
76
|
// Passkey bound to THIS request (a token minted for request A can't approve B — server re-validates).
|
|
78
|
-
try { token = await requestPasskeyGate('agent_permission_approve', { request_id: id }) }
|
|
77
|
+
try { token = await requestPasskeyGate('agent_permission_approve', { request_id: id, order_id: (card && card.dataset.aaOrderId) || undefined, action: (card && card.dataset.aaAction) || undefined, params_hash: (card && card.dataset.aaHash) || undefined }) }
|
|
79
78
|
catch (e) { if (window.dpPromptRegisterPasskey && e && e.code === 'NO_PASSKEY_REGISTERED') { await window.dpPromptRegisterPasskey(e) } else { toast$((e && e.message) || t('Passkey 验证已取消'), 'error') } if (btn) btn.disabled = false; return }
|
|
80
|
-
const r = await POST('/agent-grants/permission-requests/' + encodeURIComponent(id) + '/approve', { webauthn_token: token }).catch(() => null)
|
|
79
|
+
const r = await POST('/agent-grants/permission-requests/' + encodeURIComponent(id) + '/approve', { webauthn_token: token, duration: window.grantDurationValue('aa-dur-' + id) }).catch(() => null)
|
|
81
80
|
if (!r || r.error) { toast$((r && r.error) || t('批准失败,请重试'), 'error'); if (btn) btn.disabled = false; return }
|
|
82
81
|
toast$(t('已批准 —— 该 agent 的权限已扩展'))
|
|
83
82
|
aaHydrate(); hydrateAgentApprovalsBadge()
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
<div><span style="color:#6b7280">${t('Agent 自称')}:</span> <strong>${escHtml(c.agent_label || t('未命名'))}</strong> <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>
|
|
75
75
|
${c.reason ? `<div><span style="color:#6b7280">${t('用途(agent 自述)')}:</span> ${escHtml(c.reason)} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>` : ''}
|
|
76
76
|
<div style="margin-top:6px"><span style="color:#6b7280">${t('请求的权限')}:</span><div style="margin-top:4px">${capHtml}</div></div>
|
|
77
|
-
|
|
77
|
+
${window.grantDurationSelect(c.allowed_durations, c.suggested_duration, 'pair-duration')}
|
|
78
78
|
</div>
|
|
79
79
|
<label style="display:flex;align-items:flex-start;gap:8px;margin:14px 0 4px;font-size:12px;color:#374151;cursor:pointer">
|
|
80
80
|
<input type="checkbox" id="pair-confirm" onchange="document.getElementById('pair-approve-btn').disabled=!this.checked" style="margin-top:2px">
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
let token
|
|
93
93
|
try { token = await requestPasskeyGate('agent_pair_approve', { user_code: _code }) }
|
|
94
94
|
catch (e) { if (window.dpPromptRegisterPasskey && e && e.code === 'NO_PASSKEY_REGISTERED') { await window.dpPromptRegisterPasskey(e) } else { toast$((e && e.message) || t('Passkey 验证已取消'), 'error') } if (btn) btn.disabled = false; return }
|
|
95
|
-
const r = await POST('/agent-grants/pair/' + encodeURIComponent(_code) + '/approve', { webauthn_token: token }).catch(() => null)
|
|
95
|
+
const r = await POST('/agent-grants/pair/' + encodeURIComponent(_code) + '/approve', { webauthn_token: token, duration: window.grantDurationValue('pair-duration') }).catch(() => null)
|
|
96
96
|
if (!r || r.error) { toast$((r && r.error) || t('批准失败,请重试'), 'error'); if (btn) btn.disabled = false; return }
|
|
97
97
|
dpairShowResult('approved', r)
|
|
98
98
|
}
|
|
@@ -71,7 +71,7 @@ window.arbTaishCard = () => `
|
|
|
71
71
|
<div style="margin-bottom:10px">
|
|
72
72
|
<div class="card" onclick="location.hash='#disputes'" style="padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px;min-height:64px">
|
|
73
73
|
<div style="font-size:22px">⚖</div>
|
|
74
|
-
<div><div style="font-weight:600;font-size:14px">${t('仲裁台')}</div><div style="font-size:12px;color:#6b7280">${t('待响应 / 仲裁中 / 已结')}</div></div>
|
|
74
|
+
<div><div style="font-weight:600;font-size:14px">${t('仲裁台')}<span class="arb-badge" style="background:#7c3aed;color:#fff;border-radius:99px;font-size:11px;padding:1px 7px;margin-left:6px;display:${(window.state && state.arbPending > 0) ? 'inline' : 'none'}">${(window.state && state.arbPending) || ''}</span></div><div style="font-size:12px;color:#6b7280">${t('待响应 / 仲裁中 / 已结')}</div></div>
|
|
75
75
|
</div>
|
|
76
76
|
</div>`
|
|
77
77
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// 统一仲裁台 · decline_contest 裁决 UI(PR3)。与 app-decline-contest-ui.js(PR2,已达 ceiling)分开新建。
|
|
2
|
+
// 仲裁员两选裁决表单 + admin 兜底裁决 + 升级/落定通知模板。全局(t/GET/POST/state/handleArbitrate/requestPasskeyGate)运行时就绪。
|
|
3
|
+
(function () {
|
|
4
|
+
// 仲裁员两选裁决表单。复用 window.handleArbitrate —— radio name/reason id/msg id 与通用裁决一致,提交 POST /api/disputes/:id/arbitrate,
|
|
5
|
+
// 后端按 dispute_type 分流到唯一 resolver(dispute CAS + COI + assignment + 终态 completed + 结算,单事务)。
|
|
6
|
+
window.dcRulingForm = function (dispute) {
|
|
7
|
+
var radio = function (val, label) {
|
|
8
|
+
return '<label style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid #e9d5ff;border-radius:6px;cursor:pointer;font-size:13px"><input type="radio" name="arb-ruling-radio" value="' + val + '" style="margin-top:2px"> <span>' + label + '</span></label>'
|
|
9
|
+
}
|
|
10
|
+
return '' +
|
|
11
|
+
'<div style="margin-top:12px;border:1px solid #e9d5ff;background:#faf5ff;border-radius:8px;padding:12px">' +
|
|
12
|
+
'<div style="font-weight:600;font-size:13px;color:#6b21a8;margin-bottom:8px">⚖ ' + t('拒单举证仲裁裁决') + '</div>' +
|
|
13
|
+
'<div id="arbitrate-msg"></div>' +
|
|
14
|
+
'<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px">' +
|
|
15
|
+
radio('decline_no_fault_upheld', t('维持无责 —— 全退买家 + 退卖家质押,零罚没')) +
|
|
16
|
+
radio('decline_fault_confirmed', t('驳回举证,判卖家违约 —— 退款买家 + 罚没质押')) +
|
|
17
|
+
'</div>' +
|
|
18
|
+
'<textarea class="form-control" id="arb-reason" rows="3" placeholder="' + t('裁定理由(必填)') + '" style="width:100%;margin-bottom:8px"></textarea>' +
|
|
19
|
+
'<button class="btn btn-primary btn-sm" style="width:auto" onclick="handleArbitrate(\'' + dispute.id + '\')">' + t('确认裁定') + '</button>' +
|
|
20
|
+
'</div>'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// admin 兜底裁决(管理面)。仅 decline_contest 且仲裁窗口(arbitrate_deadline)已过时给按钮;窗口未过后端 FALLBACK_TOO_EARLY。
|
|
24
|
+
window.dcAdminResolveBtn = function (d) {
|
|
25
|
+
if (!d || d.dispute_type !== 'decline_contest' || !d.arbitrate_deadline) return ''
|
|
26
|
+
if (new Date().toISOString() <= d.arbitrate_deadline) return '' // 仲裁员优先:窗口内不给 admin 兜底入口
|
|
27
|
+
var b = function (dec, label, bg) { return '<button class="btn btn-sm" style="width:auto;background:' + bg + ';color:#fff;font-size:12px" onclick="dcAdminResolve(\'' + d.id + '\',\'' + dec + '\')">' + label + '</button>' }
|
|
28
|
+
return '<div style="display:flex;gap:6px;margin-top:6px">🛡️ ' + b('decline_no_fault_upheld', t('兜底·维持无责'), '#16a34a') + b('decline_fault_confirmed', t('兜底·判违约'), '#dc2626') + '</div>'
|
|
29
|
+
}
|
|
30
|
+
window.dcAdminResolve = async function (disputeId, decision) {
|
|
31
|
+
var reason = window.prompt(t('裁定理由(必填)'))
|
|
32
|
+
if (!reason || !reason.trim()) return
|
|
33
|
+
var tk
|
|
34
|
+
try { tk = await requestPasskeyGate('arbitrate', { dispute_id: disputeId, action: 'decline_contest_resolve', decision: decision }) }
|
|
35
|
+
catch (e) { window.alert(t('此操作需现场真人 Passkey 验证')); return }
|
|
36
|
+
var res = await POST('/admin/disputes/' + disputeId + '/decline-contest-resolve', { decision: decision, reason: reason, webauthn_token: tk })
|
|
37
|
+
if (res && res.error) { window.alert(res.error); return }
|
|
38
|
+
window.alert(t('裁决已执行'))
|
|
39
|
+
if (typeof renderAdminDisputes === 'function') renderAdminDisputes(document.getElementById('app'), {})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 双语通知模板(升级 + 落定);运行时挂载到 NOTIF_TEMPLATES。
|
|
43
|
+
if (window.NOTIF_TEMPLATES) {
|
|
44
|
+
window.NOTIF_TEMPLATES['arb_decline_contest_escalated'] = function () {
|
|
45
|
+
return { title: '⏰ ' + t('拒单举证仲裁已超时,请尽快裁决'), body: t('一笔拒单举证仲裁已过仲裁窗口仍未裁决,已进入 admin 兜底窗口 —— 请尽快处理,否则将自动判卖家违约。') }
|
|
46
|
+
}
|
|
47
|
+
window.NOTIF_TEMPLATES['arb_decline_contest_resolved'] = function (p) {
|
|
48
|
+
var upheld = p && p.decision === 'decline_no_fault_upheld'
|
|
49
|
+
return { title: (upheld ? '✅ ' : '⚖ ') + (upheld ? t('拒单举证仲裁:维持无责') : t('拒单举证仲裁:驳回,判卖家违约')),
|
|
50
|
+
body: upheld ? t('仲裁认定卖家客观无责:买家已全额退款,卖家质押已退回,无罚没。订单已结。') : t('仲裁驳回卖家举证,判卖家违约:买家已退款,卖家质押按违约处置。订单已结。') }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// 统一仲裁台 · decline_contest(拒单举证仲裁)前端渲染 + 待办角标。
|
|
2
|
+
// app.js / app-arbitrator-admin.js 都在 LOC ceiling 上(零余量),故所有渲染逻辑集中在此(非 pinned 域文件),
|
|
3
|
+
// pinned 文件侧只做 net-zero 的 window.* 调用。全局(t / GET / state)由 app.js 等 classic script 提供,运行时已就绪。
|
|
4
|
+
(function () {
|
|
5
|
+
var isDC = function (d) { return d && d.dispute_type === 'decline_contest' }
|
|
6
|
+
|
|
7
|
+
// ① 列表行类型徽章(紫色 = 仲裁色系,镜像现有 arbitration chip)。
|
|
8
|
+
window.dcChip = function (d) {
|
|
9
|
+
return isDC(d)
|
|
10
|
+
? '<span style="display:inline-flex;align-items:center;gap:3px;background:#faf5ff;color:#6b21a8;padding:0 6px;border-radius:99px;font-size:10px;font-weight:600;margin-right:5px">⚖ ' + t('拒单举证仲裁') + '</span>'
|
|
11
|
+
: ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ② 详情页:PR3 打通专用裁决前,decline_contest 的裁决通道 fail-closed —— 用只读提示卡【替代】裁决表单(不显示会 409 的按钮)。
|
|
15
|
+
window.dcNotice = function (dispute) {
|
|
16
|
+
return '' +
|
|
17
|
+
'<div class="card" style="margin-top:12px;border:1px solid #e9d5ff;background:#faf5ff;border-radius:8px;padding:12px">' +
|
|
18
|
+
'<div style="font-weight:600;font-size:13px;color:#6b21a8;margin-bottom:4px">⚖ ' + t('拒单举证仲裁') + '</div>' +
|
|
19
|
+
'<div style="font-size:12px;color:#6b7280;line-height:1.6">' + t('卖家主张客观无责并已举证。裁决通道即将开放——届时可裁定:维持无责(全退买家+退卖家质押)或驳回(判卖家违约)。') + '</div>' +
|
|
20
|
+
'<div style="font-size:12px;color:#9ca3af;margin-top:6px">⏳ ' + t('裁决通道即将开放') + '</div>' +
|
|
21
|
+
'</div>'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ③ 待办角标:拉 pending-count → 写 state.arbPending → 就地刷新 .arb-badge(不整页重渲染,镜像 chats-badge 的 updateAggregate)。
|
|
25
|
+
window.refreshArbBadge = async function () {
|
|
26
|
+
try {
|
|
27
|
+
if (!(window.state && window.state.canArbitrate)) return
|
|
28
|
+
var r = await GET('/arbitrator/pending-count')
|
|
29
|
+
window.state.arbPending = (r && typeof r.count === 'number') ? r.count : 0
|
|
30
|
+
var n = window.state.arbPending
|
|
31
|
+
document.querySelectorAll('.arb-badge').forEach(function (b) {
|
|
32
|
+
b.textContent = n > 0 ? n : ''
|
|
33
|
+
b.style.display = n > 0 ? 'inline' : 'none'
|
|
34
|
+
})
|
|
35
|
+
} catch (e) { /* 角标非关键,静默 */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ④ 双语通知模板(app-notif-templates.js 在 ceiling 上,故在此运行时挂载;title 约定「emoji+空格」开头 → 列表当图标)。
|
|
39
|
+
if (window.NOTIF_TEMPLATES) {
|
|
40
|
+
window.NOTIF_TEMPLATES['arb_decline_contest_new'] = function () {
|
|
41
|
+
return { title: '⚖ ' + t('新的拒单举证仲裁待处理'), body: t('一笔卖家客观拒单举证已进入统一仲裁台,等待仲裁员裁决。') }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// RFC-020 — shared grant-duration picker (used by #pair initial pairing + #agent-approvals expansion).
|
|
2
|
+
// Classic script, loaded before the pages that use it. Globals (t / escHtml) resolve at call time.
|
|
3
|
+
// The human chooses the grant lifetime; safe scopes → up to 30d. Kept in one place so both approval
|
|
4
|
+
// surfaces stay identical and the pinned page files don't grow.
|
|
5
|
+
;(function () {
|
|
6
|
+
const LABEL = { '1h': '1 小时', '24h': '24 小时', '7d': '7 天', '30d': '30 天' } // 'once' intentionally omitted — no real single-use grant yet (would become a misleading 1h reusable bearer)
|
|
7
|
+
|
|
8
|
+
// Render a <select id=selectId> of `allowed` durations, pre-selecting `suggested`. Falls back to a safe set.
|
|
9
|
+
window.grantDurationSelect = function (allowed, suggested, selectId) {
|
|
10
|
+
const opts = (Array.isArray(allowed) && allowed.length) ? allowed : ['1h', '24h', '7d', '30d']
|
|
11
|
+
const sel = suggested || '7d'
|
|
12
|
+
return `<div style="margin-top:10px"><span style="font-size:12px;color:#6b7280">${t('授权时长')}(${t('你可以改')}):</span>`
|
|
13
|
+
+ `<select id="${escHtml(String(selectId))}" style="width:100%;margin-top:4px;padding:8px;border:1px solid #d1d5db;border-radius:8px;font-size:14px">`
|
|
14
|
+
+ opts.map(d => `<option value="${escHtml(String(d))}" ${d === sel ? 'selected' : ''}>${escHtml(t(LABEL[d] || String(d)))}</option>`).join('')
|
|
15
|
+
+ `</select></div>`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Read the human's chosen duration from a rendered selector (undefined if absent → backend uses its default).
|
|
19
|
+
window.grantDurationValue = function (selectId) { const el = document.getElementById(selectId); return (el && el.value) || undefined }
|
|
20
|
+
})()
|
package/dist/pwa/public/app.js
CHANGED
|
@@ -566,7 +566,7 @@ async function render(page, params) {
|
|
|
566
566
|
const { status, body } = await apiWithStatus('GET', '/me')
|
|
567
567
|
if (status === 200 && body && !body.error) {
|
|
568
568
|
state.user = body
|
|
569
|
-
connectSSE(); maybePromptPlacementBind(); refreshCartBadge(); maybeShowInstallBanner(); maybeClaimPendingProductShare(); maybeClaimPendingShopReferral(); setTimeout(refreshCompareBadge, 0); refreshChatsBadge(); refreshFeedbackBadge(); refreshSnfBadge(); setTimeout(ensureSnfPull, 800); if (window.arbEntryHydrate) arbEntryHydrate()
|
|
569
|
+
connectSSE(); maybePromptPlacementBind(); refreshCartBadge(); maybeShowInstallBanner(); maybeClaimPendingProductShare(); maybeClaimPendingShopReferral(); setTimeout(refreshCompareBadge, 0); refreshChatsBadge(); refreshFeedbackBadge(); refreshSnfBadge(); setTimeout(ensureSnfPull, 800); if (window.arbEntryHydrate) arbEntryHydrate(); if (window.refreshArbBadge) refreshArbBadge()
|
|
570
570
|
try { p2pStart() } catch (e) { console.warn('[P2P] start err', e) }
|
|
571
571
|
} else if (status === 401 || status === 403) {
|
|
572
572
|
// 真·鉴权失败:清掉两层存储
|
|
@@ -1073,7 +1073,7 @@ function shell(content, activeTab, opts) {
|
|
|
1073
1073
|
]
|
|
1074
1074
|
} else if (role === 'arbitrator') {
|
|
1075
1075
|
tabs = [
|
|
1076
|
-
{ id: 'seller', icon: '⚖️', label: t('仲裁台') },
|
|
1076
|
+
{ id: 'seller', icon: '⚖️', label: t('仲裁台'), arbBadge: true },
|
|
1077
1077
|
{ id: 'orders', icon: '📋', label: t('记录') },
|
|
1078
1078
|
{ id: 'chats', target: 'chats/notifs', icon: '💬', label: t('消息'), chatsBadge: true },
|
|
1079
1079
|
{ id: 'wallet', icon: '💰', label: t('钱包') },
|
|
@@ -1204,7 +1204,7 @@ function shell(content, activeTab, opts) {
|
|
|
1204
1204
|
<span class="tab-icon" style="position:relative">
|
|
1205
1205
|
${tb.icon}
|
|
1206
1206
|
${tb.badge ? `<span id="notif-badge" style="position:absolute;top:-4px;right:-6px;background:#dc2626;color:#fff;border-radius:99px;font-size:10px;padding:0 4px;min-width:16px;text-align:center;display:${state.unread > 0 ? 'inline' : 'none'}">${state.unread || ''}</span>` : ''}
|
|
1207
|
-
${tb.chatsBadge ? (() => { const _agg = (state.chatsUnread||0)+(state.unread||0)+(state.announcementsUnread||0)+(state.feedbackUnread||0); return `<span class="chats-badge" style="position:absolute;top:-4px;right:-6px;background:#4f46e5;color:#fff;border-radius:99px;font-size:10px;padding:0 4px;min-width:16px;text-align:center;display:${_agg > 0 ? 'inline' : 'none'}">${_agg || ''}</span>` })() : ''}
|
|
1207
|
+
${tb.chatsBadge ? (() => { const _agg = (state.chatsUnread||0)+(state.unread||0)+(state.announcementsUnread||0)+(state.feedbackUnread||0); return `<span class="chats-badge" style="position:absolute;top:-4px;right:-6px;background:#4f46e5;color:#fff;border-radius:99px;font-size:10px;padding:0 4px;min-width:16px;text-align:center;display:${_agg > 0 ? 'inline' : 'none'}">${_agg || ''}</span>` })() : ''}${tb.arbBadge ? `<span class="arb-badge" style="position:absolute;top:-4px;right:-6px;background:#7c3aed;color:#fff;border-radius:99px;font-size:10px;padding:0 4px;min-width:16px;text-align:center;display:${(state.arbPending||0) > 0 ? 'inline' : 'none'}">${state.arbPending || ''}</span>` : ''}
|
|
1208
1208
|
</span>${tb.label}
|
|
1209
1209
|
</button>`).join('')}
|
|
1210
1210
|
</nav>`}`
|
|
@@ -13974,7 +13974,7 @@ function buildDisputeHtml(dispute, user) {
|
|
|
13974
13974
|
// ── 仲裁员:暂停 / 恢复自动判定时钟(playbook §2.1)────
|
|
13975
13975
|
// 补证据期 > 48h 时必须显式暂停,避免被协议层 48h 沉默判架空
|
|
13976
13976
|
const isPaused = dispute.auto_judge_paused_until && dispute.auto_judge_paused_until * 1000 > Date.now()
|
|
13977
|
-
const pauseSection = isArbitrator && dispute.status !== 'resolved' && !dispute.ruling_type ? (
|
|
13977
|
+
const pauseSection = isArbitrator && dispute.dispute_type !== 'decline_contest' && dispute.status !== 'resolved' && !dispute.ruling_type ? (
|
|
13978
13978
|
isPaused ? `
|
|
13979
13979
|
<div style="margin-top:12px;background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;padding:12px">
|
|
13980
13980
|
<div style="font-size:13px;font-weight:600;color:#92400e;margin-bottom:6px">⏸️ ${t('自动判定时钟已冻结')}</div>
|
|
@@ -14011,7 +14011,7 @@ function buildDisputeHtml(dispute, user) {
|
|
|
14011
14011
|
) : ''
|
|
14012
14012
|
|
|
14013
14013
|
// ── 仲裁员:发起补充证据请求 ──────────────────
|
|
14014
|
-
const requestEvidenceSection = isArbitrator && dispute.status !== 'resolved' ? `
|
|
14014
|
+
const requestEvidenceSection = isArbitrator && dispute.dispute_type !== 'decline_contest' && dispute.status !== 'resolved' ? `
|
|
14015
14015
|
<div style="margin-top:12px">
|
|
14016
14016
|
<button class="btn btn-outline btn-sm" style="width:auto;font-size:12px" onclick="(function(){var s=document.getElementById('req-ev-section');s.style.display=s.style.display==='none'?'':'none'})()">📋 请求补充证据</button>
|
|
14017
14017
|
<div id="req-ev-section" style="display:none;margin-top:10px;background:#f8fafc;border-radius:8px;padding:12px">
|
|
@@ -14050,7 +14050,7 @@ function buildDisputeHtml(dispute, user) {
|
|
|
14050
14050
|
</div>` : ''
|
|
14051
14051
|
|
|
14052
14052
|
// ── 仲裁员裁定(含责任分配)────────────────────
|
|
14053
|
-
const arbitrateSection = isArbitrator && (dispute.status === 'open' || dispute.status === 'in_review') ? `
|
|
14053
|
+
const arbitrateSection = isArbitrator && (dispute.status === 'open' || dispute.status === 'in_review') ? (dispute.dispute_type === 'decline_contest' ? (window.dcRulingForm ? window.dcRulingForm(dispute) : '') : `
|
|
14054
14054
|
<div style="margin-top:12px;border-top:1px solid #fecaca;padding-top:12px">
|
|
14055
14055
|
<div style="font-weight:600;font-size:13px;margin-bottom:8px">⚖️ 仲裁员裁定(截止 ${fmtTime(dispute.arbitrate_deadline)})</div>
|
|
14056
14056
|
${window.dpArbFeeNote ? window.dpArbFeeNote(dispute.payment_rail) : ''}
|
|
@@ -14102,7 +14102,7 @@ function buildDisputeHtml(dispute, user) {
|
|
|
14102
14102
|
<textarea class="form-control" id="arb-reason" placeholder="${t('请详细说明裁定依据(将记录在案)')}" style="font-size:13px"></textarea>
|
|
14103
14103
|
</div>
|
|
14104
14104
|
<button class="btn btn-danger btn-sm" style="width:auto" onclick="handleArbitrate('${dispute.id}')">${t('确认裁定')}</button>
|
|
14105
|
-
</div>` : ''
|
|
14105
|
+
</div>`) : ''
|
|
14106
14106
|
// ── 裁定结果已在 timeline 末尾以 'ruling' / 'resolved' 事件呈现(W4)
|
|
14107
14107
|
|
|
14108
14108
|
// W4: 三方参与者头部(取代两行 detail-row,更紧凑)
|
|
@@ -14342,7 +14342,7 @@ window.handleArbitrate = async (disputeId) => {
|
|
|
14342
14342
|
body = { ...body, liability_parties: liabilityParties, refund_amount: totalCalc }
|
|
14343
14343
|
}
|
|
14344
14344
|
|
|
14345
|
-
let _arbTk; try { _arbTk = await requestPasskeyGate('arbitrate', { dispute_id: disputeId }) } catch (e) { msgEl.innerHTML = alert$('error', (e && e.message ? e.message + ' — ' : '') + t('仲裁裁定需现场真人 Passkey 验证')); return }; msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('裁定中...')}</div>` // 所有 ruling(含驳回)先取 arbitrate gate token
|
|
14345
|
+
let _arbTk; try { _arbTk = await requestPasskeyGate('arbitrate', { dispute_id: disputeId, decision: ruling }) } catch (e) { msgEl.innerHTML = alert$('error', (e && e.message ? e.message + ' — ' : '') + t('仲裁裁定需现场真人 Passkey 验证')); return }; msgEl.innerHTML = `<div class="alert alert-info"><span class="spinner"></span>${t('裁定中...')}</div>` // 所有 ruling(含驳回)先取 arbitrate gate token;绑 dispute_id + decision(decline_contest 钱路 fail-closed 校验)
|
|
14346
14346
|
const res = await POST(`/disputes/${disputeId}/arbitrate`, { ...body, webauthn_token: _arbTk })
|
|
14347
14347
|
if (res.error) { msgEl.innerHTML = alert$('error', res.error); return }
|
|
14348
14348
|
|
|
@@ -14622,7 +14622,7 @@ async function renderDisputeList(app) {
|
|
|
14622
14622
|
<div class="card" onclick="navigate('#dispute/${d.id}')" style="padding:10px 12px;display:flex;align-items:center;gap:10px;cursor:pointer;${urgent ? 'border-left:3px solid #dc2626;' : ''}">
|
|
14623
14623
|
<div style="font-size:22px;flex-shrink:0">${urgent ? '⏰' : stIcon}</div>
|
|
14624
14624
|
<div style="flex:1;min-width:0">
|
|
14625
|
-
<div style="font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escHtml(d.reason || '')}</div>
|
|
14625
|
+
<div style="font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${window.dcChip ? window.dcChip(d) : ''}${escHtml(d.reason || '')}</div>
|
|
14626
14626
|
<div style="font-size:11px;color:#6b7280;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escHtml(d.initiator_name || '?')} → ${escHtml(d.defendant_name || '—')} · ${d.total_amount} WAZ</div>
|
|
14627
14627
|
<div style="font-size:10px;color:${urgent ? '#dc2626' : '#9ca3af'};margin-top:2px${urgent ? ';font-weight:600' : ''}">${disputeStatusLabelText(d.status)}${deadline ? ` · ${urgent ? '⚠ ' : ''}${t('截止')} ${fmtTime(deadline)}` : ` · ${fmtTime(d.updated_at || d.created_at)}`}</div>
|
|
14628
14628
|
</div>
|
package/dist/pwa/public/i18n.js
CHANGED
|
@@ -17,6 +17,29 @@ window.resolveInitialLang = (savedLang, navigatorLike) => {
|
|
|
17
17
|
window._lang = window.resolveInitialLang(localStorage.getItem('webaz_lang'), typeof navigator !== 'undefined' ? navigator : null)
|
|
18
18
|
|
|
19
19
|
const _EN = {
|
|
20
|
+
// ── RFC-021 order-action 审批卡(app-agent-approvals-order.js)──
|
|
21
|
+
'对订单': 'On order',
|
|
22
|
+
'批准后由服务端在你授权下执行;agent 不直接执行': 'On approval the server executes under your authority; the agent never executes directly',
|
|
23
|
+
// ── 统一仲裁台:decline_contest(拒单举证仲裁,app-decline-contest-ui.js)──
|
|
24
|
+
'拒单举证仲裁': 'Decline-contest arbitration',
|
|
25
|
+
'裁决通道即将开放': 'Ruling channel opening soon',
|
|
26
|
+
'卖家主张客观无责并已举证。裁决通道即将开放——届时可裁定:维持无责(全退买家+退卖家质押)或驳回(判卖家违约)。': 'The seller claims objective no-fault and has submitted evidence. The ruling channel is opening soon — an arbitrator will then rule: uphold no-fault (full refund to buyer + seller stake returned) or reject (seller at fault).',
|
|
27
|
+
'新的拒单举证仲裁待处理': 'New decline-contest arbitration pending',
|
|
28
|
+
'一笔卖家客观拒单举证已进入统一仲裁台,等待仲裁员裁决。': 'A seller objective-decline contest has entered the unified arbitration desk, awaiting an arbitrator ruling.',
|
|
29
|
+
'拒单举证仲裁裁决': 'Decline-contest ruling',
|
|
30
|
+
'维持无责 —— 全退买家 + 退卖家质押,零罚没': 'Uphold no-fault — full refund to buyer + seller stake returned, no forfeit',
|
|
31
|
+
'驳回举证,判卖家违约 —— 退款买家 + 罚没质押': 'Reject the contest, seller at fault — refund buyer + forfeit stake',
|
|
32
|
+
'裁定理由(必填)': 'Ruling reason (required)',
|
|
33
|
+
'兜底·维持无责': 'Fallback · uphold no-fault',
|
|
34
|
+
'兜底·判违约': 'Fallback · seller at fault',
|
|
35
|
+
'此操作需现场真人 Passkey 验证': 'This action requires a live human Passkey verification',
|
|
36
|
+
'裁决已执行': 'Ruling executed',
|
|
37
|
+
'拒单举证仲裁已超时,请尽快裁决': 'Decline-contest arbitration overdue — please rule soon',
|
|
38
|
+
'一笔拒单举证仲裁已过仲裁窗口仍未裁决,已进入 admin 兜底窗口 —— 请尽快处理,否则将自动判卖家违约。': 'A decline-contest arbitration has passed its ruling window unresolved and entered the admin fallback window — please handle it soon, or the seller will be auto-ruled at fault.',
|
|
39
|
+
'拒单举证仲裁:维持无责': 'Decline-contest: no-fault upheld',
|
|
40
|
+
'拒单举证仲裁:驳回,判卖家违约': 'Decline-contest: rejected, seller at fault',
|
|
41
|
+
'仲裁认定卖家客观无责:买家已全额退款,卖家质押已退回,无罚没。订单已结。': 'Arbitration found the seller objectively no-fault: buyer fully refunded, seller stake returned, no forfeit. Order closed.',
|
|
42
|
+
'仲裁驳回卖家举证,判卖家违约:买家已退款,卖家质押按违约处置。订单已结。': 'Arbitration rejected the seller\'s contest and ruled seller at fault: buyer refunded, seller stake handled per fault rules. Order closed.',
|
|
20
43
|
// ── 仲裁员管理 admin UI(app-arbitrator-admin.js,PR-F)──
|
|
21
44
|
'仲裁员管理': 'Arbitrator management',
|
|
22
45
|
'授权真人仲裁员(唯一授权源=active 白名单)。目标须已注册 Passkey、非本案当事人、非 agent/系统账号。授权/暂停/撤销均需你现场 Passkey,后端留痕。': 'Grant real human arbitrators (the sole authorization source is the active whitelist). The target must have a registered Passkey, not be a party to the case, and not be an agent/system account. Grant/suspend/revoke each require your live Passkey and are audited server-side.',
|
|
@@ -556,11 +579,11 @@ const _EN = {
|
|
|
556
579
|
'当你的 agent 请求更多权限时,会出现在这里等你批准。': 'When your agent requests more permissions, they appear here for your approval.',
|
|
557
580
|
'它想做什么': 'What it wants to do',
|
|
558
581
|
'授权时长': 'Grant duration',
|
|
582
|
+
'你可以改': 'you can change this',
|
|
559
583
|
'请求于': 'Requested',
|
|
560
584
|
'低风险': 'Low risk',
|
|
561
585
|
'中风险': 'Medium risk',
|
|
562
586
|
'高风险': 'High risk',
|
|
563
|
-
'一次性': 'One-time',
|
|
564
587
|
'1 小时': '1 hour',
|
|
565
588
|
'7 天': '7 days',
|
|
566
589
|
'确认拒绝这个授权请求?': 'Reject this permission request?',
|
|
@@ -60,9 +60,11 @@
|
|
|
60
60
|
<script src="/app-price.js"></script>
|
|
61
61
|
<script src="/app-order-labels.js"></script>
|
|
62
62
|
<script src="/app-seller.js"></script>
|
|
63
|
+
<script src="/app-grant-duration.js"></script>
|
|
63
64
|
<script src="/app-agents.js"></script>
|
|
64
65
|
<script src="/app-agent-pair.js"></script>
|
|
65
66
|
<script src="/app-agent-approvals.js"></script>
|
|
67
|
+
<script src="/app-agent-approvals-order.js"></script>
|
|
66
68
|
<script src="/app-agent-appeal.js"></script>
|
|
67
69
|
<script src="/app-direct-pay.js"></script>
|
|
68
70
|
<script src="/app-direct-pay-readiness.js"></script>
|
|
@@ -107,6 +109,8 @@
|
|
|
107
109
|
<script src="/app-order-errors.js"></script>
|
|
108
110
|
<script src="/app-arbitrator-entry.js"></script>
|
|
109
111
|
<script src="/app-arbitrator-admin.js"></script>
|
|
112
|
+
<script src="/app-decline-contest-ui.js"></script>
|
|
113
|
+
<script src="/app-decline-contest-ruling.js"></script>
|
|
110
114
|
<script src="/app-contribution-hub.js"></script>
|
|
111
115
|
<script src="/app-prelaunch-waz.js"></script>
|
|
112
116
|
<script src="/app-chat-poll.js"></script>
|