From 6155f4589e503a8c2a9410c1d2b67c88b030bdde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Sat, 12 Sep 2026 16:30:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal):=20=E6=8C=89=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=90=88=E5=B9=B6=E7=BA=A6=E6=9D=9F=E9=87=8D?= =?UTF-8?q?=E5=86=99=E9=A3=8E=E6=8E=A7=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 概览五指标(总量/等级/待处理/超时/重点预警) - 预警队列:每页 5 条、风险等级优先、8 项筛选、游标分页、稳定行高与省略号 - 预警详情弹窗:23 个 alert 字段 + 客户信息 + 五个处置动作 - 处置:确认接收(二次确认)/进入调查/关闭误报(必填理由)/结案(必填结论)/升级(必填原因) - 八类证据(customers/products/transactions/capital_flows/holdings/login_records/ alerts/notifications)+ 五项筛选;注意是 holdings 不是 positions - 通知记录、日报 SSE 流式生成 + 内容编辑 + 多邮箱发送 - 新增 Toast + 模态框,替换全部 17 处原生 alert(17- 文档明令禁止原生弹窗) - 风险等级筛选值改用 高/中/低(预警对象用「高」,概览 levels 用「高风险」,口径不一致) --- tools/portal.py | 675 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 577 insertions(+), 98 deletions(-) diff --git a/tools/portal.py b/tools/portal.py index cd5ee8e..fac6d14 100644 --- a/tools/portal.py +++ b/tools/portal.py @@ -460,6 +460,61 @@ PAGE = r""" 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; } @@ -481,6 +536,15 @@ PAGE = r""" +
+ +
基金智能服务平台 @@ -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 || ''; + $('modal-back').classList.add('on'); +} +function closeModal() { $('modal-back').classList.remove('on'); } + +/** 替代 confirm:返回 Promise。 */ +function askConfirm(title, text, okLabel) { + return new Promise((resolve) => { + openModal(title, `

${esc(text)}

`, + ` + `); + window.__ask = resolve; + }); +} + +/** 替代 prompt:返回 Promise。 */ +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。

+
…
加载中
- - - + + + + + +
+
-

预警列表

-

操作有业务顺序:必须先点「确认」接收预警,才能「升级」或「解决」—— - 顺序不对会返回 409 请先确认接收预警。 - 这三个都是真实写操作,会在审计留痕;升级要填原因、解决要填处理结论(各 1-500 字)。

+

预警队列

+

固定每页 ${RISK_PAGE_SIZE} 条、按风险等级优先。**排序在页内进行** —— + 接口本身不支持排序参数,跨页整体排序需要服务端支持。
+ ⚠️ 风险等级筛选值必须用 高 / 中 / 低:预警对象的 risk_level 是「高」, + 而概览的 levels 键是「高风险」—— 平台两处口径不一致,用「高风险」筛不出任何结果。

+
+ + + + + + + + + + +
加载中…
+
+ + + +
`; 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]) => `
${esc(v)}
${esc(k)}
`).join('') - : `
${r.status}
总览返回(详见下方)
`; - 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 = `
—
${esc(verdict.reason)}
`; 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 ` - - - - - - `; - }).join('') - + '
预警号客户等级规则状态操作
${no}${esc(a.customer_name || a.customer_no || a.customer_id || '')}${esc(a.risk_level ?? a.level ?? '')}${esc(rules)}${esc(a.status ?? '')} - - - -
'; + 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 ` + + + + + + + + `; + }).join('') + + '
预警号客户等级规则处置状态回执摘要操作
${no}${esc(a.customer_name || a.customer_no || '')}${lv}${esc(rules)}${esc(a.status || '')}${esc(ack)} ${escMark}${esc(a.evidence_summary || '')} + + +
'; + } + $('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) => ``).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 ``; + }).join('') + '').join('') + + '
${esc(c)}
${esc(text)}
'; +} + +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) => ` + + + `).join('') + + '
预警编号类型发送状态时间
${esc(n.alert_no ?? '')}${esc(n.notification_type ?? n.type ?? '')}${esc(n.send_status ?? n.status ?? '')}${esc(n.created_at ?? n.sent_at ?? '')}
' + : '
暂无通知记录。
'; } /* ---------------- 员工 · 运营工作台(场外基金) ---------------- */ @@ -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))}
`; }