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 `${items.map((item) => `- ${escapeHtml(item.alert_id || item.alert_no || '--')} · ${escapeHtml(item.alert_level || '--')} · ${escapeHtml(item.status || '--')} · ${escapeHtml(item.triggered_rules || item.rule_codes || '--')}
`).join('')}
`;
+ }
+
+ 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)} 条
${(falsePositive.reasons || []).map((item) => `- ${escapeHtml(item.alert_id || '--')} · ${escapeHtml(item.reason || '未填写')}
`).join('') || '- 无
'}
`,
+ `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. 规则效果
${rules.map((item) => `- ${escapeHtml(item.rule_code || '--')} · 当日 ${escapeHtml(item.daily_hits ?? 0)} · 未闭环 ${escapeHtml(item.unresolved ?? 0)} · 误报 ${escapeHtml(item.false_positives ?? 0)}
`).join('') || '- 无
'}
`,
+ `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.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 @@