From 4f75d32ea14865da17ea0c738eee3bce26097dd9 Mon Sep 17 00:00:00 2001 From: zhangshy <994452054@qq.com> Date: Sun, 13 Sep 2026 23:19:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A3=8E=E6=8E=A7=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=B1=95=E7=A4=BA=E4=B8=8E=E5=89=8D=E7=AB=AF=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../employee-risk/dashboard/dashboard.css | 59 +- .../employee-risk/dashboard/dashboard.js | 568 ++++++++++++++++-- .../portal/employee-risk/dashboard/index.html | 56 +- .../24-主项目风控前端改造TODO.md | 530 ++++++++++++++++ docs/风控业务演示文档/README.md | 2 + tests/unit/api/test_portal_frontend.py | 57 ++ 6 files changed, 1207 insertions(+), 65 deletions(-) create mode 100644 docs/风控业务演示文档/24-主项目风控前端改造TODO.md diff --git a/app/static/portal/employee-risk/dashboard/dashboard.css b/app/static/portal/employee-risk/dashboard/dashboard.css index f7ec36c..1eb64d2 100644 --- a/app/static/portal/employee-risk/dashboard/dashboard.css +++ b/app/static/portal/employee-risk/dashboard/dashboard.css @@ -10,6 +10,14 @@ .risk-assistant__intro { display: flex; justify-content: space-between; gap: var(--space-4); } .risk-assistant__intro h2 { margin: 0; font-size: var(--fs-title); } .risk-assistant__intro p { margin: var(--space-2) 0 0; color: var(--muted); line-height: 1.65; } +.risk-context-bar { margin-top: var(--space-4); padding: var(--space-3) var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); background: var(--surface-soft); border: 1px solid var(--line); border-radius: var(--radius-md); } +.risk-context-bar span { color: var(--muted); } +.risk-context-bar strong { color: var(--brand-dark); } +.risk-alert-context-form { margin-top: var(--space-3); display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: var(--space-2); align-items: end; } +.risk-alert-context-form .form-alert { grid-column: 1 / -1; } +.agent-boundary { margin-top: var(--space-3); padding: var(--space-3) var(--space-4); color: var(--muted); background: var(--surface-soft); border-radius: var(--radius-md); } +.agent-boundary summary { color: var(--ink); cursor: pointer; font-weight: 700; } +.agent-boundary p { margin: var(--space-2) 0 0; line-height: 1.7; } .risk-chat { height: 360px; margin-top: var(--space-4); padding: var(--space-4); overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-3); background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius-md); } .risk-message { max-width: 82%; padding: var(--space-3) var(--space-4); align-self: flex-start; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); line-height: 1.75; white-space: pre-wrap; } .risk-message--user { align-self: flex-end; color: var(--surface); background: var(--brand-dark); border-color: var(--brand-dark); } @@ -18,7 +26,10 @@ .risk-message__tools { margin-top: var(--space-2); color: var(--muted); font-size: var(--fs-small); } .risk-prompts { margin: var(--space-3) 0; display: flex; flex-wrap: wrap; gap: var(--space-2); } .risk-prompts button { min-height: 34px; padding: 0 var(--space-3); color: var(--brand-dark); background: var(--brand-soft); border: 0; border-radius: var(--radius-sm); cursor: pointer; } +.risk-prompts button:disabled { cursor: not-allowed; opacity: .55; } +.risk-prompts--alert { padding-top: var(--space-2); border-top: 1px dashed var(--line); } .risk-composer { display: grid; grid-template-columns: 1fr auto; gap: var(--space-2); } +.risk-disclaimer { margin: var(--space-3) 0 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.7; } .action-dialog__copy { margin: 0; color: var(--muted); line-height: 1.75; } .action-dialog__textarea { min-height: 120px; height: auto; padding: var(--space-3); resize: vertical; } .risk-upload { padding: var(--space-4); display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: end; background: var(--surface-soft); border-radius: var(--radius-md); } @@ -27,6 +38,52 @@ .risk-report-controls .form-field { flex: 1; } .risk-report-status { margin: 0; color: var(--muted); font-size: var(--fs-small); } .risk-report-content { min-height: 300px; height: auto; padding: var(--space-3); resize: vertical; line-height: 1.7; } +.risk-report-preview { padding: var(--space-3) var(--space-4); background: var(--surface-soft); border: 1px solid var(--line); border-radius: var(--radius-md); } +.risk-report-preview summary { cursor: pointer; color: var(--brand-dark); font-weight: 700; } +.risk-report-preview__content { margin-top: var(--space-3); display: grid; gap: var(--space-3); } +.risk-report-preview__section { padding: var(--space-3); background: var(--surface); border-radius: var(--radius-sm); } +.risk-report-preview__section h3 { margin: 0 0 var(--space-2); font-size: var(--fs-body); } +.risk-report-preview__section p, .risk-report-preview__section ul { margin: 0; color: var(--muted); line-height: 1.7; } +.risk-report-preview__section ul { padding-left: var(--space-4); } .evidence-table .data-table { min-width: 900px; } +.data-table__expandable-row { cursor: pointer; } +.data-table__expandable-row:hover { background: var(--surface-soft); } +.data-table__expandable-row:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; } +.data-table__expandable-row--open { background: var(--brand-soft); } +.data-table__detail-row td { padding: 0; background: var(--surface-soft); } +.data-table__detail-grid { margin: 0; padding: var(--space-4); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.data-table__detail-grid > div { min-width: 0; } +.data-table__detail-grid dt { margin-bottom: var(--space-1); color: var(--muted); font-size: var(--fs-small); } +.data-table__detail-grid dd { margin: 0; overflow-wrap: anywhere; line-height: 1.6; } +.evidence-snapshot { display: grid; gap: var(--space-3); } +.evidence-snapshot__grid { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.evidence-snapshot__grid > div { min-width: 0; padding: var(--space-3); background: var(--surface-soft); border-radius: var(--radius-sm); } +.evidence-snapshot__grid dt { margin-bottom: var(--space-1); color: var(--muted); font-size: var(--fs-small); } +.evidence-snapshot__grid dd { margin: 0; overflow-wrap: anywhere; line-height: 1.6; } +.evidence-snapshot__nested, .evidence-snapshot__archive { padding: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-md); } +.evidence-snapshot__nested h4, .evidence-snapshot__archive h4 { margin: 0 0 var(--space-3); } +.evidence-snapshot__nested-item { padding: var(--space-3); background: var(--surface-soft); border-radius: var(--radius-sm); } +.evidence-snapshot__nested-item + .evidence-snapshot__nested-item { margin-top: var(--space-3); } +.evidence-snapshot__nested-item h5 { margin: 0 0 var(--space-3); } +.evidence-snapshot__raw summary { color: var(--brand-dark); cursor: pointer; } +.evidence-snapshot__raw .json-view { margin-top: var(--space-2); } +.business-section { display: grid; gap: var(--space-3); } +.business-record-list { display: grid; gap: var(--space-3); } +.business-record { padding: var(--space-3); background: var(--surface-soft); border-radius: var(--radius-sm); } +.business-record h4 { margin: 0 0 var(--space-3); } +.business-record__grid { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.business-record__grid > div { min-width: 0; } +.business-record__grid dt { margin-bottom: var(--space-1); color: var(--muted); font-size: var(--fs-small); } +.business-record__grid dd { margin: 0; overflow-wrap: anywhere; line-height: 1.6; white-space: pre-wrap; } +.business-section__raw summary { color: var(--brand-dark); cursor: pointer; } +.business-section__raw .json-view { margin-top: var(--space-2); } +.operations-hero__identity { display: flex; align-items: center; gap: var(--space-2); } +.hero-meta-button { width: 26px; min-width: 26px; height: 26px; color: rgba(247,251,250,.9); background: rgba(255,255,255,.12); border-color: rgba(255,255,255,.25); } +.system-tip-list { display: grid; gap: var(--space-3); } +.system-tip-item { padding: var(--space-3); background: var(--surface-soft); border-radius: var(--radius-sm); } +.system-tip-item__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.system-tip-item__header strong { overflow-wrap: anywhere; } +.system-tip-item p { margin: var(--space-2) 0 0; color: var(--muted); line-height: 1.65; overflow-wrap: anywhere; } +.system-tip-item__meta { margin-top: var(--space-2); display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--muted); font-size: var(--fs-small); } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -@media (max-width: 620px) { .risk-assistant { padding: var(--space-4); } .risk-chat { height: 320px; } .risk-message { max-width: 92%; } .risk-composer { grid-template-columns: 1fr; } .risk-report-controls { align-items: stretch; flex-direction: column; } .risk-report-controls .button { width: 100%; } } +@media (max-width: 620px) { .risk-assistant { padding: var(--space-4); } .risk-context-bar { align-items: flex-start; flex-direction: column; } .risk-alert-context-form { grid-template-columns: 1fr; } .risk-chat { height: 320px; } .risk-message { max-width: 92%; } .risk-composer { grid-template-columns: 1fr; } .risk-report-controls { align-items: stretch; flex-direction: column; } .risk-report-controls .button { width: 100%; } .data-table__detail-grid, .evidence-snapshot__grid, .business-record__grid { grid-template-columns: 1fr; } } diff --git a/app/static/portal/employee-risk/dashboard/dashboard.js b/app/static/portal/employee-risk/dashboard/dashboard.js index 2be8a17..776c267 100644 --- a/app/static/portal/employee-risk/dashboard/dashboard.js +++ b/app/static/portal/employee-risk/dashboard/dashboard.js @@ -1,4 +1,4 @@ -import { apiClient } from '/static/portal/common/api-client.js?v=20260913-2'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913-8'; import { getAuthContext, getPermissions, requireRiskStaff, updateAuthPermissions } from '/static/portal/common/auth.js'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; @@ -8,16 +8,56 @@ import { PERM_CODES } from '/static/portal/common/permission-codes.js'; import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; const EVIDENCE_COLUMNS = Object.freeze({ - customers: [['customer_no', '客户编号'], ['name', '客户姓名'], ['risk_level', '风险等级'], ['behavior_score', '行为分'], ['total_asset', '资产快照'], ['status', '状态']], - products: [['product_code', '产品代码'], ['product_name', '产品名称'], ['risk_level', '产品风险'], ['product_category', '产品类型'], ['status', '状态']], - transactions: [['transaction_no', '成交编号'], ['customer_no', '客户编号'], ['product_code', '产品代码'], ['transaction_type', '方向'], ['amount', '金额'], ['executed_at', '成交时间']], - capital_flows: [['flow_no', '流水编号'], ['customer_no', '客户编号'], ['flow_type', '类型'], ['amount', '金额'], ['source_type', '来源'], ['settled_at', '到账时间']], + customers: [['customer_no', '客户编号'], ['name', '客户姓名'], ['age', '年龄'], ['risk_level', '风险等级'], ['behavior_score', '行为分'], ['assessment_expired', '测评过期']], + products: [['product_code', '产品代码'], ['product_name', '产品名称'], ['risk_level', '产品风险'], ['risk_disclosure_required', '风险揭示'], ['second_confirmation_required', '二次确认'], ['status', '状态']], + transactions: [['transaction_no', '成交编号'], ['customer_no', '客户编号'], ['product_code', '产品代码'], ['transaction_type', '方向'], ['amount', '金额'], ['channel', '渠道'], ['trade_status', '交易状态'], ['confirmed_at', '确认时间']], + capital_flows: [['flow_no', '流水编号'], ['customer_no', '客户编号'], ['flow_type', '类型'], ['amount', '金额'], ['status', '状态'], ['settled_at', '到账时间']], holdings: [['customer_no', '客户编号'], ['product_code', '产品代码'], ['product_name', '产品名称'], ['shares', '持有份额'], ['current_value', '当前市值'], ['holding_ratio', '持仓占比']], login_records: [['customer_no', '客户编号'], ['login_at', '登录时间'], ['login_result', '结果'], ['ip_region', '地区'], ['device_id', '设备'], ['is_common_device', '常用设备']], - alerts: [['alert_no', '预警编号'], ['customer_no', '客户编号'], ['alert_type', '预警类型'], ['risk_level', '风险等级'], ['status', '状态'], ['created_at', '生成时间']], + alerts: [['alert_no', '预警编号'], ['customer_no', '客户编号'], ['alert_type', '预警类型'], ['risk_level', '风险等级'], ['status', '状态'], ['priority_score', '优先级'], ['due_at', '处理时限'], ['evidence_archived', '证据归档']], notifications: [['notification_no', '通知编号'], ['alert_no', '预警编号'], ['channel', '渠道'], ['send_status', '发送状态'], ['title', '标题'], ['send_time', '发送时间']], }); +const FIELD_LABELS = Object.freeze({ + id: '记录编号', customer_id: '客户记录编号', customer_no: '客户编号', username: '登录账号', name: '客户姓名', + age: '年龄', occupation: '职业', mobile_masked: '脱敏手机号', total_asset: '总资产', customer_tier: '客户等级', + risk_level: '风险等级', risk_score: '风险测评分数', assessment_date: '测评日期', assessment_valid_until: '测评有效期至', + assessment_expired: '测评是否过期', behavior_score: '行为分', risk_tags: '风险标签', opened_at: '开户时间', status: '状态', + product_id: '产品记录编号', product_code: '产品代码', product_name: '产品名称', exchange_code: '交易市场', + product_category: '产品类型', currency: '币种', lot_size: '交易单位', price_tick: '最小价格变动', + min_amount: '最低交易金额', single_investor_max_holding_ratio: '单客户最大持仓比例', current_nav: '最新净值', + current_nav_at: '净值时间', fund_manager: '基金管理人', transaction_fee_rate: '交易费率', + management_fee_rate: '管理费率', custodian_fee_rate: '托管费率', open_start_at: '开放起始时间', + open_end_at: '开放结束时间', risk_disclosure_required: '需要风险揭示', second_confirmation_required: '需要二次确认', + recording_required: '需要录音录像', transaction_no: '成交编号', transaction_type: '交易方向', amount: '金额', + channel: '交易渠道', trade_status: '交易状态', risk_disclosure_signed: '已签署风险揭示', + second_confirmation: '已完成二次确认', recording_id: '录音录像编号', work_order_no: '工单编号', + confirmed_at: '确认时间', executed_at: '成交时间', flow_no: '资金流水编号', flow_type: '资金方向', + settled_at: '资金到账时间', occurred_at: '资金发生时间', source_type: '资金来源', match_status: '匹配状态', + shares: '持有份额', cost_amount: '持仓成本', current_value: '当前市值', profit_loss: '浮动盈亏', + holding_days: '持有天数', holding_ratio: '持仓占比', login_at: '登录时间', login_result: '登录结果', + ip_region: '登录地区', device_id: '登录设备', is_common_device: '是否常用设备', failure_reason: '失败原因', + alert_id: '预警记录编号', alert_no: '预警编号', alert_type: '预警类型', rule_codes: '命中规则', + triggered_rules: '命中规则', evidence_summary: '证据摘要', evidence_snapshot: '证据快照', + priority_score: '优先级分数', event_status: '事件状态', ack_status: '确认状态', ack_at: '确认时间', + due_at: '处理时限', is_escalated: '是否升级', escalated_at: '升级时间', evidence_archived: '证据是否归档', + close_reason: '关闭原因', notification_id: '通知编号', notification_no: '通知编号', title: '标题', + send_status: '发送状态', receiver_email: '接收邮箱', send_time: '发送时间', created_at: '创建时间', updated_at: '更新时间', +}); + +const SNAPSHOT_FIELD_LABELS = Object.freeze({ + product_id: '关联产品', capital_flow_id: '关联资金流水', work_order_id: '关联工单', + transaction_id: '关联交易', flow_no: '资金流水编号', ratio: '规则比例', + age: '客户年龄', customer_age: '客户年龄', device_id: '登录设备', device_changed: '设备是否变化', + average_amount: '历史平均金额', amount: '交易金额', total_amount: '合计金额', max_amount: '最大单笔金额', + transaction_count: '交易笔数', window_hours: '统计窗口(小时)', channel: '交易渠道', + work_order_no: '工单编号', matched_work_order: '匹配工单', level_gap: '风险等级差', + hour: '规则触发小时', beijing_hour: '北京时间小时', utc_confirmed_at: '成交确认时间(UTC)', + open_status: '原始状态', login_ip_regions: '登录地区', merged_alerts: '合并证据', + evidence_archive: '证据归档文件', stored_name: '归档文件名', file_size: '文件大小', + sha256: '文件校验值', alert_type: '证据类型', +}); + const ACTIONS = Object.freeze({ acknowledge: { endpoint: 'RK007', title: '确认接收预警', copy: '确认后将记录当前处理人与接收时间。', success: '预警已确认接收' }, investigate: { endpoint: 'RK008', title: '进入调查', copy: '预警将进入人工调查状态。', success: '已进入调查' }, @@ -26,6 +66,9 @@ const ACTIONS = Object.freeze({ escalate: { endpoint: 'RK011', title: '升级处理', copy: '升级是预警标记,不改变当前闭环状态。', label: '升级理由', field: 'reason', success: '预警已升级' }, }); +const GENERAL_CHAT_KEY = '__general_risk_chat__'; +const ALERT_CHAT_PREFIX = 'alert:'; + function displayValue(value, key = '') { if (value === null || value === undefined || value === '') return '--'; if (Array.isArray(value)) return value.length ? value.join('、') : '--'; @@ -46,17 +89,72 @@ function statusTag(status) { return `${escapeHtml(status || '--')}`; } -function tableMarkup(items, columns, { action } = {}) { +function detailMarkup(item) { + const entries = Object.entries(item || {}); + if (!entries.length) return '

暂无可展开字段

'; + return `
${entries.map(([key, value]) => `
${escapeHtml(FIELD_LABELS[key] || `${key}(技术字段)`)}
${escapeHtml(displayValue(value, key))}
`).join('')}
`; +} + +function tableMarkup(items, columns, { action, expandable = false, actionFirst = false } = {}) { const headers = columns.map(([, label]) => `${escapeHtml(label)}`).join(''); - const rows = items.map((item) => { + const rows = items.map((item, index) => { const cells = columns.map(([key]) => { const content = key === 'risk_level' ? riskTag(item[key]) : key === 'send_status' || key === 'status' ? statusTag(item[key]) : escapeHtml(displayValue(item[key], key)); return `${content}`; }).join(''); const actionCell = action ? `` : ''; - return `${cells}${actionCell}`; + const rowAttributes = expandable + ? ` class="data-table__expandable-row" data-expand-row="${index}" tabindex="0" role="button" aria-expanded="false"` + : ''; + const mainRow = `${actionFirst ? actionCell : ''}${cells}${actionFirst ? '' : actionCell}`; + if (!expandable) return mainRow; + const colspan = columns.length + (action ? 1 : 0); + return `${mainRow}${detailMarkup(item)}`; }).join(''); - return `
${headers}${action ? '' : ''}${rows}
操作
`; + return `
${action && actionFirst ? '' : ''}${headers}${action && !actionFirst ? '' : ''}${rows}
操作操作
`; +} + +function bindExpandableRows(container) { + container.querySelectorAll('[data-expand-row]').forEach((row) => { + const toggle = () => { + const detail = container.querySelector(`[data-expand-detail="${row.dataset.expandRow}"]`); + if (!detail) return; + detail.hidden = !detail.hidden; + row.setAttribute('aria-expanded', String(!detail.hidden)); + row.classList.toggle('data-table__expandable-row--open', !detail.hidden); + }; + row.addEventListener('click', (event) => { + if (event.target.closest('button, a, input, select, textarea')) return; + toggle(); + }); + row.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(); + } + }); + }); +} + +const formErrorTimers = new WeakMap(); + +function clearFormError(element) { + if (!element) return; + const timer = formErrorTimers.get(element); + if (timer) window.clearTimeout(timer); + formErrorTimers.delete(element); + element.textContent = ''; + element.classList.remove('form-alert--visible'); +} + +function showFormError(element, message) { + if (!element) return; + const existing = formErrorTimers.get(element); + if (existing) window.clearTimeout(existing); + element.textContent = message; + element.classList.add('form-alert--visible'); + const timer = window.setTimeout(() => clearFormError(element), 5000); + formErrorTimers.set(element, timer); } if (requireRiskStaff()) { @@ -68,6 +166,7 @@ if (requireRiskStaff()) { notification: { cursors: [null], page: 0, next: null, filters: {} }, currentAlert: null, action: null, + chat: { activeKey: GENERAL_CHAT_KEY, contexts: new Map() }, controllers: new Set(), }; const metrics = document.querySelector('[data-risk-metrics]'); @@ -78,6 +177,8 @@ if (requireRiskStaff()) { const alertDialog = document.querySelector('[data-alert-dialog]'); const actionDialog = document.querySelector('[data-action-dialog]'); const reportDialog = document.querySelector('[data-report-dialog]'); + const systemTipsDialog = document.querySelector('[data-system-tips-dialog]'); + const policyDialog = document.querySelector('[data-policy-dialog]'); function controller() { const item = new AbortController(); @@ -145,7 +246,7 @@ if (requireRiskStaff()) { else { alertTable.innerHTML = tableMarkup(items, [ ['alert_no', '预警编号'], ['risk_level', '风险等级'], ['customer_no', '客户'], ['product_name', '产品'], ['alert_type', '风险类型'], ['status', '状态'], ['created_at', '生成时间'], - ], { action: { label: '详情', key: (item) => item.alert_no } }); + ], { action: { label: '详情', key: (item) => item.alert_no }, actionFirst: true }); alertTable.querySelectorAll('[data-row-action]').forEach((button) => button.addEventListener('click', () => openAlert(button.dataset.rowAction))); } updatePager('alert', response.meta || {}); @@ -155,13 +256,22 @@ if (requireRiskStaff()) { } } + function updateEvidenceFilterVisibility() { + const source = document.querySelector('[data-evidence-filter]').elements.source.value; + document.querySelector('[data-behavior-filter]').hidden = source !== 'customers'; + } + async function loadEvidence() { renderLoading(evidenceTable, 4); try { + updateEvidenceFilterVisibility(); const response = await apiClient.get('RK004', { pathParams: { source: state.evidence.source }, query: { ...state.evidence.filters, cursor: state.evidence.cursors[state.evidence.page], limit: 10 } }); const items = Array.isArray(response.data) ? response.data : []; if (!items.length) renderEmpty(evidenceTable, '暂无证据记录', '当前类型和筛选条件下没有数据。'); - else evidenceTable.innerHTML = `
${tableMarkup(items, EVIDENCE_COLUMNS[state.evidence.source])}
`; + else { + evidenceTable.innerHTML = `
${tableMarkup(items, EVIDENCE_COLUMNS[state.evidence.source], { expandable: true })}
`; + bindExpandableRows(evidenceTable); + } updatePager('evidence', response.meta || {}); } catch (error) { apiClient.reportError(error); @@ -186,9 +296,170 @@ if (requireRiskStaff()) { } } - function detailSection(title, value) { + const MONEY_FIELDS = new Set([ + 'amount', 'total_amount', 'max_amount', 'average_amount', 'gross_amount', 'net_amount', + 'fee_amount', 'fee', 'cost_amount', 'current_value', 'market_value', 'total_asset', + 'profit_loss', 'balance_after', 'cash_balance', 'available_cash', 'frozen_cash', + 'initial_balance', 'min_amount', + ]); + const RATIO_FIELDS = new Set([ + 'ratio', 'exemption_ratio', 'holding_ratio', 'profit_loss_ratio', + ]); + const BOOLEAN_FIELDS = new Set([ + 'assessment_expired', 'risk_disclosure_required', 'second_confirmation_required', + 'recording_required', 'risk_disclosure_signed', 'second_confirmation', + 'is_common_device', 'is_escalated', 'evidence_archived', 'auto_confirmed', + ]); + + function formatBusinessValue(key, value) { + if (value === null || value === undefined || value === '') return '--'; + if (MONEY_FIELDS.has(key)) { + const numeric = Number(value); + return Number.isFinite(numeric) + ? `${numeric.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元` + : value; + } + if (RATIO_FIELDS.has(key)) { + const numeric = Number(value); + return Number.isFinite(numeric) ? `${(numeric * 100).toFixed(2)}%` : value; + } + if (BOOLEAN_FIELDS.has(key)) return value ? '是' : '否'; + if (typeof value === 'object') return JSON.stringify(value, null, 2); + return displayValue(value, key); + } + + function businessRecordMarkup(record, index = null) { + const entries = Object.entries(record || {}); + if (!entries.length) return '

暂无数据

'; + return `
+ ${index === null ? '' : `

${escapeHtml(index)}

`} +
${entries.map(([key, value]) => ` +
+
${escapeHtml(FIELD_LABELS[key] || `${key}(技术字段)`)}
+
${escapeHtml(formatBusinessValue(key, value))}
+
`).join('')} +
+
`; + } + + function renderBusinessSection(title, value) { if (value === null || value === undefined || (Array.isArray(value) && !value.length)) return ''; - return `

${escapeHtml(title)}

${escapeHtml(JSON.stringify(value, null, 2))}
`; + const records = Array.isArray(value) + ? value.map((item, index) => businessRecordMarkup(item, `${title} ${index + 1}`)).join('') + : businessRecordMarkup(value); + return `
+

${escapeHtml(title)}

+
${records}
+
+ 查看原始数据 +
${escapeHtml(JSON.stringify(value, null, 2))}
+
+
`; + } + + function snapshotLabel(key) { + return SNAPSHOT_FIELD_LABELS[key] || `${key}(技术字段)`; + } + + function findRelatedLabel(collection, id, codeKey, nameKey) { + if (!Array.isArray(collection) || id === null || id === undefined) return null; + const row = collection.find((item) => String(item?.id ?? '') === String(id)); + if (!row) return null; + const code = row?.[codeKey]; + const name = row?.[nameKey]; + return [code, name].filter(Boolean).join(' · ') || null; + } + + function formatSnapshotValue(key, value, detail, alert) { + if (value === null || value === undefined || value === '') return '--'; + if (key === 'product_id') { + const product = detail.product || {}; + return [product.product_code, product.product_name].filter(Boolean).join(' · ') || value; + } + if (key === 'work_order_id') { + const workOrder = detail.work_order || {}; + return workOrder.work_order_no || value; + } + if (key === 'capital_flow_id') { + return findRelatedLabel(detail.capital_flows, value, 'flow_no', 'flow_type') || value; + } + if (key === 'transaction_id') { + const transaction = detail.transaction || {}; + return transaction.transaction_no || value; + } + if (key === 'ratio') { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return value; + if ((alert.rule_codes || []).includes('RW-012') || numeric > 1) return `${numeric} 倍`; + return `${(numeric * 100).toFixed(2)}%`; + } + if (['amount', 'average_amount', 'total_amount', 'max_amount'].includes(key)) { + const numeric = Number(value); + return Number.isFinite(numeric) + ? `${numeric.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元` + : value; + } + if (key === 'file_size') { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return value; + return numeric >= 1024 * 1024 + ? `${(numeric / 1024 / 1024).toFixed(2)} MB` + : `${(numeric / 1024).toFixed(2)} KB`; + } + if (key === 'sha256' && typeof value === 'string') return `${value.slice(0, 16)}…`; + return displayValue(value, key); + } + + function snapshotEntriesMarkup(entries, detail, alert) { + return `
${entries.map(([key, value]) => ` +
+
${escapeHtml(snapshotLabel(key))}
+
${escapeHtml(formatSnapshotValue(key, value, detail, alert))}
+
`).join('')}
`; + } + + function renderMergedEvidence(items, detail, alert) { + if (!Array.isArray(items) || !items.length) return ''; + return `
+

合并证据

+ ${items.map((item, index) => { + const evidence = item?.evidence && typeof item.evidence === 'object' ? item.evidence : {}; + return `
+
${escapeHtml(item?.alert_type || `合并证据 ${index + 1}`)}
+ ${snapshotEntriesMarkup(Object.entries(evidence), detail, alert)} +
`; + }).join('')} +
`; + } + + function renderEvidenceArchive(value) { + if (!value || typeof value !== 'object') return ''; + return `
+

证据归档文件

+ ${snapshotEntriesMarkup(Object.entries(value), {}, { rule_codes: [] })} +
`; + } + + function renderEvidenceSnapshot(snapshot, detail) { + if (!snapshot || typeof snapshot !== 'object' || !Object.keys(snapshot).length) { + return '

证据快照

暂无证据快照

'; + } + const alert = detail.alert || detail; + const merged = snapshot.merged_alerts; + const archive = snapshot.evidence_archive; + const baseEntries = Object.entries(snapshot).filter( + ([key]) => key !== 'merged_alerts' && key !== 'evidence_archive', + ); + return `
+

证据快照

+ ${baseEntries.length ? snapshotEntriesMarkup(baseEntries, detail, alert) : ''} + ${renderMergedEvidence(merged, detail, alert)} + ${renderEvidenceArchive(archive)} +
+ 查看原始数据 +
${escapeHtml(JSON.stringify(snapshot, null, 2))}
+
+
`; } function renderAlertActions(detail) { @@ -217,9 +488,14 @@ if (requireRiskStaff()) { state.currentAlert = response.data; const detail = response.data || {}; const alert = detail.alert || detail; - target.innerHTML = `
预警编号
${escapeHtml(alert.alert_no)}
风险等级
${riskTag(alert.risk_level)}
客户
${escapeHtml(alert.customer_no || '--')} · ${escapeHtml(alert.customer_name || '--')}
产品
${escapeHtml(alert.product_code || '--')} · ${escapeHtml(alert.product_name || '--')}
处置状态
${statusTag(alert.status)}
确认状态
${escapeHtml(alert.ack_status || '--')}
命中规则
${escapeHtml(displayValue(alert.rule_codes))}
升级标记
${alert.is_escalated ? '已升级' : '未升级'}

证据摘要

${escapeHtml(alert.evidence_summary || '暂无摘要')}

${detail.evidence_truncated?.length ? `

部分证据达到返回上限:${escapeHtml(detail.evidence_truncated.join('、'))}

` : ''}${detailSection('客户画像', detail.customer)}${detailSection('关联交易', detail.transaction)}${detailSection('关联产品', detail.product)}${detailSection('资金流水', detail.capital_flows)}${detailSection('持仓证据', detail.holdings)}${detailSection('登录证据', detail.login_records)}${detailSection('证据快照', detail.evidence_snapshot)}${hasPermission(PERM_CODES.RISK_ALERT_WRITE) ? `
` : ''}`; + const archiveName = detail.evidence_snapshot?.evidence_archive?.stored_name; + const archiveText = alert.evidence_archived + ? `已归档${archiveName ? ` · ${archiveName}` : ''}` + : '未归档'; + target.innerHTML = `
预警编号
${escapeHtml(alert.alert_no)}
风险等级
${riskTag(alert.risk_level)}
客户
${escapeHtml(alert.customer_no || '--')} · ${escapeHtml(alert.customer_name || '--')}
产品
${escapeHtml(alert.product_code || '--')} · ${escapeHtml(alert.product_name || '--')}
处置状态
${statusTag(alert.status)}
确认状态
${escapeHtml(alert.ack_status || '--')}
证据归档
${escapeHtml(archiveText)}
命中规则
${escapeHtml(displayValue(alert.rule_codes))}
升级标记
${alert.is_escalated ? '已升级' : '未升级'}

证据摘要

${escapeHtml(alert.evidence_summary || '暂无摘要')}

${detail.evidence_truncated?.length ? `

部分证据达到返回上限:${escapeHtml(detail.evidence_truncated.join('、'))}

` : ''}${renderBusinessSection('客户画像', detail.customer)}${renderBusinessSection('关联交易', detail.transaction)}${renderBusinessSection('关联产品', detail.product)}${renderBusinessSection('关联工单', detail.work_order)}${renderBusinessSection('资金流水', detail.capital_flows)}${renderBusinessSection('持仓证据', detail.holdings)}${renderBusinessSection('登录证据', detail.login_records)}${renderEvidenceSnapshot(detail.evidence_snapshot, detail)}${hasPermission(PERM_CODES.RISK_ALERT_WRITE) ? `
` : ''}`; target.querySelector('[data-evidence-upload]')?.addEventListener('submit', uploadEvidence); renderAlertActions(detail); + switchChatContext(alert.alert_no); } catch (error) { apiClient.reportError(error); renderError(target, error, () => openAlert(alertNo)); @@ -237,7 +513,7 @@ if (requireRiskStaff()) { field.querySelector('[data-action-label]').textContent = config.label || '处理说明'; field.querySelector('textarea').required = Boolean(config.field); field.querySelector('textarea').value = ''; - document.querySelector('[data-action-alert]').classList.remove('form-alert--visible'); + clearFormError(document.querySelector('[data-action-alert]')); actionDialog.showModal(); } @@ -251,21 +527,21 @@ if (requireRiskStaff()) { const body = config.field ? { [config.field]: value } : {}; const alert = form.querySelector('[data-action-alert]'); const submit = form.querySelector('[type="submit"]'); + clearFormError(alert); if (config.field && !value) { - alert.textContent = `请填写${config.label}。`; - alert.classList.add('form-alert--visible'); + showFormError(alert, `请填写${config.label}。`); return; } submit.disabled = true; try { await apiClient.post(config.endpoint, body, { pathParams: { alertNo } }); - showToast(config.success); actionDialog.close(); - await Promise.all([loadOverview(), loadAlerts(), openAlert(alertNo)]); + alertDialog.close(); + await Promise.all([loadOverview(), loadAlerts()]); + showToast(config.success); } catch (error) { apiClient.reportError(error); - alert.textContent = error.status === 409 ? '预警可能已处理,请核对当前状态。' : error.message; - alert.classList.add('form-alert--visible'); + showFormError(alert, error.status === 409 ? '预警可能已处理,请核对当前状态。' : error.message); } finally { submit.disabled = false; } } @@ -274,14 +550,20 @@ if (requireRiskStaff()) { const form = event.currentTarget; const alertNo = state.currentAlert?.alert?.alert_no || state.currentAlert?.alert_no; const submit = form.querySelector('[type="submit"]'); + const detail = form.closest('[data-alert-detail]'); + const error = detail?.querySelector('[data-evidence-error]'); + clearFormError(error); submit.disabled = true; try { await apiClient.upload('RK012', new FormData(form), { pathParams: { alertNo } }); - showToast('证据上传成功'); - await openAlert(alertNo); + alertDialog.close(); + await loadAlerts(); + showToast('证据上传并归档成功'); } catch (error) { apiClient.reportError(error); - showToast(error.message || '证据上传失败', 'error'); + const errorElement = detail?.querySelector('[data-evidence-error]'); + showFormError(errorElement, error.message || '证据上传失败'); + errorElement?.scrollIntoView({ block: 'center', behavior: 'smooth' }); } finally { submit.disabled = false; } } @@ -302,6 +584,38 @@ if (requireRiskStaff()) { } } + function distributionText(items) { + if (!Array.isArray(items) || !items.length) return '无'; + return items.map((item) => `${item.name || '--'} ${item.count ?? 0} 条`).join('、'); + } + + function reportAlertList(items) { + if (!Array.isArray(items) || !items.length) return '

无

'; + return ``; + } + + function renderReportPreview(report) { + const wrap = document.querySelector('[data-report-preview-wrap]'); + const target = document.querySelector('[data-report-preview]'); + if (!report || !target || !wrap) return; + const unresolved = report.unresolved_items || {}; + const falsePositive = report.false_positive_statistics || {}; + const disposition = report.disposition_results || {}; + const rules = Array.isArray(report.rule_effectiveness) ? report.rule_effectiveness : []; + target.innerHTML = [ + `

1. 当日预警数量

${escapeHtml(report.daily_alert_count ?? 0)} 条

`, + `

2. 等级分布

${escapeHtml(distributionText(report.level_distribution))}

`, + `

3. 重点风险事件

${reportAlertList(report.key_risk_events)}
`, + `

4. 未闭环事项

共 ${escapeHtml(unresolved.total ?? 0)} 条,新增 ${escapeHtml(unresolved.new_today ?? 0)} 条,历史 ${escapeHtml(unresolved.historical ?? 0)} 条,超时 ${escapeHtml(unresolved.overdue ?? 0)} 条

${reportAlertList(unresolved.items)}
`, + `

5. 误报统计

共 ${escapeHtml(falsePositive.total ?? 0)} 条

`, + `

6. 类型分布

${escapeHtml(distributionText(report.type_distribution))}

`, + `

7. 处置结果

确认接收 ${escapeHtml(disposition.acknowledged ?? 0)} 条,关闭误报 ${escapeHtml(disposition.false_positive_closed ?? 0)} 条,调查中 ${escapeHtml(disposition.investigating ?? 0)} 条,升级 ${escapeHtml(disposition.escalated ?? 0)} 条

`, + `

8. 规则效果

`, + `

9. 建议优化方向

${escapeHtml(report.optimization_suggestions || '--')}

`, + ].join(''); + wrap.hidden = false; + } + async function generateReport() { const form = document.querySelector('[data-report-form]'); const content = form.elements.content; @@ -311,6 +625,8 @@ if (requireRiskStaff()) { const item = controller(); button.disabled = true; content.value = ''; + document.querySelector('[data-report-preview-wrap]').hidden = true; + document.querySelector('[data-report-preview]').innerHTML = ''; status.textContent = '正在生成日报'; try { await apiClient.stream('RK014', { report_date: reportDate }, { @@ -319,8 +635,11 @@ if (requireRiskStaff()) { if (type === 'progress') status.textContent = data.message || '正在生成日报'; if (type === 'replace') content.value = data.content || ''; if (type === 'done') { - content.value = data.report?.content || content.value; - status.textContent = data.report?.data_truncated ? '日报生成完成,数据已达到查询上限' : '日报生成完成'; + const report = data.report || {}; + content.value = report.content || content.value; + renderReportPreview(report); + const source = report.source === '模型' ? '大模型生成' : '规则降级'; + status.textContent = `${report.report_date || '--'} · ${report.generated_at || '--'} · ${source}${report.data_truncated ? ' · 数据已达到查询上限' : ''}`; } }, }); @@ -338,67 +657,170 @@ if (requireRiskStaff()) { const alert = form.querySelector('[data-report-alert]'); const submit = form.querySelector('[type="submit"]'); const recipients = form.elements.recipients.value.split(/[,,;;]/).map((item) => item.trim()).filter(Boolean); - alert.classList.remove('form-alert--visible'); + clearFormError(alert); submit.disabled = true; try { await apiClient.post('RK015', { recipients, subject: form.elements.subject.value.trim(), content: form.elements.content.value.trim() }); + reportDialog.close(); showToast('日报邮件发送成功'); } catch (error) { apiClient.reportError(error); - alert.textContent = error.message || '日报邮件发送失败'; - alert.classList.add('form-alert--visible'); + showFormError(alert, error.message || '日报邮件发送失败'); } finally { submit.disabled = false; } } - function appendMessage(kind, text) { + function chatContextKey(alertNo = null) { + return alertNo ? `${ALERT_CHAT_PREFIX}${alertNo}` : GENERAL_CHAT_KEY; + } + + function ensureChatState(key) { + if (!state.chat.contexts.has(key)) { + state.chat.contexts.set(key, { sessionId: null, messages: [], sending: false }); + } + return state.chat.contexts.get(key); + } + + function renderChat() { const log = document.querySelector('[data-chat-log]'); - const message = document.createElement('div'); - message.className = `risk-message risk-message--${kind}`; - message.textContent = text; - log.append(message); + const chat = ensureChatState(state.chat.activeKey); + log.replaceChildren(); + if (!chat.messages.length) { + const empty = document.createElement('div'); + empty.className = 'risk-message risk-message--assistant'; + empty.textContent = '请选择下方常用问题,或输入需要核查的风险事项。'; + log.append(empty); + return; + } + chat.messages.forEach((message) => { + const item = document.createElement('div'); + item.className = `risk-message risk-message--${message.role}${message.pending ? ' risk-message--pending' : ''}${message.error ? ' risk-message--error' : ''}`; + item.textContent = message.content || (message.pending ? '正在检索风险数据并形成研判草案' : ''); + if (message.tools) { + const tools = document.createElement('div'); + tools.className = 'risk-message__tools'; + tools.textContent = `已调用风控只读工具:${typeof message.tools === 'string' ? message.tools : JSON.stringify(message.tools)}`; + item.append(tools); + } + log.append(item); + }); log.scrollTop = log.scrollHeight; - return message; + } + + function updateChatContextUI() { + const alertNo = state.chat.activeKey.startsWith(ALERT_CHAT_PREFIX) + ? state.chat.activeKey.slice(ALERT_CHAT_PREFIX.length) + : null; + document.querySelector('[data-chat-context]').textContent = alertNo ? `预警 ${alertNo}` : '通用风险问答'; + document.querySelector('[data-clear-chat-context]').hidden = !alertNo; + document.querySelector('[data-general-prompts]').hidden = Boolean(alertNo); + document.querySelector('[data-alert-prompts]').hidden = !alertNo; + renderChat(); + } + + function switchChatContext(alertNo = null) { + state.chat.activeKey = chatContextKey(alertNo); + ensureChatState(state.chat.activeKey); + updateChatContextUI(); + } + + async function bindAlertContext(event) { + event.preventDefault(); + const form = event.currentTarget; + const input = form.elements.alert_no; + const error = form.querySelector('[data-alert-context-error]'); + const alertNo = input.value.trim(); + clearFormError(error); + if (!alertNo) { + showFormError(error, '请输入预警编号'); + return; + } + if (!/^[A-Za-z0-9_-]{1,64}$/.test(alertNo)) { + showFormError(error, '预警编号格式不正确'); + return; + } + try { + await apiClient.get('RK003', { pathParams: { alertNo } }); + switchChatContext(alertNo); + input.value = ''; + showToast(`已绑定预警 ${alertNo}`); + } catch (requestError) { + apiClient.reportError(requestError); + showFormError(error, requestError.message || '预警编号不存在或无权限查看'); + } } async function sendAgentMessage(message) { const form = document.querySelector('[data-agent-form]'); const submit = form.querySelector('[type="submit"]'); - appendMessage('user', message); - const reply = appendMessage('pending', '正在检索风险数据并形成研判草案'); + const activeKey = state.chat.activeKey; + const chat = ensureChatState(activeKey); + if (chat.sending) return; + const alertNo = activeKey.startsWith(ALERT_CHAT_PREFIX) + ? activeKey.slice(ALERT_CHAT_PREFIX.length) + : null; + const sessionId = chat.sessionId || crypto.randomUUID(); + chat.sessionId = sessionId; + chat.messages.push({ role: 'user', content: message }); + const assistantMessage = { role: 'assistant', content: '', tools: null, pending: true, streaming: true }; + chat.messages.push(assistantMessage); + chat.sending = true; submit.disabled = true; + renderChat(); const item = controller(); const timer = window.setTimeout(() => item.abort(), 45000); try { - const accepted = await apiClient.post('R001', { agent_type: 'risk', message, session_id: crypto.randomUUID(), idempotency_key: crypto.randomUUID().replaceAll('-', '') }); + const accepted = await apiClient.post('R001', { + agent_type: 'risk', + message: alertNo ? `当前预警编号:${alertNo}。${message}` : message, + session_id: sessionId, + idempotency_key: crypto.randomUUID().replaceAll('-', ''), + }); const runId = accepted.data.run_id; - let content = ''; await apiClient.stream('R003', null, { pathParams: { runId }, signal: item.signal, onEvent: ({ type, data }) => { - if (type === 'delta') content += data.content || ''; - if (type === 'replace') content = data.content || ''; - if (type === 'tools') reply.dataset.tools = JSON.stringify(data.tool_calls || {}); + if (type === 'delta') assistantMessage.content += data.content || ''; + if (type === 'replace') assistantMessage.content = data.content || ''; + if (type === 'tools') assistantMessage.tools = data.tool_calls || {}; if (type === 'error') throw new Error(data.error_code || '风险助手运行失败'); - if (content) { - reply.className = 'risk-message risk-message--assistant'; - reply.textContent = content; - if (reply.dataset.tools) { - const tools = document.createElement('div'); - tools.className = 'risk-message__tools'; - tools.textContent = '已调用受控只读工具'; - reply.append(tools); - } - } + if (state.chat.activeKey === activeKey) renderChat(); }, }); } catch (error) { - reply.className = 'risk-message risk-message--error'; - reply.textContent = error.name === 'AbortError' ? '请求仍在处理中,请确认 Agent Worker 已启动后重试。' : (error.message || '风险助手运行失败'); + assistantMessage.error = true; + assistantMessage.content = error.name === 'AbortError' ? '请求仍在处理中,请确认 Agent Worker 已启动后重试。' : (error.message || '风险助手运行失败'); } finally { + assistantMessage.pending = false; + assistantMessage.streaming = false; + chat.sending = false; window.clearTimeout(timer); state.controllers.delete(item); submit.disabled = false; + if (state.chat.activeKey === activeKey) renderChat(); + } + } + + async function openSystemTips() { + const target = document.querySelector('[data-system-tips-list]'); + if (!systemTipsDialog.open) systemTipsDialog.showModal(); + renderLoading(target, 3); + try { + const response = await apiClient.get('RK005', { query: { limit: 10 } }); + const items = Array.isArray(response.data) ? response.data : []; + if (!items.length) { + renderEmpty(target, '暂无系统提示', '当前没有新的系统或通知记录。'); + return; + } + target.innerHTML = `
${items.map((item) => ` +
+
${escapeHtml(item.title || '系统提示')}${statusTag(item.send_status)}
+

${escapeHtml(item.content || '--')}

+
预警:${escapeHtml(item.alert_no || '--')}渠道:${escapeHtml(item.channel || '--')}时间:${escapeHtml(formatDateTime(item.send_time))}
+
`).join('')}
`; + } catch (error) { + apiClient.reportError(error); + renderError(target, error, openSystemTips); } } @@ -420,7 +842,17 @@ if (requireRiskStaff()) { if (tab.dataset.tab === 'notifications' && !notificationTable.dataset.loaded) { notificationTable.dataset.loaded = 'true'; loadNotifications(); } })); document.querySelector('[data-alert-filter]').addEventListener('submit', (event) => { event.preventDefault(); state.alert.filters = Object.fromEntries(new FormData(event.currentTarget)); resetPager('alert'); loadAlerts(); }); - document.querySelector('[data-evidence-filter]').addEventListener('submit', (event) => { event.preventDefault(); const values = Object.fromEntries(new FormData(event.currentTarget)); state.evidence.source = values.source; delete values.source; state.evidence.filters = values; resetPager('evidence'); loadEvidence(); }); + document.querySelector('[data-evidence-filter]').addEventListener('submit', (event) => { + event.preventDefault(); + const values = Object.fromEntries(new FormData(event.currentTarget)); + state.evidence.source = values.source; + delete values.source; + if (state.evidence.source !== 'customers') delete values.behavior_level; + state.evidence.filters = values; + resetPager('evidence'); + loadEvidence(); + }); + document.querySelector('[data-evidence-filter]').elements.source.addEventListener('change', updateEvidenceFilterVisibility); document.querySelector('[data-notification-filter]').addEventListener('submit', (event) => { event.preventDefault(); state.notification.filters = Object.fromEntries(new FormData(event.currentTarget)); resetPager('notification'); loadNotifications(); }); bindPager('alert', loadAlerts); bindPager('evidence', loadEvidence); @@ -429,16 +861,40 @@ if (requireRiskStaff()) { document.querySelectorAll('[data-close-alert]').forEach((button) => button.addEventListener('click', () => alertDialog.close())); document.querySelectorAll('[data-close-action]').forEach((button) => button.addEventListener('click', () => actionDialog.close())); document.querySelectorAll('[data-close-report]').forEach((button) => button.addEventListener('click', () => reportDialog.close())); + document.querySelector('[data-system-tips]').addEventListener('click', openSystemTips); + document.querySelectorAll('[data-close-system-tips]').forEach((button) => button.addEventListener('click', () => systemTipsDialog.close())); + document.querySelector('[data-policy-tips]').addEventListener('click', () => policyDialog.showModal()); + document.querySelectorAll('[data-close-policy]').forEach((button) => button.addEventListener('click', () => policyDialog.close())); document.querySelector('[data-action-form]').addEventListener('submit', submitAction); + document.querySelector('[data-action-form]').addEventListener('input', () => { + clearFormError(document.querySelector('[data-action-alert]')); + }); document.querySelector('[data-open-report]').addEventListener('click', () => { reportDialog.showModal(); applyPermissionGuard(reportDialog); }); document.querySelector('[data-generate-report]').addEventListener('click', generateReport); document.querySelector('[data-report-form]').addEventListener('submit', sendReport); + document.querySelector('[data-report-form]').addEventListener('input', () => { + clearFormError(document.querySelector('[data-report-alert]')); + }); + document.querySelector('[data-clear-chat-context]').addEventListener('click', () => switchChatContext()); + document.querySelector('[data-alert-context-form]').addEventListener('submit', bindAlertContext); + document.querySelector('[data-alert-context-form]').addEventListener('input', (event) => { + clearFormError(event.currentTarget.querySelector('[data-alert-context-error]')); + }); document.querySelector('[data-agent-form]').addEventListener('submit', (event) => { event.preventDefault(); const input = event.currentTarget.elements.message; const message = input.value.trim(); if (message) { input.value = ''; sendAgentMessage(message); } }); document.querySelectorAll('[data-prompt]').forEach((button) => button.addEventListener('click', () => sendAgentMessage(button.dataset.prompt))); + document.querySelectorAll('[data-alert-prompt]').forEach((button) => button.addEventListener('click', () => sendAgentMessage(button.dataset.alertPrompt))); + actionDialog.addEventListener('close', () => { + clearFormError(actionDialog.querySelector('[data-action-alert]')); + }); + reportDialog.addEventListener('close', () => { + clearFormError(reportDialog.querySelector('[data-report-alert]')); + }); window.addEventListener('pagehide', () => state.controllers.forEach((item) => item.abort())); apiClient.track('page_view', { page: 'risk-dashboard', portal: 'employee-risk' }); hydrateIdentity().catch((error) => { apiClient.reportError(error); showToast(error.message || '权限加载失败', 'error'); }); loadOverview(); loadAlerts(); + updateChatContextUI(); + updateEvidenceFilterVisibility(); } diff --git a/app/static/portal/employee-risk/dashboard/index.html b/app/static/portal/employee-risk/dashboard/index.html index b6fd87e..ed9b92a 100644 --- a/app/static/portal/employee-risk/dashboard/index.html +++ b/app/static/portal/employee-risk/dashboard/index.html @@ -5,7 +5,7 @@ 风险工作台 · 南方财富 - +
@@ -15,7 +15,13 @@

风险事件研判与处置工作台

聚合规则扫描、预警证据、人工处置与通知结果,所有状态变化均由服务端鉴权并留存审计。

预警队列证据查询通知留痕
-
当前操作员数据范围加载中
+
+
+ 当前操作员 + +
+ 数据范围加载中 +
@@ -46,9 +52,7 @@
- - - +
@@ -67,15 +71,35 @@
@@ -100,6 +124,10 @@

选择日期后生成日报。

+ @@ -109,6 +137,18 @@ - + +

系统提示

+
+ +
+ + +

政策解读

+

政策解读入口为预留功能,本期暂未接入具体内容。后续确定政策数据来源和更新流程后再开放。

+ +
+ + diff --git a/docs/风控业务演示文档/24-主项目风控前端改造TODO.md b/docs/风控业务演示文档/24-主项目风控前端改造TODO.md new file mode 100644 index 0000000..01ff118 --- /dev/null +++ b/docs/风控业务演示文档/24-主项目风控前端改造TODO.md @@ -0,0 +1,530 @@ +# 主项目风控前端改造 TODO + +## 文档信息 + +| 项目 | 内容 | +|---|---| +| 文档版本 | v1.0 | +| 编制日期 | 2026-09-13 | +| 对比对象 | 原项目单页风控工作台与主项目 `/portal/employee-risk/dashboard/` | +| 改造原则 | 保留主项目增强能力,补齐原项目缺失能力,破坏性交互调整先讨论 | + +## 1. 改造边界 + +### 1.1 必须保留 + +以下能力属于主项目统一前端增强,不得因对齐原项目而回退: + +- 统一登录、JWT、RBAC、数据范围和服务端权限校验。 +- 统一门户、员工登录页和角色分流。 +- 统一 `api-client.js`、错误码、trace ID、重试和 401 会话失效处理。 +- 风险概览“未闭环预警”正确口径。 +- 预警队列按风险等级排序,每页 5 条。 +- 预警筛选、证据筛选和游标分页。 +- 通知记录展示发送状态和失败原因。 +- 手动扫描 `RK006` 60 秒独立超时。 +- 响应式布局、统一主题、组件和移动端基础适配。 +- 证据上传大小限制、文件类型校验和归档唯一约束。 + +### 1.2 不在本次前端改造范围 + +- 修改风控 REST 接口路径、权限码和数据范围逻辑。 +- 修改预警状态机、行为分和规则扫描算法。 +- 将高风险邮件扩展为多收件人。 +- 自动确认、自动关闭或自动升级预警。 +- 将定时扫描并入 Web 进程。 + +## 2. TODO 总览 + +| 编号 | 事项 | 类型 | 优先级 | 是否需先讨论 | 预计影响 | +|---|---|---|---|---|---| +| FE-01 | 保留主项目统一前端增强能力 | 约束 | P0 | 否 | 防止功能回退 | +| FE-02 | 日报展示数据来源、生成时间和结构化九段内容 | 修改 | P0 | 否 | 前端 | +| FE-03 | Agent 连续会话与会话归属 | 修改 | P0 | 是 | 前端,可能涉及会话策略 | +| FE-04 | Agent 绑定当前预警上下文 | 修改 | P0 | 是 | 前端,可能涉及 Agent 输入约定 | +| FE-05 | 恢复研判、话术、工单摘要快捷指令 | 修改 | P1 | 是 | 前端提示词 | +| FE-06 | 增加 Agent 能力边界和免责声明 | 修改 | P1 | 否 | 前端 | +| FE-07 | 恢复证据表关键字段 | 修改 | P1 | 是 | 前端 | +| FE-08 | 政策解读入口和数据来源 | 缺失 | P1 | 是 | 前端,可能需后端接口 | +| FE-09 | 系统提示入口 | 缺失 | P2 | 是 | 前端,可能复用通知 | +| FE-10 | 预警处置交互是否恢复原分步流程 | 交互 | P2 | 是 | 前端 | +| FE-11 | 主项目统一前端专项验收 | 验证 | P0 | 否 | 测试 | + +## 3. 直接修改项 + +### FE-02 日报展示数据来源、生成时间和结构化九段内容 + +现状: + +- 日报弹窗主要展示一个可编辑文本区。 +- 模型成功生成时,接口返回 `source=模型`,但页面没有明确展示。 +- `generated_at` 和结构化九段数据没有完整呈现。 + +目标: + +- 生成完成后展示 `日报日期`、`生成时间` 和 `source`。 +- `source=模型` 显示为“大模型生成”,`source=规则模板` 显示为“规则降级”。 +- 保留可编辑正文和邮件发送。 +- 增加九段式结构化预览,至少展示重点风险、未闭环、误报原因、类型分布和规则效果。 +- `data_truncated=true` 时必须持续显示数据截断提示。 + +接口字段已存在,预计只需修改前端。 + +### FE-06 增加 Agent 能力边界和免责声明 + +目标: + +- 明确说明 Agent 只能读取概览、预警和证据。 +- 明确不能确认、调查、关闭、升级预警。 +- 明确结论只是研判草案,不能替代人工复核。 +- 文案遵循主项目品牌和统一组件样式。 + +不涉及接口和权限变化。 + +## 4. 先讨论后实现 + +### FE-03 Agent 连续会话与会话归属 + +当前问题: + +- 每条消息都创建新的随机 `session_id`。 +- 没有连续上下文。 +- 切换页面或刷新后无法恢复会话。 + +需要讨论: + +1. 会话是否按“当前用户 + 预警编号”归属。 +2. 通用风控问答是否使用独立会话。 +3. 会话历史和消息是否只保存在 Redis,还是继续写 MySQL。 +4. 页面刷新后是否需要恢复上下文。 +5. 是否保留多预警并行会话。 + +推荐方向: + +- 每个预警一个会话。 +- 通用问答一个会话。 +- 会话 ID 在页面状态中复用,不再每条消息重新生成。 +- 会话历史遵循主项目现有会话和记忆策略。 + +### FE-04 Agent 绑定当前预警上下文 + +原项目行为: + +- 打开某条预警后,聊天上下文自动切换到该预警。 +- 研判、话术和工单摘要预设自动携带当前预警。 +- 可以主动退出当前预警上下文。 + +主项目当前行为: + +- Agent 面板与预警详情没有上下文绑定。 +- 快捷问题始终是通用问题。 + +需要讨论: + +- 前端把 `alert_no` 拼入提问文本,还是扩展 Agent 请求上下文。 +- 打开预警弹窗是否自动切换 Agent 上下文。 +- Agent 标签页如何显示当前绑定的预警编号。 +- 从预警详情切到通用问答时如何明确提示。 + +### FE-05 恢复研判、话术、工单摘要快捷指令 + +目标快捷指令: + +- 生成研判:命中规则、核心证据、风险判断和人工复核建议。 +- 生成话术:中性审慎的客户回访话术,不预设客户违规。 +- 工单摘要:工单标题、命中规则、关键证据、建议动作和处理时限。 + +讨论项: + +- 是否只在绑定预警后启用。 +- 是否由前端拼提示词,还是由后端提供场景化任务模板。 +- 是否分别映射 `task_type`,以便模型路由和评测。 + +### FE-07 恢复证据表关键字段 + +原项目比主项目多展示的字段: + +- 客户:年龄、职业、总资产、风险等级、测评分数、测评是否过期、行为分。 +- 产品:风险揭示、二次确认、双录要求。 +- 交易:渠道、交易状态、风险揭示、二次确认、确认时间。 +- 预警:优先级、处理时限、证据归档状态。 + +需要讨论: + +- 在当前宽表布局中恢复全部字段,还是使用“主要列 + 详情展开”。 +- 移动端隐藏哪些次要列。 +- 是否通过列配置统一定义,避免每张表重复代码。 + +推荐方向: + +- 桌面端保留核心列,增加“更多字段”展开。 +- 移动端使用卡片详情,不强行展示宽表。 +- 字段配置集中维护。 + +### FE-08 政策解读入口和数据来源 + +原项目包含“政策解读”弹窗,主项目风控工作台没有该入口。 + +需要讨论: + +1. 政策内容来自知识库、独立风控接口还是静态配置。 +2. 是否只允许风控角色查看。 +3. 内容更新是否走知识发布流程。 +4. 是否需要显示来源、版本、生效时间和引用链接。 +5. 是否复用客服 Agent 的政策知识,而不是新增重复数据源。 + +当前主项目没有对应的风控政策 REST 端点,因此该事项可能需要先确定后端来源。 + +### FE-09 系统提示入口 + +原项目通过“系统提示”弹窗查看通知,主项目已把通知记录放进独立标签页。 + +需要讨论: + +- 是否还需要全局系统提示图标。 +- 是否只提示未读通知、系统公告和操作风险。 +- 系统提示与通知记录是否合并。 +- 已读状态当前没有后端能力,是否需要新增。 + +推荐方向: + +- 不复制通知列表,只在统一门户提供未读通知入口。 +- 没有已读能力前,不引入“已读”视觉状态。 + +### FE-10 预警处置交互是否恢复原分步流程 + +现状: + +- 主项目使用统一操作弹窗。 +- 误报理由、结案结论和升级理由通过通用字段提交。 +- 证据上传独立放在预警详情中。 + +原项目: + +- 确认接收有二次确认。 +- 关闭误报、结案和升级使用独立面板。 +- 证据上传附在结案面板中。 + +需要讨论: + +- 统一弹窗是否已足够清晰。 +- 是否恢复确认接收二次确认。 +- 是否把证据上传重新放入结案流程。 +- 是否保持主项目统一交互规范优先。 + +后端接口不变,改造只影响前端交互。 + +## 5. 实施顺序建议 + +### 第一阶段:低风险前端补齐 + +1. 日报展示 `source`、生成时间、截断提示和结构化内容。 +2. Agent 能力边界和免责声明。 +3. 证据表字段配置化。 + +### 第二阶段:Agent 体验改造 + +1. 确定会话归属策略。 +2. 恢复连续会话。 +3. 绑定当前预警上下文。 +4. 恢复研判、话术和工单摘要快捷指令。 + +### 第三阶段:待确认功能 + +1. 政策解读数据源和接口方案。 +2. 系统提示与通知的关系。 +3. 预警处置交互是否恢复分步流程。 + +## 6. 验收清单 + +- 主项目登录、RBAC、数据范围、错误码和会话失效能力不回退。 +- 风险概览显示未闭环口径。 +- 预警队列、证据、通知和 Agent 均使用统一 `api-client`。 +- 日报能直观看出模型生成或规则降级。 +- Agent 能绑定当前预警并连续对话。 +- 研判、话术和工单摘要三个场景可用。 +- 证据关键字段可查询且移动端不溢出。 +- 政策解读和系统提示若实现,必须有明确数据源。 +- 所有按钮权限与后端权限码一致。 +- 关键写操作保留幂等键和成功失败提示。 +- 主项目统一前端专项测试通过。 + +## 7. 已确认决策 + +1. Agent 会话按“用户 + 预警”归属,通用问答和预警问答分开。 +2. 政策解读本期只保留前端入口和说明,不接入具体内容。 +3. 系统提示独立于通知记录,在账号信息旁提供按钮和弹窗列表。 +4. 证据表使用“核心列 + 展开详情”。 +5. 预警处置暂不恢复原分步交互,继续使用主项目统一操作弹窗。 +6. 所有改造必须遵循主项目前端风格,不能影响其他业务前端。 + +## 8. 本轮前端实现状态 + +已完成: + +- Agent 按通用和预警分别维护会话状态。 +- Agent 在同一会话内复用 `session_id`。 +- 打开预警后自动切换预警会话,并支持退出上下文。 +- 增加研判、话术和工单摘要三个预警场景指令。 +- 增加 Agent 能力边界和免责声明。 +- 增加账号信息旁的系统提示按钮和弹窗通知列表。 +- 增加政策解读预留按钮和未开放说明。 +- 证据表改为核心列加展开详情。 +- 日报增加结构化摘要、生成时间和模型来源展示。 + +待联调: + +- 使用主项目统一前端完成桌面端、移动端和权限状态验收。 +- 验证 Agent 连续会话与预警上下文在真实模型下的表现。 +- 政策解读数据源接入属于后续独立事项。 + +## 9. 前端问题记录、解答与处理状态(2026-09-13) + +### BUG-01 证据表展开按钮位于最右侧 + +问题: + +- 当前只能点击表格最右侧“展开”按钮。 +- 宽表需要横向滚动后才能看到按钮,操作不方便。 + +当前实现: + +- `tableMarkup()` 在最右侧增加 `data-expand-row` 按钮。 +- `bindExpandableRows()` 只监听该按钮。 + +结论与方案: + +- 可以改为点击整行任意数据单元格展开或缩回。 +- 建议保留操作列点击行为,其他数据单元格均切换详情。 +- 需要增加键盘可访问性和 `aria-expanded` 状态。 +- 只涉及前端,不修改接口。 + +### BUG-02 展开详情显示数据库原生字段 + +问题: + +- 展开后字段名使用数据库列名。 +- 业务人员无法直接理解字段含义。 + +当前实现: + +- `detailMarkup()` 直接遍历 `Object.entries(item)`。 + +结论与方案: + +- 需要为八类证据分别建立业务字段标签和格式化规则。 +- 金额、比率、时间、布尔值和数组要使用统一格式。 +- 未登记的新字段可以回退显示原名,但必须标记为技术字段。 +- 只涉及前端字段映射。 + +### BUG-03 Agent 缺少手动输入或选择预警编号的入口 + +问题: + +- 当前只有打开预警详情后才会自动进入该预警上下文。 +- 直接进入智能助手时只能进行通用问答。 +- 没有手动填写或选择预警编号的控件。 + +当前实现: + +- `openAlert()` 成功后调用 `switchChatContext(alert.alert_no)`。 +- Agent 请求会附带“当前预警编号:xxx”。 +- 用户主动退出后回到通用会话。 + +结论与方案: + +- 自动绑定可以保留。 +- 建议在智能助手中增加预警编号输入框和“绑定”按钮。 +- 绑定前可调用预警详情接口校验编号是否存在及是否有权限。 +- 也可提供当前预警列表的搜索选择,但首版输入框最直接。 +- 需要明确绑定失败、无权限和编号不存在时的提示。 +- 只涉及前端;现有详情接口足够完成校验。 + +### BUG-04 报错信息持续展示 + +问题: + +- 部分错误提示出现后不会自动消失。 +- 影响表单继续操作。 + +当前实现: + +- Toast 默认 3.6 秒后移除。 +- 操作弹窗和邮件表单使用 `form-alert--visible`,只在下次打开或再次提交时才清除。 +- 内容区 `renderError()` 会持续显示到重试或刷新。 + +结论与方案: + +- 表单内错误应在以下时机清除: + - 用户修改输入。 + - 用户关闭弹窗。 + - 请求重新开始。 + - 操作成功。 +- 内容区错误应保留到重试成功,否则用户会丢失状态说明。 +- 不建议所有错误统一自动消失,需区分“表单错误”和“页面加载错误”。 + +### BUG-05 弹窗打开时 Toast 被遮挡 + +问题: + +- 预警告警操作成功或失败后,通知看不到。 +- 原生 `dialog` 位于浏览器顶层,普通页面 Toast 的 `z-index` 无法超过它。 + +当前实现: + +- `showToast()` 把 Toast 挂在 `document.body`。 +- 操作结果同时通过 Toast 展示。 + +结论与方案: + +- 弹窗内操作结果应优先使用弹窗内部状态区展示。 +- 推荐流程: + - 失败:在弹窗内展示错误,弹窗保持打开。 + - 成功:弹窗内短暂展示成功状态,再关闭并刷新。 +- 关闭后的 Toast 只作为补充提示,不能作为唯一反馈。 +- 不修改后端。 + +### BUG-06 证据时间筛选是否有效 + +问题: + +- 所有证据类型都展示开始时间和结束时间。 +- 用户无法判断时间筛选是否真的生效。 + +当前实现: + +- 时间条件已传给后端。 +- 后端只在以下证据类型应用时间: + - `transactions` + - `capital_flows` + - `login_records` + - `notifications` +- 以下类型当前不应用时间: + - `customers` + - `products` + - `holdings` + - `alerts` + +结论与方案: + +- 时间筛选不是摆设,但对部分证据类型无效。 +- 前端应根据证据类型动态显示或禁用时间筛选。 +- 在 `alerts` 类型下如果要按预警创建时间筛选,需要后端补充参数透传,属于后续后端事项。 +- 当前阶段至少应隐藏无效输入,避免误导业务人员。 + +### 本轮处理结果 + +- BUG-01 已处理:证据表主行可直接点击或使用键盘展开、缩回。 +- BUG-02 已处理:展开详情使用业务字段标签,未知字段明确标记为技术字段。 +- BUG-03 已处理:智能助手增加预警编号输入和绑定入口,并保留打开预警详情后的自动绑定。 +- BUG-04 已处理:表单错误在输入、关闭和成功提交时清理;页面加载错误保留到重试。 +- BUG-05 已处理:成功操作自动关闭相关弹窗后在页面层展示 Toast;失败保留弹窗并显示错误。 +- BUG-06 已处理:删除证据时间筛选框;行为关注度只在客户证据类型显示。 +- BUG-07 已处理:证据快照改用业务字段标签,支持归档信息和合并证据嵌套展示,原始 JSON 改为折叠入口。 +- BUG-08 已处理:证据上传成功自动关闭详情弹窗并显示结果 Toast;失败保留弹窗并定位错误。 +- BUG-09 已处理:预警详情头部展示证据归档状态和归档文件名。 +- BUG-10 已处理:表单错误统一 5 秒自动消失;页面级错误保留到重试。 + +## 10. 新增前端问题记录与解答(2026-09-13) + +### BUG-07 预警详情中的证据快照显示数据库原生字段 + +当前实现: + +- 预警详情通过 `detailSection('证据快照', detail.evidence_snapshot)` 显示。 +- `detailSection()` 对对象直接执行 `JSON.stringify()`。 +- 因此会看到 `product_id`、`ratio`、`device_id`、`merged_alerts` 等技术字段。 + +结论与方案: + +- 需要把证据快照改成业务化展示。 +- 建议按快照类型建立字段映射,例如: + - `product_id` -> 产品记录编号,同时优先展示产品名称。 + - `ratio` -> 命中比例或赎回比例。 + - `amount` -> 交易金额。 + - `average_amount` -> 历史平均金额。 + - `device_id` -> 登录设备。 + - `merged_alerts` -> 多规则合并证据列表。 +- 保留“查看原始 JSON”作为专家模式入口,不默认展示。 +- 只涉及前端,不修改接口。 + +本次修复范围扩展: + +- 客户画像、关联交易、关联产品、关联工单、资金流水、持仓证据、登录证据全部改为业务字段展示。 +- 证据快照中的普通字段、归档信息、合并证据继续单独业务化渲染。 +- 各区块默认不再显示原生 JSON。 +- 原始 JSON 统一移动到“查看原始数据”折叠入口。 + +### BUG-08 上传证据结果位于详情底部,反馈不直观 + +当前实现: + +- 证据上传表单位于预警详情最底部。 +- 成功和失败提示仍靠近表单显示。 +- 页面较长时,用户需要滚动到底部才能看到结果。 + +结论与方案: + +- 成功时自动关闭预警详情弹窗,再在普通页面层显示 Toast,避免被原生 `dialog` 遮挡。 +- 失败时保留详情弹窗,显示明确原因并滚动到错误位置。 +- 成功后刷新预警列表即可看到最新归档状态。 +- 只涉及前端交互和布局。 + +### BUG-09 预警详情缺少证据归档状态 + +当前实现: + +- 列表预警对象已有 `evidence_archived`。 +- 预警详情主体也包含该字段。 +- 前端详情头部没有单独展示。 + +结论与方案: + +- 在预警详情头部增加“证据归档”字段。 +- 建议展示: + - 已归档 + - 未归档 +- 已归档时如果能从 `evidence_snapshot.evidence_archive` 取得文件名,可继续展示。 +- 只涉及前端展示。 + +### BUG-10 错误信息仍持续展示 + +当前实现: + +- 表单错误已在输入、关闭和成功提交时清除。 +- 但没有统一的 5 秒自动消失机制。 +- 页面加载错误和权限错误仍会持续展示到重试或刷新。 + +结论与方案: + +- 表单级错误统一在 5 秒后自动消失。 +- 新错误出现时取消旧错误计时器,避免旧计时器误清新错误。 +- 成功提示也可以统一使用 5 秒自动消失。 +- 页面级错误建议保留: + - 登录失效 + - 无权限 + - 页面数据加载失败 + 这些错误不能自动消失,否则用户会不知道页面为空的原因。 + +### BUG-11 重点预警的展示逻辑 + +当前实现: + +- 数据来自风险概览接口的 `high_priority`。 +- 查询条件为: + - `risk_level=高` + - `open_only=true` + - 最多返回 3 条 +- 排序为先高等级、再按创建时间倒序。 +- 点击“查看详情”打开对应预警弹窗。 + +是否为新前端功能: + +- 是。 +- 原项目单页前端重点展示统一指标和预警队列。 +- 主项目统一风控前端新增了独立的“重点预警”侧栏,用于快速查看最多 3 条高风险未闭环预警。 + +结论: + +- 展示逻辑符合当前后端口径,不需要修改。 +- 如果后续希望调整数量、排序或展示字段,只需修改前端或新增概览参数。 diff --git a/docs/风控业务演示文档/README.md b/docs/风控业务演示文档/README.md index 8e95ca4..918d2a3 100644 --- a/docs/风控业务演示文档/README.md +++ b/docs/风控业务演示文档/README.md @@ -44,6 +44,7 @@ | 21 | 主项目合并后后端必改清单 | 列出合并后必须处理的后端事项 | | 22 | 风控模块需求说明书 | 统一业务需求、规则、边界和验收口径 | | 23 | 风控模块接口文档 | 提供 REST、SSE、字段和联调约定 | +| 24 | 主项目风控前端改造TODO | 记录前端保留项、修改项和待讨论方案 | ## 推荐阅读顺序 @@ -58,3 +59,4 @@ 9. 20:主项目方导入 Agent 工具白名单和意图配置。 10. 21:主项目代码合并后的后端整改与验收。 11. 22、23:分别作为需求交付和接口联调的统一入口。 +12. 24:主项目风控前端改造的事项、决策项和实施顺序。 diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index eb51a6e..8b93847 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -160,6 +160,63 @@ def test_risk_workspace_covers_documented_modules() -> None: assert permission in combined +def test_risk_workspace_has_context_sessions_system_tips_and_expandable_evidence() -> None: + html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8") + source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8") + assert "data-chat-context" in html + assert "data-clear-chat-context" in html + assert "data-alert-prompts" in html + assert "data-system-tips" in html + assert "data-policy-tips" in html + assert "data-report-preview" in html + assert "chatContextKey" in source + assert "session_id: sessionId" in source + assert "session_id: crypto.randomUUID()" not in source + assert "bindExpandableRows" in source + assert "bindAlertContext" in source + assert "actionDialog.close();" in source + assert "alertDialog.close();" in source + assert "reportDialog.close();" in source + assert "5000" in source + assert "证据归档" in source + assert "大模型生成" in source + + +def test_risk_evidence_filters_remove_time_inputs_and_use_business_labels() -> None: + html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8") + source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8") + assert 'name="start_time"' not in html + assert 'name="end_time"' not in html + assert "data-behavior-filter" in html + assert "FIELD_LABELS" in source + assert "技术字段" in source + assert "data-table__expandable-row" in source + assert "row.addEventListener('click'" in source + assert "row.addEventListener('keydown'" in source + + +def test_risk_evidence_snapshot_uses_business_labels_and_nested_sections() -> None: + source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8") + css = (PORTAL / "employee-risk" / "dashboard" / "dashboard.css").read_text(encoding="utf-8") + assert "SNAPSHOT_FIELD_LABELS" in source + assert "renderEvidenceSnapshot" in source + assert "renderBusinessSection" in source + assert "businessRecordMarkup" in source + assert "关联工单" in source + assert "renderMergedEvidence" in source + assert "renderEvidenceArchive" in source + assert "查看原始数据" in source + assert "evidence-snapshot__nested" in css + assert "evidence-snapshot__grid" in css + assert "business-record__grid" in css + + +def test_risk_alert_action_is_first_column() -> None: + source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8") + assert "actionFirst" in source + assert "actionFirst: true" in source + + def test_admin_workspace_is_not_an_identity_placeholder() -> None: html = (PORTAL / "employee-console" / "workspace" / "index.html").read_text(encoding="utf-8") assert "只展示服务端确认的身份边界" not in html