。 */
+function askText(title, label, options) {
+ const opts = options || {};
+ if (opts.choices) {
+ return new Promise((resolve) => {
+ const sel = opts.choices.map((c) => ``).join('');
+ openModal(title,
+ `
+ `,
+ `
+ `);
+ window.__ask = resolve;
+ });
+ }
+ return new Promise((resolve) => {
+ openModal(title,
+ `
+
+ ${esc(opts.hint || '')}
`,
+ `
+ `);
+ window.__ask = resolve;
+ });
+}
+
+/* ---------------- 统一响应处理(17- 文档 §六) ----------------
+ 失败提示解析顺序:error.message -> HTTP 状态码映射 -> 通用失败提示。
+ 注意本平台**业务失败也返回 HTTP 200**,错误在 body.code 里。 */
+
+const HTTP_TEXT = {
+ 401: '登录已失效,请重新登录',
+ 403: '没有当前操作权限',
+ 404: '记录不存在或无权查看',
+ 409: '当前状态不允许该操作',
+ 413: '文件超过大小限制',
+ 422: '提交内容不符合要求',
+ 500: '系统异常,请稍后重试',
+ 503: '服务暂时不可用',
+ 504: '服务暂时不可用',
+};
+
+/** 解析出「这次调用到底成没成」,并给出可直接展示的话。 */
+function judge(r) {
+ const body = (r && r.body) || {};
+ const err = body.error || {};
+ const code = body.code;
+ const biz = (code === undefined || code === null) ? null : Number(code);
+ const httpBad = !r || r.status >= 400 || r.status === 0;
+ const bizBad = (biz !== null && biz !== 0) || !!err.code;
+ const failed = httpBad || bizBad;
+
+ let reason = '';
+ if (err.message) reason = err.message;
+ else if (bizBad && body.message) reason = body.message;
+ else if (bizBad && biz !== null) reason = HTTP_TEXT[biz] || (body.message || '');
+ if (!reason && httpBad) reason = HTTP_TEXT[(r || {}).status] || '请求失败';
+ if (!failed) return { failed: false, reason: '' };
+
+ const details = (body.error && body.error.field_errors) || body.field_errors || [];
+ if (details.length) {
+ reason += ':' + details.map((d) => `${d.field} ${d.message}`).join(';');
+ }
+ return { failed: true, reason: reason || '请求失败' };
+}
+
+/** 按 17- 文档 §六 给出「操作名 + 成功/失败」的提示文案。 */
+function report(action, r, successText) {
+ const verdict = judge(r);
+ if (verdict.failed) {
+ const text = verdict.reason || '请求失败';
+ if ((r || {}).status === 409) {
+ bad(`${action}未完成:${text}\n可能已被处理,请刷新后核对状态`);
+ } else {
+ bad(`${action}失败:${text}`);
+ }
+ return false;
+ }
+ ok(successText || `${action}成功`);
+ return true;
+}
+
// 统一调用平台接口:令牌在服务端,前端只传方法与路径
async function api(method, path, body, query) {
return jpost('/api/call', { method, path, body, query });
@@ -630,7 +811,7 @@ async function ensureSession() {
body:{ agent_type:'customer_service' } });
const d = (r.body || {}).data || {};
CONV = d.session_id || d.id || null;
- if (!CONV) { alert('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
+ if (!CONV) { bad('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
return CONV;
}
@@ -685,7 +866,7 @@ async function myCandidates() {
async function decide(id, decision) {
const r = await jpost('/api/call', { method:'POST',
path: `/api/v1/users/me/memory-candidates/${id}/decisions`, body: { decision } });
- alert(`HTTP ${r.status}\n` + pretty(r.body));
+ bad(`HTTP ${r.status}\n` + pretty(r.body));
myCandidates();
}
@@ -708,114 +889,412 @@ function showExtra(title, r) {
${esc(pretty(r.body))}`;
}
-/* ---------------- 员工 · 风控工作台 ---------------- */
+/* ---------------- 员工 · 风控工作台 ----------------
+ 按 docs/风控业务演示文档/17-前端合并提示词与验收约束.md 实现:
+ 概览五指标、预警队列每页 5 条 + 风险等级优先 + 筛选 + 游标分页 + 详情弹窗、
+ 五个处置动作(确认接收二次确认、误报必填理由)、八类证据、通知、日报流式。
+ 交互上不用原生 alert/prompt —— 改用 Toast 与模态框。 */
+
+const EVIDENCE_SOURCES = [
+ ['customers', '客户'], ['products', '产品'], ['transactions', '交易'],
+ ['capital_flows', '资金'], ['holdings', '持仓'], ['login_records', '登录'],
+ ['alerts', '预警'], ['notifications', '通知'],
+];
+// 注意是 `holdings` 而不是 positions —— 传错会被 422 拒绝。
+const LEVEL_RANK = { '高风险': 0, '高': 0, '中风险': 1, '中': 1, '低风险': 2, '低': 2 };
+const RISK_PAGE_SIZE = 5;
+let RISK_CURSOR = null;
+let RISK_CURSOR_STACK = [];
+let RISK_HAS_MORE = false;
+let RISK_ITEMS = [];
+let RISK_DETAIL = null;
+
function renderStaff(box) {
box.innerHTML = `
-
风控工作台
-
只对 risk_operator / operator 开放。数据范围 all:
- 能看到全部客户的预警。处置类操作会写审计。
-
+
风险概览
+
数据范围 all(风控专员可看全部客户)。五个指标取自
+ GET /api/v1/risk/overview。
+
-
-
-
+
+
+
+
+
+
+
`;
loadRisk();
- loadAlerts();
+ loadAlerts(null);
+}
+
+function resetRiskFilters() {
+ ['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
+ 'product_name', 'start_time', 'end_time'].forEach((k) => { $('f-' + k).value = ''; });
+ loadAlerts(null);
+}
+
+function riskQuery() {
+ const q = {};
+ ['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
+ 'product_name', 'start_time', 'end_time'].forEach((k) => {
+ const v = ($('f-' + k) || {}).value;
+ if (v && v.trim()) q[k] = v.trim();
+ });
+ q.limit = RISK_PAGE_SIZE; // 该接口 limit 上限就是 5
+ return q;
}
async function loadRisk() {
const r = await GET('/api/v1/risk/overview');
- const d = r.body?.data || {};
- const entries = Object.entries(d).filter(([, v]) => typeof v !== 'object');
- $('overview').innerHTML = entries.length
- ? entries.map(([k, v]) => ``).join('')
- : ``;
- if (!entries.length) $('staff-extra').innerHTML =
- `总览原始返回
${esc(pretty(r.body))}`;
-}
-
-async function loadAlerts() {
- // 刻意**不传 limit**:该接口的 limit 上限是 5(传 20 会 422
- // `query.limit: Input should be less than or equal to 5`),
- // 不传则用平台默认值,最稳。
- const r = await GET('/api/v1/risk/alerts');
- const items = Array.isArray(r.body?.data) ? r.body.data
- : (r.body?.data?.items || []);
- const box = $('alerts');
- if (!Array.isArray(items) || !items.length) {
- box.innerHTML = `没有预警数据(HTTP ${r.status}${r.body?.code ? ' code=' + r.body.code : ''})。可先点「触发一次扫描」。
- ${esc(pretty(r.body))}`;
+ const d = (r.body || {}).data || {};
+ const verdict = judge(r);
+ if (verdict.failed) {
+ $('risk-overview').innerHTML = ``;
return;
}
- box.innerHTML = `| 预警号 | 客户 | 等级 | 规则 | 状态 | 操作 |
`
- + items.map((a) => {
- const no = esc(a.alert_no ?? a.alert_id ?? '');
- const rules = Array.isArray(a.rule_codes) ? a.rule_codes.join(', ') : (a.rule_code ?? '');
- return `
- ${no} |
- ${esc(a.customer_name || a.customer_no || a.customer_id || '')} |
- ${esc(a.risk_level ?? a.level ?? '')} |
- ${esc(rules)} |
- ${esc(a.status ?? '')} |
-
-
-
-
- |
`;
- }).join('')
- + '
';
+ const levels = d.levels || {};
+ const hi = (d.high_priority || []).length;
+ const cards = [
+ ['预警总量', d.total],
+ ['待处理', d.pending],
+ ['已超时', d.overdue],
+ ['高风险', levels['高风险'] ?? levels['高'] ?? 0],
+ ['重点预警', hi],
+ ];
+ $('risk-overview').innerHTML = cards.map(([label, value]) =>
+ `${esc(value ?? '—')}
${esc(label)}
`).join('');
+}
+
+async function loadAlerts(cursor) {
+ if (cursor === null || cursor === undefined) { RISK_CURSOR_STACK = []; }
+ const q = riskQuery();
+ if (cursor) q.cursor = cursor;
+ const r = await GET('/api/v1/risk/alerts', q);
+ const verdict = judge(r);
+ const box = $('alerts');
+ if (verdict.failed) {
+ box.innerHTML = `加载失败:${esc(verdict.reason)}
`;
+ $('pg-info').textContent = '';
+ return;
+ }
+ const data = (r.body || {}).data;
+ const items = Array.isArray(data) ? data : ((data || {}).items || []);
+ const meta = (r.body || {}).meta || {};
+ RISK_HAS_MORE = !!meta.has_more;
+ RISK_CURSOR = meta.next_cursor || null;
+ // 页内按风险等级优先,同级按 priority_score 降序
+ RISK_ITEMS = items.slice().sort((a, b) => {
+ const ra = LEVEL_RANK[a.risk_level] ?? 9;
+ const rb = LEVEL_RANK[b.risk_level] ?? 9;
+ if (ra !== rb) return ra - rb;
+ return (b.priority_score || 0) - (a.priority_score || 0);
+ });
+ $('risk-count').textContent = `(本页 ${RISK_ITEMS.length} 条)`;
+
+ if (!RISK_ITEMS.length) {
+ box.innerHTML = '没有符合条件的预警。可调整筛选条件或点「手动扫描」。
';
+ } else {
+ box.innerHTML = `
+ | 预警号 | 客户 |
+ 等级 | 规则 |
+ 处置状态 | 回执 |
+ 摘要 | 操作 |
`
+ + RISK_ITEMS.map((a) => {
+ const no = esc(a.alert_no || '');
+ const lv = esc(a.risk_level || '');
+ const rules = (a.rule_codes || []).join(', ');
+ const ack = a.ack_status === '已确认' ? '已确认' : '未确认';
+ const escMark = a.is_escalated ? '已升级' : '';
+ return `
+ ${no} |
+ ${esc(a.customer_name || a.customer_no || '')} |
+ ${lv} |
+ ${esc(rules)} |
+ ${esc(a.status || '')} |
+ ${esc(ack)} ${escMark} |
+ ${esc(a.evidence_summary || '')} |
+
+
+
+ |
`;
+ }).join('')
+ + '
';
+ }
+ $('pg-prev').disabled = RISK_CURSOR_STACK.length === 0;
+ $('pg-next').disabled = !RISK_HAS_MORE;
+ $('pg-info').textContent =
+ `第 ${RISK_CURSOR_STACK.length + 1} 页 · 每页 ${RISK_PAGE_SIZE} 条` + (RISK_HAS_MORE ? ' · 还有下一页' : ' · 已到末页');
+}
+
+function riskNextPage() {
+ if (!RISK_HAS_MORE) return;
+ RISK_CURSOR_STACK.push(RISK_CURSOR);
+ loadAlerts(RISK_CURSOR);
+}
+function riskPrevPage() {
+ if (!RISK_CURSOR_STACK.length) return;
+ const prev = RISK_CURSOR_STACK.pop();
+ loadAlerts(RISK_CURSOR_STACK.length ? prev : null);
+}
+
+function levelPill(lv) {
+ const t = esc(lv || '');
+ return `${t}`;
+}
+
+async function openAlert(no) {
+ const r = await GET(`/api/v1/risk/alerts/${no}`);
+ const verdict = judge(r);
+ if (verdict.failed) { bad(`打开预警详情失败:${verdict.reason}`); return; }
+ const d = (r.body || {}).data || {};
+ const a = d.alert || {};
+ const c = d.customer || {};
+ RISK_DETAIL = a;
+ const ack = a.ack_status === '已确认';
+ const snap = a.evidence_snapshot || {};
+ openModal(`预警详情 ${a.alert_no || no}`, `
+
+ - 预警编号
${esc(a.alert_no)}
+ - 预警类型
- ${esc(a.alert_type)}
+ - 风险等级
- ${levelPill(a.risk_level)} · 优先级分 ${esc(a.priority_score)}
+ - 处置状态
- ${esc(a.status)} ${a.is_escalated ? '(已升级标记)' : ''}
+ - 回执状态
- ${esc(a.ack_status)}${a.ack_at ? ' @ ' + esc(a.ack_at) : ''}
+ - 证据归档
- ${a.evidence_archived ? '已归档' : '未归档'}
+ - 规则
- ${(a.rule_codes || []).map((x) => `${esc(x)}`).join('')}
+ - 到期时间
- ${esc(a.due_at)}
+ - 结案原因
- ${esc(a.close_reason || '—')}
+ - 证据摘要
- ${esc(a.evidence_summary)}
+ - 证据快照
${esc(pretty(snap))}
+ - 客户
- ${esc(c.name)}(${esc(c.customer_no)})· 投资者类型 ${esc(c.investor_type)} ·
+ 行为分 ${esc(c.behavior_score)} · 总资产 ${esc(c.total_asset)}
+ - 风险标签
- ${esc(c.risk_tags || '—')}
+
`,
+ `
+
+
+
+
+ `);
+}
+
+/* 五个处置动作。写操作都走二次确认,误报/升级/结案必填文字。 */
+async function ack(no) {
+ if (!await askConfirm('确认接收', `确认接收预警 ${no}?\n确认后才会进入可处置状态。`, '确认接收')) return;
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/acknowledgements`, body:{} });
+ if (report('确认接收', r, '预警已确认接收')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
+}
+
+async function investigate(no) {
+ if (!await askConfirm('进入调查', `将预警 ${no} 标记为调查中?`, '进入调查')) return;
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/investigations`, body:{} });
+ if (report('进入调查', r, '已进入调查')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
+}
+
+async function exclude(no) {
+ const reason = await askText('关闭误报', '误报理由(必填,1-500 字)',
+ { value: '', maxlength: 500, hint: '本题为必填项,平台会校验。' });
+ if (reason === null) return;
+ if (!reason.trim()) { bad('误报理由不能为空'); return; }
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/exclusions`, body:{ reason: reason.trim() } });
+ if (report('关闭误报', r, '已关闭误报')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
+}
+
+async function escalate(no) {
+ const reason = await askText('升级处理', '升级原因(必填,1-500 字)',
+ { value: '客户风险等级需人工复核', maxlength: 500 });
+ if (reason === null) return;
+ if (!reason.trim()) { bad('升级原因不能为空'); return; }
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/escalations`, body:{ reason: reason.trim() } });
+ if (report('升级处理', r, '预警已升级')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
+}
+
+async function resolveAlert(no) {
+ const resolution = await askText('完成结案', '处理结论(必填,1-500 字)',
+ { value: '已联系客户核实,风险已排除', maxlength: 500 });
+ if (resolution === null) return;
+ if (!resolution.trim()) { bad('处理结论不能为空'); return; }
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/resolutions`, body:{ resolution: resolution.trim() } });
+ if (report('完成结案', r, '预警已完成结案')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
}
async function scanRisk() {
+ if (!await askConfirm('手动扫描', '触发一次风控规则扫描?会产生新的预警与审计记录。', '开始扫描')) return;
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/alerts/scan', body:{} });
- showExtra2('扫描结果', r);
- loadAlerts();
+ if (report('手动扫描', r, '预警扫描完成')) { loadRisk(); loadAlerts(null); }
}
-async function dailyReport() {
- const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/daily-report', body:{} });
- showExtra2('日报结果', r);
+/* 日报:SSE 流式生成 -> 可编辑 -> 多邮箱发送 */
+async function openDailyReport() {
+ openModal('生成风控日报', `
+
+ 使用 POST /api/v1/risk/daily-report/stream(SSE)
+ (尚未开始)
+
+
+
+
+
+
+
+ 发送接口要 recipients / subject / content 三个字段。
`);
}
-async function ack(no) { await act_(no, 'acknowledgements', '确认'); }
-
-async function esc_(no) { // 升级:接口要求 reason(1-500 字)
- const reason = prompt('升级原因(必填,最多 500 字):', '客户风险等级需人工复核');
- if (reason === null) return;
- if (!reason.trim()) return alert('升级原因不能为空');
- await act_(no, 'escalations', '升级', { reason: reason.slice(0, 500) });
+async function streamDailyReport() {
+ const box = $('dr-stream');
+ box.textContent = '';
+ try {
+ const resp = await fetch('/api/call', {
+ method: 'POST', headers: HEAD(),
+ body: JSON.stringify({ method:'POST', path:'/api/v1/risk/daily-report/stream', body:{} }),
+ });
+ const payload = await resp.json();
+ const raw = ((payload.body || {})._raw) || '';
+ if (!raw) {
+ box.textContent = pretty(payload.body);
+ const verdict = judge(payload);
+ if (verdict.failed) bad('日报生成失败:' + verdict.reason);
+ return;
+ }
+ // 逐条解析 SSE:event: xxx / data: {...}
+ let content = '';
+ raw.split('\n').forEach((line) => {
+ if (line.startsWith('data:')) {
+ try {
+ const obj = JSON.parse(line.slice(5).trim());
+ if (obj.type === 'replace' && obj.content) content = obj.content;
+ else if (obj.content) content += obj.content;
+ if (obj.message) box.textContent += `[${obj.stage || obj.type}] ${obj.message}\n`;
+ } catch { /* 忽略非 JSON 行 */ }
+ } else if (line.startsWith('event:')) {
+ box.textContent += `── ${line.slice(6).trim()} ──\n`;
+ }
+ });
+ if (content) { $('dr-content').value = content; ok('日报生成完成'); }
+ else warn('日报流已结束,但没有解析到内容');
+ } catch (err) {
+ bad('日报流式请求失败:' + err.message);
+ }
}
-async function resolve(no) { // 解决:字段名是 resolution,不是 reason
- const resolution = prompt('处理结论(必填,最多 500 字):', '已联系客户核实,风险已排除');
- if (resolution === null) return;
- if (!resolution.trim()) return alert('处理结论不能为空');
- await act_(no, 'resolutions', '解决', { resolution: resolution.slice(0, 500) });
+async function sendDailyReport() {
+ const content = $('dr-content').value.trim();
+ const subject = $('dr-subject').value.trim();
+ const to = $('dr-to').value.split(',').map((x) => x.trim()).filter(Boolean);
+ if (!content) { bad('日报内容为空,先生成或填写'); return; }
+ if (!to.length) { bad('请至少填一个收件人'); return; }
+ if (!await askConfirm('发送日报', `发送给 ${to.join(', ')}?`, '发送')) return;
+ const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/daily-report/mail',
+ body:{ recipients: to, subject, content } });
+ report('邮件发送', r, '日报发送成功');
}
-async function act_(no, action, label, body) {
- if (!confirm(`对预警 ${no} 执行「${label}」?这会写审计。`)) return;
- const r = await jpost('/api/call', { method:'POST',
- path: `/api/v1/risk/alerts/${no}/${action}`, body: body || {} });
- showExtra2(`${label} ${no}`, r);
- loadAlerts();
+/* 八类证据 */
+async function openEvidence() {
+ openModal('八类证据', `
+ ${EVIDENCE_SOURCES.map(([k, label], i) =>
+ ``).join('')}
+
+
+
+
+
+
+
+
+ `,
+ '');
+ loadEvidence('customers');
}
-function showExtra2(title, r) {
- $('staff-extra').innerHTML = `${esc(title)}
- HTTP ${r.status}${r.status === 403 ? ' —— 权限不足(fail closed)' : ''}
- ${esc(pretty(r.body))}`;
+let EV_SOURCE = 'customers';
+async function loadEvidence(source, btn) {
+ EV_SOURCE = source;
+ if (btn) {
+ document.querySelectorAll('#ev-tabs button').forEach((b) => b.classList.remove('on'));
+ btn.classList.add('on');
+ }
+ const q = {};
+ ['keyword', 'behavior_level', 'send_status', 'start_time', 'end_time'].forEach((k) => {
+ const v = ($('ev-' + k) || {}).value;
+ if (v && v.trim()) q[k] = v.trim();
+ });
+ const r = await GET(`/api/v1/risk/evidence/${source}`, q);
+ const verdict = judge(r);
+ const box = $('ev-body');
+ if (verdict.failed) { box.innerHTML = `加载失败:${esc(verdict.reason)}
`; return; }
+ const data = (r.body || {}).data;
+ const items = Array.isArray(data) ? data : ((data || {}).items || []);
+ if (!items.length) { box.innerHTML = '该类证据暂无数据。
'; return; }
+ const cols = Object.keys(items[0]);
+ box.innerHTML = `${items.length} 条 · 来源
+ ${esc(source)}
+ ${cols.map((c) => `| ${esc(c)} | `).join('')}
`
+ + items.slice(0, 30).map((row) => '' + cols.map((c) => {
+ const v = row[c];
+ const text = (v === null || v === undefined) ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
+ return `| ${esc(text)} | `;
+ }).join('') + '
').join('')
+ + '
';
+}
+
+function reloadEvidence() { loadEvidence(EV_SOURCE); }
+
+/* 通知记录 */
+async function openNotifications() {
+ openModal('通知记录', '',
+ '');
+ const r = await GET('/api/v1/risk/notifications');
+ const verdict = judge(r);
+ if (verdict.failed) { $('nt-body').innerHTML = `加载失败:${esc(verdict.reason)}
`; return; }
+ const data = (r.body || {}).data;
+ const items = Array.isArray(data) ? data : ((data || {}).items || []);
+ $('nt-body').innerHTML = items.length
+ ? `| 预警编号 | 类型 | 发送状态 | 时间 |
`
+ + items.map((n) => `${esc(n.alert_no ?? '')} |
+ ${esc(n.notification_type ?? n.type ?? '')} |
+ ${esc(n.send_status ?? n.status ?? '')} |
+ ${esc(n.created_at ?? n.sent_at ?? '')} |
`).join('')
+ + '
'
+ : '暂无通知记录。
';
}
/* ---------------- 员工 · 运营工作台(场外基金) ---------------- */
@@ -904,20 +1383,20 @@ async function recoverMailbox() {
body:{ operator_id: myId() } }));
}
async function docFields() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('识别字段 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/nl2sql-fields`));
}
async function docRules() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('规则结果 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/rule-results`));
}
async function docConfirm() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
// decision 是**中文枚举**:确认无误 / 确认异常 / 未处理
const decision = prompt('确认结论(确认无误 / 确认异常 / 未处理):', '确认无误');
if (decision === null) return;
if (['确认无误', '确认异常', '未处理'].indexOf(decision) < 0) {
- return alert('只能是:确认无误 / 确认异常 / 未处理');
+ return bad('只能是:确认无误 / 确认异常 / 未处理');
}
if (!confirm(`对单据 ${t} 提交「${decision}」?这是写操作,会进审计。`)) return;
showOffsite('确认单据 ' + t, await jpost('/api/call',
@@ -925,13 +1404,13 @@ async function docConfirm() {
body:{ decision, operator_id: myId() } }));
}
async function docRetry() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('重试识别 ' + t, await jpost('/api/call',
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`,
body:{ operator_id: myId() } }));
}
async function docNotify() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
// notification_type 取值:risk / settlement / mail_return / normal_return / exception_return…
const type = prompt('通知类型(risk / settlement / mail_return / normal_return / exception_return):',
'normal_return');
@@ -947,7 +1426,7 @@ async function settle() {
if (fund === null) return;
const date = prompt('申请日期 application_date(YYYY-MM-DD):', '');
if (date === null) return;
- if (!fund.trim() || !date.trim()) return alert('基金代码与申请日期都必填');
+ if (!fund.trim() || !date.trim()) return bad('基金代码与申请日期都必填');
if (!confirm(`重算 ${fund} 在 ${date} 的结算统计?这是写操作。`)) return;
showOffsite('结算重算', await jpost('/api/call',
{ method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate',
@@ -1100,7 +1579,7 @@ async function loadKnowledge() {
async function admPromoLookup() {
const t = $('promo-task').value.trim();
- if (!t) return alert('请填任务单号');
+ if (!t) return bad('请填任务单号');
const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
const mv = ((r.body || {}).data || {}).material_version || {};
if (mv.id) $('adm-version').value = mv.id;
@@ -1112,8 +1591,8 @@ async function admPromoLookup() {
async function admReview(decision) {
const t = $('promo-task').value.trim();
const vid = parseInt($('adm-version').value.trim(), 10);
- if (!t) return alert('请填任务单号');
- if (!vid) return alert('请填 material_version_id(可先点「查任务」自动填)');
+ if (!t) return bad('请填任务单号');
+ if (!vid) return bad('请填 material_version_id(可先点「查任务」自动填)');
const comment = $('adm-comment').value.trim() || null;
if (!confirm(`对 ${t} 的版本 ${vid} 执行「${decision}」?`)) return;
const r = await jpost('/api/call', { method:'POST',
@@ -1221,7 +1700,7 @@ function promoSkeleton() {
function promoTaskNo() {
const t = $('promo-task').value.trim();
- if (!t) { alert('请先填任务单号(创建任务后会返回)'); return null; }
+ if (!t) { bad('请先填任务单号(创建任务后会返回)'); return null; }
return t;
}
@@ -1229,7 +1708,7 @@ async function promoCreate() {
const name = $('promo-name').value.trim();
const title = $('promo-title').value.trim();
const style = $('promo-style').value.trim();
- if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填');
+ if (!name || !title || !style) return bad('产品名、材料标题、风格代码都要填');
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/fund-promotion-materials',
body:{ product_name: name, material_title: title, style_code: style } });
const d = (r.body || {}).data || {};
@@ -1241,7 +1720,7 @@ async function promoSaveInputs() {
const t = promoTaskNo(); if (!t) return;
let body;
try { body = JSON.parse($('promo-inputs').value); }
- catch (e) { return alert('结构化输入不是合法 JSON:' + e.message); }
+ catch (e) { return bad('结构化输入不是合法 JSON:' + e.message); }
const r = await jpost('/api/call', { method:'PUT',
path:`/api/v1/fund-promotion-materials/${t}/inputs`, body });
showAdv('② 保存结构化输入 ' + t, r);
@@ -1272,9 +1751,9 @@ async function promoGenerate(fmt) {
async function promoDeliver() {
const t = promoTaskNo(); if (!t) return;
const versionId = parseInt($('promo-version').value.trim(), 10);
- if (!versionId) return alert('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
+ if (!versionId) return bad('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
const ids = $('promo-advisors').value.split(',').map((x) => parseInt(x.trim(), 10)).filter((x) => x);
- if (!ids.length) return alert('请填要投递的投顾 id');
+ if (!ids.length) return bad('请填要投递的投顾 id');
if (!confirm(`把版本 ${versionId} 投递给投顾 ${ids.join(', ')}?`)) return;
const r = await jpost('/api/call', { method:'POST',
path:`/api/v1/fund-promotion-materials/${t}/deliveries`,
@@ -1326,19 +1805,19 @@ function result(title, r) {
const code = body.code;
const err = body.error || {};
const biz = (code === undefined || code === null) ? null : Number(code);
- const bad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code;
+ const isBad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code;
const bits = [`HTTP ${r.status}`];
if (biz !== null) bits.push(`body.code ${biz}`);
if (err.code) bits.push(`error ${esc(err.code)}`);
let extra = '';
if (err.message) extra = esc(err.message);
- else if (bad && body.message) extra = esc(body.message);
- if (!bad) {
+ else if (isBad && body.message) extra = esc(body.message);
+ if (!isBad) {
if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)';
else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)';
}
return `${esc(title)}
- ${bits.join(' · ')}${extra ? ' —— ' + extra : ''}
+ ${bits.join(' · ')}${extra ? ' —— ' + extra : ''}
${esc(pretty(body))}`;
}