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:
+577
-98
@@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user