feat(portal): 按风控前端合并约束重写风控工作台

- 概览五指标(总量/等级/待处理/超时/重点预警)
- 预警队列:每页 5 条、风险等级优先、8 项筛选、游标分页、稳定行高与省略号
- 预警详情弹窗:23 个 alert 字段 + 客户信息 + 五个处置动作
- 处置:确认接收(二次确认)/进入调查/关闭误报(必填理由)/结案(必填结论)/升级(必填原因)
- 八类证据(customers/products/transactions/capital_flows/holdings/login_records/
  alerts/notifications)+ 五项筛选;注意是 holdings 不是 positions
- 通知记录、日报 SSE 流式生成 + 内容编辑 + 多邮箱发送
- 新增 Toast + 模态框,替换全部 17 处原生 alert(17- 文档明令禁止原生弹窗)
- 风险等级筛选值改用 高/中/低(预警对象用「高」,概览 levels 用「高风险」,口径不一致)
This commit is contained in:
2026-09-12 16:30:54 +08:00
parent cb13f9cf45
commit 6155f4589e
+577 -98
View File
@@ -460,6 +460,61 @@ PAGE = r"""<!DOCTYPE html>
input.q { padding: 6px 9px; border: 1px solid #c9d2dd; border-radius: 4px; font: inherit; font-size: 13px; }
.muted { color: #8fa0b5; font-size: 12.5px; }
.hidden { display: none; }
/* 表格:行高固定、超长省略(17- 文档要求) */
table.fixed { table-layout: fixed; }
table.fixed td, table.fixed th { height: 30px; line-height: 30px; padding: 0 9px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
table.fixed td.wrap { white-space: normal; line-height: 1.5; height: auto; padding: 6px 9px; }
/* 筛选栏 */
.filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end;
background: #f7f9fc; border: 1px solid #e6ecf3; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; }
.filters label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #5a6b7f; }
.filters input, .filters select { padding: 5px 8px; border: 1px solid #c9d2dd;
border-radius: 4px; font: inherit; font-size: 13px; min-width: 120px; }
/* 分页 */
.pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12.5px;
color: #5a6b7f; }
/* 风险等级徽章 */
.lv { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11.5px; }
.lv-高, .lv-high { background: #fdecea; color: #a8342a; }
.lv-中, .lv-medium { background: #fff4e5; color: #8a5a00; }
.lv-低, .lv-low { background: #e8eef6; color: #44536a; }
/* Toast */
#toasts { position: fixed; right: 18px; bottom: 18px; z-index: 9999;
display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
.toast { min-width: 240px; max-width: 420px; padding: 10px 14px; border-radius: 6px;
box-shadow: 0 4px 16px rgba(31,45,61,.18); font-size: 13px; line-height: 1.6;
background: #fff; border-left: 4px solid #1f6feb; white-space: pre-wrap; }
.toast.ok { border-left-color: #1a7f37; }
.toast.bad { border-left-color: #c0392b; }
.toast.warn { border-left-color: #e0a800; }
.toast b { display: block; margin-bottom: 2px; }
/* 模态框 */
#modal-back { position: fixed; inset: 0; background: rgba(15,23,32,.45); z-index: 9998;
display: none; align-items: center; justify-content: center; padding: 20px; }
#modal-back.on { display: flex; }
.modal { background: #fff; border-radius: 8px; width: min(860px, 100%);
max-height: 88vh; display: flex; flex-direction: column;
box-shadow: 0 12px 40px rgba(15,23,32,.3); }
.modal header { background: #fff; color: #1f2d3d; border-bottom: 1px solid #e6ecf3;
padding: 13px 18px; font-size: 15px; font-weight: 600; display: flex;
align-items: center; }
.modal .body { padding: 16px 18px; overflow: auto; }
.modal .foot { padding: 12px 18px; border-top: 1px solid #e6ecf3; display: flex;
gap: 8px; justify-content: flex-end; }
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 6px 12px; font-size: 13px; }
.kv dt { color: #6b7c93; }
.kv dd { margin: 0; word-break: break-all; }
.modal textarea { width: 100%; min-height: 88px; padding: 8px; border: 1px solid #c9d2dd;
border-radius: 4px; font: inherit; font-size: 13px; resize: vertical; }
.tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.tabs button { padding: 5px 11px; font-size: 12.5px; border: 1px solid #c9d2dd;
background: #fff; border-radius: 14px; cursor: pointer; }
.tabs button.on { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.stream { background: #0d1117; color: #d6e2f0; padding: 12px; border-radius: 6px;
font-family: Consolas, monospace; font-size: 12.5px; white-space: pre-wrap;
max-height: 300px; overflow: auto; line-height: 1.6; }
</style>
</head>
<body>
@@ -481,6 +536,15 @@ PAGE = r"""<!DOCTYPE html>
</div>
</div>
<div id="toasts"></div>
<div id="modal-back" onclick="if(event.target===this)closeModal()">
<div class="modal">
<header><span id="modal-title">标题</span></header>
<div class="body" id="modal-body"></div>
<div class="foot" id="modal-foot"></div>
</div>
</div>
<div id="app">
<header>
<b>基金智能服务平台</b>
@@ -515,6 +579,123 @@ async function jpost(url, body) {
return r.json();
}
/* ---------------- 通用交互组件 ----------------
17- 文档明确要求:不使用浏览器原生 alert/prompt 作为正式交互方案,
不能只用控制台日志代替用户提示。这里用 Toast + 模态框替代。 */
function toast(text, kind) {
const box = $('toasts');
const el = document.createElement('div');
el.className = 'toast ' + (kind || '');
el.textContent = text;
box.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 3200);
setTimeout(() => el.remove(), 3600);
}
function ok(text) { toast(text, 'ok'); }
function bad(text) { toast(text, 'bad'); }
function warn(text) { toast(text, 'warn'); }
function openModal(title, bodyHtml, footHtml) {
$('modal-title').textContent = title;
$('modal-body').innerHTML = bodyHtml;
$('modal-foot').innerHTML = footHtml || '<button class="act" onclick="closeModal()">关闭</button>';
$('modal-back').classList.add('on');
}
function closeModal() { $('modal-back').classList.remove('on'); }
/** 替代 confirm:返回 Promise<boolean>。 */
function askConfirm(title, text, okLabel) {
return new Promise((resolve) => {
openModal(title, `<p style="font-size:13.5px;line-height:1.8">${esc(text)}</p>`,
`<button class="act" onclick="closeModal();window.__ask(false)">取消</button>
<button class="act primary" onclick="closeModal();window.__ask(true)">${esc(okLabel || '确定')}</button>`);
window.__ask = resolve;
});
}
/** 替代 prompt:返回 Promise<string|null>。 */
function askText(title, label, options) {
const opts = options || {};
if (opts.choices) {
return new Promise((resolve) => {
const sel = opts.choices.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
openModal(title,
`<label style="font-size:12.5px;color:#5a6b7f">${esc(label)}</label>
<select id="ask-value" style="width:100%;margin-top:6px;padding:7px;border:1px solid #c9d2dd;border-radius:4px;font:inherit">${sel}</select>`,
`<button class="act" onclick="closeModal();window.__ask(null)">取消</button>
<button class="act primary" onclick="closeModal();window.__ask(document.getElementById('ask-value').value)">提交</button>`);
window.__ask = resolve;
});
}
return new Promise((resolve) => {
openModal(title,
`<label style="font-size:12.5px;color:#5a6b7f">${esc(label)}</label>
<textarea id="ask-value" maxlength="${opts.maxlength || 500}">${esc(opts.value || '')}</textarea>
<div class="muted" style="margin-top:4px">${esc(opts.hint || '')}</div>`,
`<button class="act" onclick="closeModal();window.__ask(null)">取消</button>
<button class="act primary" onclick="closeModal();window.__ask(document.getElementById('ask-value').value)">提交</button>`);
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) {
<pre>${esc(pretty(r.body))}</pre>`;
}
/* ---------------- 员工 · 风控工作台 ---------------- */
/* ---------------- 员工 · 风控工作台 ----------------
按 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 = `
<div class="panel">
<h2>风控工作台</h2>
<p class="hint">只对 <code>risk_operator</code> / <code>operator</code> 开放。数据范围 <code>all</code>:
能看到全部客户的预警。处置类操作会写审计。</p>
<div class="grid" id="overview"><div class="stat"><div class="n">…</div><div class="l">加载中</div></div></div>
<h2>风险概览</h2>
<p class="hint">数据范围 <code>all</code>(风控专员可看全部客户)。五个指标取自
<code>GET /api/v1/risk/overview</code>。</p>
<div class="grid" id="risk-overview"><div class="stat"><div class="n">…</div><div class="l">加载中</div></div></div>
<div class="row" style="margin-top:14px">
<button class="act primary" onclick="loadRisk()">刷新总览</button>
<button class="act warn" onclick="scanRisk()">触发一次扫描</button>
<button class="act" onclick="dailyReport()">生成日报</button>
<button class="act primary" onclick="loadRisk()">刷新概览</button>
<button class="act" onclick="loadAlerts(null)">刷新队列</button>
<button class="act warn" onclick="scanRisk()">手动扫描</button>
<button class="act" onclick="openDailyReport()">生成日报</button>
<button class="act" onclick="openNotifications()">通知记录</button>
<button class="act" onclick="openEvidence()">八类证据</button>
</div>
</div>
<div class="panel">
<h2>预警列表</h2>
<p class="hint">操作有<b>业务顺序</b>:必须先点「确认」接收预警,才能「升级」或「解决」——
顺序不对会返回 <code>409 请先确认接收预警</code>。
这三个都是真实写操作,会在审计留痕;升级要填原因、解决要填处理结论(各 1-500 字)。</p>
<h2>预警队列<span class="muted" id="risk-count"></span></h2>
<p class="hint">固定每页 ${RISK_PAGE_SIZE} 条、按风险等级优先。**排序在页内进行** ——
接口本身不支持排序参数,跨页整体排序需要服务端支持。<br>
⚠️ 风险等级筛选值必须用 <b>高 / 中 / 低</b>:预警对象的 <code>risk_level</code> 是「高」,
而概览的 <code>levels</code> 键是「高风险」—— 平台两处口径不一致,用「高风险」筛不出任何结果。</p>
<div class="filters">
<label>关键词<input id="f-keyword" placeholder="预警号/摘要"></label>
<label>客户号<input id="f-customer_no" placeholder="T-CUST"></label>
<label>风险等级<select id="f-risk_level">
<option value="">全部</option>
<option value="高">高</option>
<option value="中">中</option>
<option value="低">低</option>
</select></label>
<label>规则码<input id="f-rule_code" placeholder="RW-015"></label>
<label>产品代码<input id="f-product_code"></label>
<label>产品名<input id="f-product_name"></label>
<label>开始时间<input id="f-start_time" placeholder="2026-09-01"></label>
<label>结束时间<input id="f-end_time" placeholder="2026-09-30"></label>
<button class="act primary" onclick="loadAlerts(null)">查询</button>
<button class="act" onclick="resetRiskFilters()">重置</button>
</div>
<div id="alerts"><div class="muted">加载中…</div></div>
<div class="pager">
<button class="act" id="pg-prev" onclick="riskPrevPage()">上一页</button>
<button class="act" id="pg-next" onclick="riskNextPage()">下一页</button>
<span id="pg-info"></span>
</div>
</div>
<div class="panel" id="staff-extra"></div>`;
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]) => `<div class="stat"><div class="n">${esc(v)}</div><div class="l">${esc(k)}</div></div>`).join('')
: `<div class="stat"><div class="n">${r.status}</div><div class="l">总览返回(详见下方)</div></div>`;
if (!entries.length) $('staff-extra').innerHTML =
`<h2>总览原始返回</h2><pre>${esc(pretty(r.body))}</pre>`;
}
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 = `<div class="muted">没有预警数据(HTTP ${r.status}${r.body?.code ? ' code=' + r.body.code : ''})。可先点「触发一次扫描」。</div>
<pre>${esc(pretty(r.body))}</pre>`;
const d = (r.body || {}).data || {};
const verdict = judge(r);
if (verdict.failed) {
$('risk-overview').innerHTML = `<div class="stat"><div class="n">—</div><div class="l">${esc(verdict.reason)}</div></div>`;
return;
}
box.innerHTML = `<table><thead><tr><th>预警号</th><th>客户</th><th>等级</th><th>规则</th><th>状态</th><th>操作</th></tr></thead><tbody>`
+ 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 `<tr>
<td><code>${no}</code></td>
<td>${esc(a.customer_name || a.customer_no || a.customer_id || '')}</td>
<td>${esc(a.risk_level ?? a.level ?? '')}</td>
<td>${esc(rules)}</td>
<td>${esc(a.status ?? '')}</td>
<td>
<button class="act" onclick="ack('${no}')">确认</button>
<button class="act warn" onclick="esc_('${no}')">升级</button>
<button class="act primary" onclick="resolve('${no}')">解决</button>
</td></tr>`;
}).join('')
+ '</tbody></table>';
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]) =>
`<div class="stat"><div class="n">${esc(value ?? '—')}</div><div class="l">${esc(label)}</div></div>`).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 = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`;
$('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 = '<div class="muted">没有符合条件的预警。可调整筛选条件或点「手动扫描」。</div>';
} else {
box.innerHTML = `<table class="fixed"><thead><tr>
<th style="width:112px">预警号</th><th style="width:110px">客户</th>
<th style="width:74px">等级</th><th style="width:150px">规则</th>
<th style="width:80px">处置状态</th><th style="width:70px">回执</th>
<th>摘要</th><th style="width:150px">操作</th></tr></thead><tbody>`
+ 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 ? '<span class="lv lv-高">已升级</span>' : '';
return `<tr>
<td><code>${no}</code></td>
<td>${esc(a.customer_name || a.customer_no || '')}</td>
<td><span class="lv lv-${lv}">${lv}</span></td>
<td title="${esc(rules)}">${esc(rules)}</td>
<td>${esc(a.status || '')}</td>
<td>${esc(ack)} ${escMark}</td>
<td title="${esc(a.evidence_summary || '')}">${esc(a.evidence_summary || '')}</td>
<td>
<button class="act" onclick="openAlert('${no}')">详情</button>
<button class="act" onclick="ack('${no}')">确认</button>
</td></tr>`;
}).join('')
+ '</tbody></table>';
}
$('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 `<span class="lv lv-${t}">${t}</span>`;
}
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}`, `
<dl class="kv">
<dt>预警编号</dt><dd><code>${esc(a.alert_no)}</code></dd>
<dt>预警类型</dt><dd>${esc(a.alert_type)}</dd>
<dt>风险等级</dt><dd>${levelPill(a.risk_level)} · 优先级分 ${esc(a.priority_score)}</dd>
<dt>处置状态</dt><dd>${esc(a.status)} ${a.is_escalated ? '(已升级标记)' : ''}</dd>
<dt>回执状态</dt><dd>${esc(a.ack_status)}${a.ack_at ? ' @ ' + esc(a.ack_at) : ''}</dd>
<dt>证据归档</dt><dd>${a.evidence_archived ? '已归档' : '未归档'}</dd>
<dt>规则</dt><dd>${(a.rule_codes || []).map((x) => `<span class="pill">${esc(x)}</span>`).join('')}</dd>
<dt>到期时间</dt><dd>${esc(a.due_at)}</dd>
<dt>结案原因</dt><dd>${esc(a.close_reason || '—')}</dd>
<dt>证据摘要</dt><dd>${esc(a.evidence_summary)}</dd>
<dt>证据快照</dt><dd><pre style="margin:0">${esc(pretty(snap))}</pre></dd>
<dt>客户</dt><dd>${esc(c.name)}(${esc(c.customer_no)})· 投资者类型 ${esc(c.investor_type)} ·
行为分 ${esc(c.behavior_score)} · 总资产 ${esc(c.total_asset)}</dd>
<dt>风险标签</dt><dd>${esc(c.risk_tags || '—')}</dd>
</dl>`,
`<button class="act" onclick="ack('${esc(a.alert_no)}')" ${ack ? 'disabled' : ''}>确认接收</button>
<button class="act" onclick="investigate('${esc(a.alert_no)}')">进入调查</button>
<button class="act warn" onclick="exclude('${esc(a.alert_no)}')">关闭误报</button>
<button class="act" onclick="escalate('${esc(a.alert_no)}')">升级</button>
<button class="act primary" onclick="resolveAlert('${esc(a.alert_no)}')">完成结案</button>
<button class="act" onclick="closeModal()">关闭</button>`);
}
/* 五个处置动作。写操作都走二次确认,误报/升级/结案必填文字。 */
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('生成风控日报', `
<div class="row"><button class="act primary" onclick="streamDailyReport()">开始流式生成</button>
<span class="muted">使用 <code>POST /api/v1/risk/daily-report/stream</code>(SSE)</span></div>
<div class="stream" id="dr-stream">(尚未开始)</div>
<label style="display:block;margin-top:12px;font-size:12.5px;color:#5a6b7f">日报内容(可编辑后再发送)</label>
<textarea id="dr-content" style="min-height:200px"></textarea>
<div class="row" style="margin-top:10px">
<input class="q" id="dr-subject" value="风控预警日报" placeholder="邮件主题" style="width:220px">
<input class="q" id="dr-to" placeholder="收件人,逗号分隔" style="width:280px">
<button class="act primary" onclick="sendDailyReport()">发送日报</button>
</div>
<div class="muted">发送接口要 <code>recipients</code> / <code>subject</code> / <code>content</code> 三个字段。</div>`);
}
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('八类证据', `
<div class="tabs" id="ev-tabs">${EVIDENCE_SOURCES.map(([k, label], i) =>
`<button class="${i === 0 ? 'on' : ''}" onclick="loadEvidence('${k}', this)">${esc(label)}</button>`).join('')}</div>
<div class="filters">
<label>关键词<input id="ev-keyword"></label>
<label>行为分等级<input id="ev-behavior_level" placeholder="如 关注"></label>
<label>发送状态<input id="ev-send_status" placeholder="如 成功"></label>
<label>开始时间<input id="ev-start_time" placeholder="2026-09-01"></label>
<label>结束时间<input id="ev-end_time" placeholder="2026-09-30"></label>
<button class="act primary" onclick="reloadEvidence()">查询</button>
</div>
<div id="ev-body"><div class="muted">加载中…</div></div>`,
'<button class="act" onclick="closeModal()">关闭</button>');
loadEvidence('customers');
}
function showExtra2(title, r) {
$('staff-extra').innerHTML = `<h2>${esc(title)}</h2>
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 权限不足(fail closed)' : ''}</p>
<pre>${esc(pretty(r.body))}</pre>`;
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 = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`; return; }
const data = (r.body || {}).data;
const items = Array.isArray(data) ? data : ((data || {}).items || []);
if (!items.length) { box.innerHTML = '<div class="muted">该类证据暂无数据。</div>'; return; }
const cols = Object.keys(items[0]);
box.innerHTML = `<div class="muted" style="margin-bottom:6px">${items.length} 条 · 来源
<code>${esc(source)}</code></div>
<table class="fixed"><thead><tr>${cols.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>`
+ items.slice(0, 30).map((row) => '<tr>' + cols.map((c) => {
const v = row[c];
const text = (v === null || v === undefined) ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
return `<td title="${esc(text)}">${esc(text)}</td>`;
}).join('') + '</tr>').join('')
+ '</tbody></table>';
}
function reloadEvidence() { loadEvidence(EV_SOURCE); }
/* 通知记录 */
async function openNotifications() {
openModal('通知记录', '<div id="nt-body"><div class="muted">加载中…</div></div>',
'<button class="act" onclick="closeModal()">关闭</button>');
const r = await GET('/api/v1/risk/notifications');
const verdict = judge(r);
if (verdict.failed) { $('nt-body').innerHTML = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`; return; }
const data = (r.body || {}).data;
const items = Array.isArray(data) ? data : ((data || {}).items || []);
$('nt-body').innerHTML = items.length
? `<table class="fixed"><thead><tr><th>预警编号</th><th>类型</th><th>发送状态</th><th>时间</th></tr></thead><tbody>`
+ items.map((n) => `<tr><td><code>${esc(n.alert_no ?? '')}</code></td>
<td>${esc(n.notification_type ?? n.type ?? '')}</td>
<td>${esc(n.send_status ?? n.status ?? '')}</td>
<td>${esc(n.created_at ?? n.sent_at ?? '')}</td></tr>`).join('')
+ '</tbody></table>'
: '<div class="muted">暂无通知记录。</div>';
}
/* ---------------- 员工 · 运营工作台(场外基金) ---------------- */
@@ -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 <b>${r.status}</b>`];
if (biz !== null) bits.push(`body.code <b>${biz}</b>`);
if (err.code) bits.push(`error <b>${esc(err.code)}</b>`);
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 `<h2>${esc(title)}</h2>
<p class="hint ${bad ? 'bad' : ''}">${bits.join(' · ')}${extra ? ' —— ' + extra : ''}</p>
<p class="hint ${isBad ? 'bad' : ''}">${bits.join(' · ')}${extra ? ' —— ' + extra : ''}</p>
<pre>${esc(pretty(body))}</pre>`;
}