445 lines
27 KiB
JavaScript
445 lines
27 KiB
JavaScript
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
|
||
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';
|
||
import { showToast } from '/static/portal/common/notifications.js';
|
||
import { applyPermissionGuard } from '/static/portal/common/permission-guard.js';
|
||
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', '到账时间']],
|
||
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', '生成时间']],
|
||
notifications: [['notification_no', '通知编号'], ['alert_no', '预警编号'], ['channel', '渠道'], ['send_status', '发送状态'], ['title', '标题'], ['send_time', '发送时间']],
|
||
});
|
||
|
||
const ACTIONS = Object.freeze({
|
||
acknowledge: { endpoint: 'RK007', title: '确认接收预警', copy: '确认后将记录当前处理人与接收时间。', success: '预警已确认接收' },
|
||
investigate: { endpoint: 'RK008', title: '进入调查', copy: '预警将进入人工调查状态。', success: '已进入调查' },
|
||
exclude: { endpoint: 'RK009', title: '关闭误报', copy: '请填写可追溯的误报理由。', label: '误报理由', field: 'reason', success: '已关闭误报' },
|
||
resolve: { endpoint: 'RK010', title: '完成结案', copy: '结案后将按风险等级更新行为分。', label: '处置结论', field: 'resolution', success: '预警已完成结案' },
|
||
escalate: { endpoint: 'RK011', title: '升级处理', copy: '升级是预警标记,不改变当前闭环状态。', label: '升级理由', field: 'reason', success: '预警已升级' },
|
||
});
|
||
|
||
function displayValue(value, key = '') {
|
||
if (value === null || value === undefined || value === '') return '--';
|
||
if (Array.isArray(value)) return value.length ? value.join('、') : '--';
|
||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||
if (typeof value === 'object') return JSON.stringify(value);
|
||
if (key.endsWith('_at') || key.endsWith('_time') || key === 'created_at' || key === 'updated_at') return formatDateTime(value);
|
||
return String(value);
|
||
}
|
||
|
||
function riskTag(level) {
|
||
const key = level === '高' || level === '高风险' ? 'high' : level === '中' || level === '中风险' ? 'medium' : 'low';
|
||
const label = String(level || '--').endsWith('风险') ? level : `${level || '--'}风险`;
|
||
return `<span class="tag status-tag--${key}">${escapeHtml(label)}</span>`;
|
||
}
|
||
|
||
function statusTag(status) {
|
||
const failed = status === '发送失败';
|
||
return `<span class="tag ${failed ? 'status-tag--failed' : 'tag--neutral'}">${escapeHtml(status || '--')}</span>`;
|
||
}
|
||
|
||
function tableMarkup(items, columns, { action } = {}) {
|
||
const headers = columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join('');
|
||
const rows = items.map((item) => {
|
||
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 `<td class="ellipsis-cell" title="${escapeHtml(displayValue(item[key], key))}">${content}</td>`;
|
||
}).join('');
|
||
const actionCell = action ? `<td><button class="button table-action" type="button" data-row-action="${escapeHtml(action.key(item))}">${escapeHtml(action.label)}</button></td>` : '';
|
||
return `<tr>${cells}${actionCell}</tr>`;
|
||
}).join('');
|
||
return `<div class="data-table-wrap"><table class="data-table"><thead><tr>${headers}${action ? '<th>操作</th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||
}
|
||
|
||
if (requireRiskStaff()) {
|
||
mountShell({ active: 'risk-dashboard', mode: 'risk' });
|
||
const context = getAuthContext();
|
||
const state = {
|
||
alert: { cursors: [null], page: 0, next: null, filters: {} },
|
||
evidence: { cursors: [null], page: 0, next: null, source: 'customers', filters: {} },
|
||
notification: { cursors: [null], page: 0, next: null, filters: {} },
|
||
currentAlert: null,
|
||
action: null,
|
||
controllers: new Set(),
|
||
};
|
||
const metrics = document.querySelector('[data-risk-metrics]');
|
||
const highPriority = document.querySelector('[data-high-priority]');
|
||
const alertTable = document.querySelector('[data-alert-table]');
|
||
const evidenceTable = document.querySelector('[data-evidence-table]');
|
||
const notificationTable = document.querySelector('[data-notification-table]');
|
||
const alertDialog = document.querySelector('[data-alert-dialog]');
|
||
const actionDialog = document.querySelector('[data-action-dialog]');
|
||
const reportDialog = document.querySelector('[data-report-dialog]');
|
||
|
||
function controller() {
|
||
const item = new AbortController();
|
||
state.controllers.add(item);
|
||
item.signal.addEventListener('abort', () => state.controllers.delete(item), { once: true });
|
||
return item;
|
||
}
|
||
|
||
function hasPermission(code) {
|
||
return getPermissions().includes(code);
|
||
}
|
||
|
||
async function hydrateIdentity() {
|
||
const response = await apiClient.get('A038', { pathParams: { userId: context.userId } });
|
||
const identity = response.data;
|
||
updateAuthPermissions(identity.permissions, identity.data_scope);
|
||
document.querySelector('[data-staff-name]').textContent = `${identity.username} · ${identity.user_no}`;
|
||
document.querySelector('[data-data-scope]').textContent = `数据范围:${identity.data_scope || '--'} · 权限 ${identity.permissions.length} 项`;
|
||
applyPermissionGuard();
|
||
}
|
||
|
||
function renderOverview(data) {
|
||
const levels = data.levels || {};
|
||
metrics.innerHTML = [
|
||
['未闭环预警', data.total ?? 0, '待处理与调查中的全部预警', ''],
|
||
['高风险', levels['高风险'] ?? 0, `中风险 ${levels['中风险'] ?? 0} · 低风险 ${levels['低风险'] ?? 0}`, 'negative'],
|
||
['待处理', data.pending ?? 0, '尚未进入调查', ''],
|
||
['已超时', data.overdue ?? 0, '超过处置截止时间', data.overdue ? 'negative' : ''],
|
||
].map(([label, value, meta, tone]) => `<article class="metric-card"><p class="metric-card__label">${label}</p><p class="metric-card__value${tone ? ` metric-card__value--${tone}` : ''}">${escapeHtml(value)}</p><p class="metric-card__meta">${escapeHtml(meta)}</p></article>`).join('');
|
||
const important = Array.isArray(data.high_priority) ? data.high_priority : [];
|
||
if (!important.length) {
|
||
renderEmpty(highPriority, '暂无重点预警', '当前没有高风险未闭环事项。');
|
||
return;
|
||
}
|
||
highPriority.innerHTML = `<ul class="operations-list">${important.map((item) => `<li class="operations-list__item"><strong>${escapeHtml(item.alert_no)}</strong><span>${escapeHtml(item.evidence_summary || item.alert_type || '待人工研判')}</span><button class="button table-action" type="button" data-priority-alert="${escapeHtml(item.alert_no)}">查看详情</button></li>`).join('')}</ul>`;
|
||
highPriority.querySelectorAll('[data-priority-alert]').forEach((button) => button.addEventListener('click', () => openAlert(button.dataset.priorityAlert)));
|
||
}
|
||
|
||
async function loadOverview() {
|
||
renderLoading(metrics, 4);
|
||
renderLoading(highPriority, 2);
|
||
try {
|
||
const response = await apiClient.get('RK001');
|
||
renderOverview(response.data || {});
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
renderError(metrics, error, loadOverview);
|
||
renderError(highPriority, error, loadOverview);
|
||
}
|
||
}
|
||
|
||
function updatePager(kind, meta) {
|
||
const current = state[kind];
|
||
current.next = meta.next_cursor || null;
|
||
document.querySelector(`[data-${kind}-prev]`).disabled = current.page === 0;
|
||
document.querySelector(`[data-${kind}-next]`).disabled = !current.next;
|
||
}
|
||
|
||
async function loadAlerts() {
|
||
renderLoading(alertTable, 4);
|
||
try {
|
||
const response = await apiClient.get('RK002', { query: { ...state.alert.filters, cursor: state.alert.cursors[state.alert.page], limit: 5 } });
|
||
const items = Array.isArray(response.data) ? response.data : [];
|
||
if (!items.length) renderEmpty(alertTable, '暂无预警', '当前筛选条件下没有风险预警。');
|
||
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 } });
|
||
alertTable.querySelectorAll('[data-row-action]').forEach((button) => button.addEventListener('click', () => openAlert(button.dataset.rowAction)));
|
||
}
|
||
updatePager('alert', response.meta || {});
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
renderError(alertTable, error, loadAlerts);
|
||
}
|
||
}
|
||
|
||
async function loadEvidence() {
|
||
renderLoading(evidenceTable, 4);
|
||
try {
|
||
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 = `<div class="evidence-table">${tableMarkup(items, EVIDENCE_COLUMNS[state.evidence.source])}</div>`;
|
||
updatePager('evidence', response.meta || {});
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
renderError(evidenceTable, error, loadEvidence);
|
||
}
|
||
}
|
||
|
||
async function loadNotifications() {
|
||
renderLoading(notificationTable, 4);
|
||
try {
|
||
const response = await apiClient.get('RK005', { query: { ...state.notification.filters, cursor: state.notification.cursors[state.notification.page], limit: 10 } });
|
||
const items = Array.isArray(response.data) ? response.data : [];
|
||
if (!items.length) renderEmpty(notificationTable, '暂无通知记录', '扫描产生的站内与邮件通知将在这里显示。');
|
||
else {
|
||
const enriched = items.map((item) => ({ ...item, fail_reason: item.fail_reason || '--' }));
|
||
notificationTable.innerHTML = tableMarkup(enriched, [['notification_no', '通知编号'], ['alert_no', '预警编号'], ['channel', '渠道'], ['send_status', '发送状态'], ['receiver_email', '收件邮箱'], ['fail_reason', '失败原因'], ['send_time', '发送时间']]);
|
||
}
|
||
updatePager('notification', response.meta || {});
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
renderError(notificationTable, error, loadNotifications);
|
||
}
|
||
}
|
||
|
||
function detailSection(title, value) {
|
||
if (value === null || value === undefined || (Array.isArray(value) && !value.length)) return '';
|
||
return `<section><h3 class="section-heading__title">${escapeHtml(title)}</h3><pre class="json-view">${escapeHtml(JSON.stringify(value, null, 2))}</pre></section>`;
|
||
}
|
||
|
||
function renderAlertActions(detail) {
|
||
const alert = detail.alert || detail;
|
||
const open = ['待处理', '调查中'].includes(alert.status);
|
||
const acknowledged = alert.ack_status === '已确认';
|
||
const buttons = [];
|
||
if (hasPermission(PERM_CODES.RISK_ALERT_WRITE)) {
|
||
if (alert.status === '待处理' && !acknowledged) buttons.push(['acknowledge', '确认接收']);
|
||
if (alert.status === '待处理' && acknowledged) buttons.push(['investigate', '进入调查']);
|
||
if (open && acknowledged) buttons.push(['exclude', '关闭误报']);
|
||
if (alert.status === '调查中' && acknowledged) buttons.push(['resolve', '完成结案']);
|
||
if (open && acknowledged && !alert.is_escalated) buttons.push(['escalate', '升级处理']);
|
||
}
|
||
document.querySelector('[data-alert-actions]').innerHTML = `${buttons.map(([key, label]) => `<button class="button${key === 'resolve' ? ' button--primary' : ''}${key === 'exclude' ? ' button--danger' : ''}" type="button" data-alert-action="${key}">${label}</button>`).join('')}<button class="button" type="button" data-close-alert>关闭</button>`;
|
||
document.querySelectorAll('[data-alert-action]').forEach((button) => button.addEventListener('click', () => openAction(button.dataset.alertAction)));
|
||
document.querySelectorAll('[data-close-alert]').forEach((button) => button.addEventListener('click', () => alertDialog.close()));
|
||
}
|
||
|
||
async function openAlert(alertNo) {
|
||
if (!alertDialog.open) alertDialog.showModal();
|
||
const target = document.querySelector('[data-alert-detail]');
|
||
renderLoading(target, 3);
|
||
try {
|
||
const response = await apiClient.get('RK003', { pathParams: { alertNo } });
|
||
state.currentAlert = response.data;
|
||
const detail = response.data || {};
|
||
const alert = detail.alert || detail;
|
||
target.innerHTML = `<dl class="detail-grid"><div><dt>预警编号</dt><dd>${escapeHtml(alert.alert_no)}</dd></div><div><dt>风险等级</dt><dd>${riskTag(alert.risk_level)}</dd></div><div><dt>客户</dt><dd>${escapeHtml(alert.customer_no || '--')} · ${escapeHtml(alert.customer_name || '--')}</dd></div><div><dt>产品</dt><dd>${escapeHtml(alert.product_code || '--')} · ${escapeHtml(alert.product_name || '--')}</dd></div><div><dt>处置状态</dt><dd>${statusTag(alert.status)}</dd></div><div><dt>确认状态</dt><dd>${escapeHtml(alert.ack_status || '--')}</dd></div><div><dt>命中规则</dt><dd>${escapeHtml(displayValue(alert.rule_codes))}</dd></div><div><dt>升级标记</dt><dd>${alert.is_escalated ? '已升级' : '未升级'}</dd></div></dl><section><h3 class="section-heading__title">证据摘要</h3><p>${escapeHtml(alert.evidence_summary || '暂无摘要')}</p></section>${detail.evidence_truncated?.length ? `<p class="form-alert form-alert--visible">部分证据达到返回上限:${escapeHtml(detail.evidence_truncated.join('、'))}</p>` : ''}${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) ? `<form class="risk-upload" data-evidence-upload><label class="form-field"><span class="form-field__label">补充证据</span><input class="form-field__input" type="file" name="evidence_file" accept="image/*,.pdf,.doc,.docx" required></label><button class="button" type="submit">上传归档</button></form>` : ''}`;
|
||
target.querySelector('[data-evidence-upload]')?.addEventListener('submit', uploadEvidence);
|
||
renderAlertActions(detail);
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
renderError(target, error, () => openAlert(alertNo));
|
||
}
|
||
}
|
||
|
||
function openAction(key) {
|
||
const config = ACTIONS[key];
|
||
if (!config || !state.currentAlert) return;
|
||
state.action = key;
|
||
document.querySelector('[data-action-title]').textContent = config.title;
|
||
document.querySelector('[data-action-copy]').textContent = config.copy;
|
||
const field = document.querySelector('[data-action-field]');
|
||
field.hidden = !config.field;
|
||
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');
|
||
actionDialog.showModal();
|
||
}
|
||
|
||
async function submitAction(event) {
|
||
event.preventDefault();
|
||
const config = ACTIONS[state.action];
|
||
const alertNo = state.currentAlert?.alert?.alert_no || state.currentAlert?.alert_no;
|
||
if (!config || !alertNo) return;
|
||
const form = event.currentTarget;
|
||
const value = String(new FormData(form).get('reason') || '').trim();
|
||
const body = config.field ? { [config.field]: value } : {};
|
||
const alert = form.querySelector('[data-action-alert]');
|
||
const submit = form.querySelector('[type="submit"]');
|
||
if (config.field && !value) {
|
||
alert.textContent = `请填写${config.label}。`;
|
||
alert.classList.add('form-alert--visible');
|
||
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)]);
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
alert.textContent = error.status === 409 ? '预警可能已处理,请核对当前状态。' : error.message;
|
||
alert.classList.add('form-alert--visible');
|
||
} finally { submit.disabled = false; }
|
||
}
|
||
|
||
async function uploadEvidence(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const alertNo = state.currentAlert?.alert?.alert_no || state.currentAlert?.alert_no;
|
||
const submit = form.querySelector('[type="submit"]');
|
||
submit.disabled = true;
|
||
try {
|
||
await apiClient.upload('RK012', new FormData(form), { pathParams: { alertNo } });
|
||
showToast('证据上传成功');
|
||
await openAlert(alertNo);
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
showToast(error.message || '证据上传失败', 'error');
|
||
} finally { submit.disabled = false; }
|
||
}
|
||
|
||
async function scan() {
|
||
const button = document.querySelector('[data-scan]');
|
||
button.disabled = true;
|
||
button.textContent = '扫描中';
|
||
try {
|
||
const response = await apiClient.post('RK006', {});
|
||
showToast(`预警扫描完成,新建 ${response.data?.created_count ?? 0} 条`);
|
||
await Promise.all([loadOverview(), loadAlerts(), loadNotifications()]);
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
showToast(error.message || '预警扫描失败', 'error');
|
||
} finally {
|
||
button.textContent = '手动扫描';
|
||
applyPermissionGuard();
|
||
}
|
||
}
|
||
|
||
async function generateReport() {
|
||
const form = document.querySelector('[data-report-form]');
|
||
const content = form.elements.content;
|
||
const status = document.querySelector('[data-report-status]');
|
||
const button = document.querySelector('[data-generate-report]');
|
||
const reportDate = form.elements.report_date.value || null;
|
||
const item = controller();
|
||
button.disabled = true;
|
||
content.value = '';
|
||
status.textContent = '正在生成日报';
|
||
try {
|
||
await apiClient.stream('RK014', { report_date: reportDate }, {
|
||
signal: item.signal,
|
||
onEvent: ({ type, data }) => {
|
||
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 ? '日报生成完成,数据已达到查询上限' : '日报生成完成';
|
||
}
|
||
},
|
||
});
|
||
showToast('日报生成完成');
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
status.textContent = error.message || '日报生成失败';
|
||
showToast(status.textContent, 'error');
|
||
} finally { button.disabled = false; state.controllers.delete(item); }
|
||
}
|
||
|
||
async function sendReport(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
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');
|
||
submit.disabled = true;
|
||
try {
|
||
await apiClient.post('RK015', { recipients, subject: form.elements.subject.value.trim(), content: form.elements.content.value.trim() });
|
||
showToast('日报邮件发送成功');
|
||
} catch (error) {
|
||
apiClient.reportError(error);
|
||
alert.textContent = error.message || '日报邮件发送失败';
|
||
alert.classList.add('form-alert--visible');
|
||
} finally { submit.disabled = false; }
|
||
}
|
||
|
||
function appendMessage(kind, text) {
|
||
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);
|
||
log.scrollTop = log.scrollHeight;
|
||
return 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', '正在检索风险数据并形成研判草案');
|
||
submit.disabled = true;
|
||
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 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 === '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);
|
||
}
|
||
}
|
||
},
|
||
});
|
||
} catch (error) {
|
||
reply.className = 'risk-message risk-message--error';
|
||
reply.textContent = error.name === 'AbortError' ? '请求仍在处理中,请确认 Agent Worker 已启动后重试。' : (error.message || '风险助手运行失败');
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
state.controllers.delete(item);
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
function resetPager(kind) {
|
||
state[kind].cursors = [null];
|
||
state[kind].page = 0;
|
||
state[kind].next = null;
|
||
}
|
||
|
||
function bindPager(kind, loader) {
|
||
document.querySelector(`[data-${kind}-prev]`).addEventListener('click', () => { if (state[kind].page > 0) { state[kind].page -= 1; loader(); } });
|
||
document.querySelector(`[data-${kind}-next]`).addEventListener('click', () => { if (state[kind].next) { state[kind].cursors[state[kind].page + 1] = state[kind].next; state[kind].page += 1; loader(); } });
|
||
}
|
||
|
||
document.querySelectorAll('[data-tab]').forEach((tab) => tab.addEventListener('click', () => {
|
||
document.querySelectorAll('[data-tab]').forEach((item) => item.setAttribute('aria-selected', String(item === tab)));
|
||
document.querySelectorAll('[data-view]').forEach((view) => { view.hidden = view.dataset.view !== tab.dataset.tab; });
|
||
if (tab.dataset.tab === 'evidence' && !evidenceTable.dataset.loaded) { evidenceTable.dataset.loaded = 'true'; loadEvidence(); }
|
||
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-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);
|
||
bindPager('notification', loadNotifications);
|
||
document.querySelector('[data-scan]').addEventListener('click', scan);
|
||
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-action-form]').addEventListener('submit', submitAction);
|
||
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-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)));
|
||
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();
|
||
}
|