## 现象
"单据核对与运营动作"里的单据信息全是 `--`:
```
20260914-001-A01
subscription · -- · --
申请日期 -- 申购金额 / 赎回份额 -- / --
机构 -- 基金代码 --
```
而 OCR 识别字段里这些值都**有**(基金代码 15911 / 申请日期 2026-09-11 /
申购金额 5000.00 / 机构 星澜财富服务中心)。所以不是"没保存",是**渲染时取错了数据源**。
## 根因:同一个单据,两个接口给的不是同一份字段
| 接口 | `attachments[].documents[]` 的字段 |
|---|---|
| `GET /mails/{id}`(`_mail_detail`) | **只有摘要 4 个键**:`task_id` / `document_type` / `status` / `operator_decision` |
| `GET /mails/{id}/recognition-fields`(`_recognition_payload`) | **全部标准化字段**:`fund_code` / `fund_name` / `application_no` / `application_date` / `agency` / `subscription_amount_yuan` … |
`renderDocuments()` 渲染单据 kv 用的是 `state.documents`,而 `loadMail()` 只从
**邮件详情**那一份构建它 ⇒ 标准化字段根本不在对象里,只能显示 `--`。
前端其实**已经有** `syncDocumentsFromRecognition()` 想做这件事,但 `loadMail()` 里
是「先 `syncDocumentsFromRecognition(...)`、紧接着又用邮件详情重建 `state.documents`」
—— 合并结果被后一行覆盖掉了(这也是保存识别字段后那次同步没生效的原因)。
## 修法
1. `loadMail()`:构建完 `state.documents`(邮件侧摘要)之后**再调用一次**
`syncDocumentsFromRecognition(state.recognition)`,把识别侧的标准字段合并进来;
2. `syncDocumentsFromRecognition()` 由"整段替换"改为**逐条按 `task_id` 合并**:
- 两侧都有 → 识别侧字段优先,摘要里独有的键(`status` / `operator_decision`)保留;
- 只有摘要侧 → 原样保留(不因为识别接口没提到就丢单据);
- 只有识别侧 → 也补进来(例如刚生成、摘要还没刷新);
- 识别接口无 attachments(请求失败等)→ 早退,**不清空**已有单据。
## 验证
`node --check offsite.js` 通过;并用 Node **从源文件抽出该函数**(不另抄一份逻辑)灌入
线上两个接口的真实载荷形态,6+4+3+1 项断言全部通过:
- 摘要 + 识别侧全字段 → `fund_code=15911` / `application_date=2026-09-11` /
`agency=星澜财富服务中心` / `subscription_amount_yuan=5000.0000`,且
`status=planned`、`operator_decision=未处理` 未被覆盖,`attachment_id` 已补上;
- 识别接口返回空 → 邮件侧摘要仍在;
- 邮件侧独有单据保留、识别侧独有单据出现。
前端相关测试:`tests/unit/api/test_portal_frontend.py` → 45 passed, 1 failed
(仍是组员在改的投顾页,与本次无关);`pytest tests/unit/api -k "offsite or operations"`
→ 1 passed。
## 说明:为什么没改后端
也可以让 `_mail_detail` 直接带上标准化字段,但那会改动已被文档化的接口载荷形状;
而前端本来就有合并函数、意图明确(`save-ocr` 路径早就在调用它),
因此按"恢复原有意图 + 按 task_id 正确合并"来修,零接口契约风险。
673 lines
42 KiB
JavaScript
673 lines
42 KiB
JavaScript
import { apiClient } from '/static/portal/common/api-client.js?v=20260921';
|
||
import { getAuthContext, requireOperator } 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 { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
|
||
|
||
const PREVIEW_TYPES = new Set(['application/pdf', 'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif']);
|
||
const NOTIFICATION_TYPES = [['mail_return', '邮件回执'], ['normal_return', '正常回执'], ['exception_return', '异常回执'], ['risk', '风险通知'], ['settlement', '清算通知']];
|
||
const NOTIFICATION_LABELS = Object.fromEntries(NOTIFICATION_TYPES);
|
||
const MAIL_STATUS_FILTERS = [
|
||
['exception', '有异常'],
|
||
['processed', '已处理'],
|
||
['processing', '处理中'],
|
||
['inbox', '已入库'],
|
||
['replied', '已回执'],
|
||
['all', '全部邮件'],
|
||
];
|
||
|
||
if (requireOperator()) {
|
||
mountShell({ active: 'operator-offsite', mode: 'operator' });
|
||
const context = getAuthContext();
|
||
const operatorId = String(context?.userId || context?.username || '');
|
||
const pageParams = new URLSearchParams(location.search);
|
||
const state = {
|
||
page: Number(pageParams.get('page')) > 0 ? Number(pageParams.get('page')) : 1, pageSize: 10, total: 0, mails: [], mailbox: null, activeMailStatus: 'all',
|
||
mailDetails: {},
|
||
selectedMailId: '', mail: null, recognition: null, documents: [], nl2sql: {}, rules: {},
|
||
ocrDrafts: {}, nlDrafts: {}, notice: null, noticeTaskId: '', noticeDraft: '', stats: null,
|
||
statsForm: { fundCode: '', applicationDate: '' },
|
||
recalculatingTasks: new Set(),
|
||
requestedMailId: pageParams.get('mail_id') || '',
|
||
};
|
||
const root = document.querySelector('main');
|
||
const targets = {
|
||
metrics: document.querySelector('[data-offsite-metrics]'),
|
||
statusTabs: document.querySelector('[data-mail-status-tabs]'),
|
||
list: document.querySelector('[data-mail-list]'),
|
||
pagination: document.querySelector('[data-mail-pagination]'),
|
||
detail: document.querySelector('[data-mail-detail]'),
|
||
documents: document.querySelector('[data-document-list]'),
|
||
noticePanel: document.querySelector('[data-notification-panel]'),
|
||
notice: document.querySelector('[data-notification]'),
|
||
statistics: document.querySelector('[data-statistics]'),
|
||
};
|
||
document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员';
|
||
document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
|
||
|
||
function value(item, fallback = '--') {
|
||
if (item === null || item === undefined || item === '') return fallback;
|
||
if (Array.isArray(item)) return item.length ? item.join('、') : fallback;
|
||
if (typeof item === 'object') return JSON.stringify(item);
|
||
return String(item);
|
||
}
|
||
|
||
function tag(text, tone = '') {
|
||
return `<span class="tag ${tone ? `status-tag--${tone}` : 'tag--neutral'}">${escapeHtml(text || '--')}</span>`;
|
||
}
|
||
|
||
function actionButton(label, action, tone = '', disabled = false) {
|
||
return `<button class="button table-action${tone ? ` button--${tone}` : ''}" type="button" data-action="${escapeHtml(action)}"${disabled ? ' disabled' : ''}>${escapeHtml(label)}</button>`;
|
||
}
|
||
|
||
function fieldStatusLabel(status) {
|
||
return {
|
||
success: '成功',
|
||
query_failed: '失败',
|
||
not_queried: '未查询',
|
||
pending: '待查询',
|
||
corrected: '人工修正',
|
||
}[String(status || '').toLowerCase()] || '未查询';
|
||
}
|
||
|
||
function documentStatusLabel(status) {
|
||
return {
|
||
operator_confirmed: '已处理',
|
||
recognition_exception: '识别异常',
|
||
recognition_review: '待复核',
|
||
recognition_retrying: '识别重试中',
|
||
query_failed: '查询失败',
|
||
planned: '已入库',
|
||
recognized: '已入库',
|
||
received: '已入库',
|
||
normal_return_sent: '已回执',
|
||
completed: '已完成',
|
||
deleted: '已删除',
|
||
processing: '处理中',
|
||
}[String(status || '').toLowerCase()] || status || '已读取';
|
||
}
|
||
|
||
function documentNotificationType(document) {
|
||
if (document?.operator_decision === '确认正常') return 'normal_return';
|
||
if (document?.operator_decision === '确认异常') return 'exception_return';
|
||
return '';
|
||
}
|
||
|
||
function mailDocuments(mail) {
|
||
return (mail?.attachments || []).flatMap((attachment) => attachment.documents || []);
|
||
}
|
||
|
||
function mailDisplayStatus(mail) {
|
||
const internalStatus = String(mail?.status || '');
|
||
const documents = mailDocuments(state.mailDetails[mail?.mail_id]);
|
||
const decisions = documents.map((item) => item.operator_decision).filter(Boolean);
|
||
|
||
if (internalStatus === 'processing') {
|
||
if (decisions.includes('确认异常')) return { key: 'exception', label: '有异常', tone: 'high' };
|
||
if (decisions.length && decisions.every((decision) => decision === '确认正常')) {
|
||
return { key: 'processed', label: '已处理', tone: 'low' };
|
||
}
|
||
return { key: 'processing', label: '处理中', tone: 'medium' };
|
||
}
|
||
if (internalStatus === 'recognized' || internalStatus === 'received') {
|
||
return { key: 'inbox', label: '已入库', tone: 'active' };
|
||
}
|
||
if (internalStatus === 'normal_return_sent' || internalStatus === 'completed') {
|
||
return { key: 'replied', label: '已回执', tone: 'low' };
|
||
}
|
||
if (internalStatus === 'deleted') return { key: 'deleted', label: '已删除', tone: 'neutral' };
|
||
return { key: 'processing', label: '处理中', tone: 'medium' };
|
||
}
|
||
|
||
function documentDisplayStatus(document) {
|
||
if (document.operator_decision === '确认异常') return { label: '有异常', tone: 'high' };
|
||
if (document.operator_decision === '确认正常') return { label: '已处理', tone: 'low' };
|
||
if (document.status === 'recognized' || document.status === 'planned') {
|
||
return { label: '已入库', tone: 'active' };
|
||
}
|
||
return { label: '处理中', tone: 'medium' };
|
||
}
|
||
|
||
function ruleComparison(row) {
|
||
const calculation = row?.calculation || {};
|
||
if (calculation.实际值 !== undefined && calculation.规则值 !== undefined) {
|
||
return {
|
||
actual: calculation.实际值,
|
||
rule: calculation.规则值,
|
||
expression: calculation.比较,
|
||
};
|
||
}
|
||
if (row?.rule_code === 'subscription_minimum_amount') {
|
||
return {
|
||
actual: row.document_value?.申购金额元,
|
||
rule: '> 1 元',
|
||
expression: row.document_value?.申购金额元 === undefined
|
||
? ''
|
||
: `${row.document_value.申购金额元} > 1 元`,
|
||
};
|
||
}
|
||
if (calculation.申购后持有比例 !== undefined) {
|
||
const actual = `${Number(calculation.申购后持有比例) * 100}%`;
|
||
return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` };
|
||
}
|
||
if (calculation.本次申购份额 !== undefined && calculation.份额上限 !== undefined) {
|
||
return {
|
||
actual: calculation.本次申购份额,
|
||
rule: calculation.份额上限,
|
||
expression: `${calculation.本次申购份额} ≤ ${calculation.份额上限}`,
|
||
};
|
||
}
|
||
if (calculation.赎回比例 !== undefined) {
|
||
const actual = `${Number(calculation.赎回比例) * 100}%`;
|
||
return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` };
|
||
}
|
||
if (row?.rule_code === 'redemption_available_quantity') {
|
||
return {
|
||
actual: row.document_value?.赎回份额,
|
||
rule: row.database_value?.当前最新可用份额,
|
||
expression: row.document_value?.赎回份额 === undefined
|
||
|| row.database_value?.当前最新可用份额 === undefined
|
||
? ''
|
||
: `${row.document_value.赎回份额} ≤ ${row.database_value.当前最新可用份额}`,
|
||
};
|
||
}
|
||
return { actual: '', rule: '', expression: '' };
|
||
}
|
||
|
||
function numericValue(value) {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const normalized = Number(String(value).replace(/,/g, '').replace(/%$/, ''));
|
||
return Number.isFinite(normalized) ? normalized : null;
|
||
}
|
||
|
||
function formatFixed(value, digits = 2) {
|
||
const number = numericValue(value);
|
||
if (number === null) return value === undefined || value === null || value === '' ? '--' : String(value);
|
||
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||
}
|
||
|
||
function formatAmount(value) {
|
||
return formatFixed(value, 2);
|
||
}
|
||
|
||
function formatNav(value) {
|
||
return formatFixed(value, 4);
|
||
}
|
||
|
||
function isNumericDisplayField(name) {
|
||
return /金额|份额|总份额|持有份额|可用份额|subscription_amount_yuan|redemption_amount_yuan|redemption_shares/.test(String(name || ''));
|
||
}
|
||
|
||
function formatFieldValue(name, value) {
|
||
if (/净值|nav/i.test(String(name || ''))) return formatNav(value);
|
||
if (isNumericDisplayField(name)) return formatAmount(value);
|
||
return value === undefined || value === null ? '' : String(value);
|
||
}
|
||
|
||
function formatExpression(value) {
|
||
return String(value || '').replace(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?%?/gi, (match) => {
|
||
const isPercent = match.endsWith('%');
|
||
const number = numericValue(match);
|
||
if (number === null) return match;
|
||
return `${number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${isPercent ? '%' : ''}`;
|
||
});
|
||
}
|
||
|
||
function renderDocumentValue(row) {
|
||
const documentValue = row?.document_value || {};
|
||
const subscriptionAmount = documentValue.申购金额 ?? documentValue.申购金额元 ?? documentValue.subscription_amount_yuan;
|
||
if (subscriptionAmount !== undefined && subscriptionAmount !== null && subscriptionAmount !== '') {
|
||
return `申购金额:${formatAmount(subscriptionAmount)}`;
|
||
}
|
||
const redemptionAmount = documentValue.赎回金额 ?? documentValue.赎回金额元 ?? documentValue.redemption_amount_yuan ?? documentValue.赎回份额;
|
||
if (redemptionAmount !== undefined && redemptionAmount !== null && redemptionAmount !== '') {
|
||
return `赎回金额:${formatAmount(redemptionAmount)}`;
|
||
}
|
||
return '--';
|
||
}
|
||
|
||
function renderRuleComparison(row) {
|
||
const comparison = ruleComparison(row);
|
||
if (!comparison.actual && !comparison.rule) {
|
||
return '<span class="operator-inline-note">暂无可对比数据</span>';
|
||
}
|
||
const conclusion = row.result === '正常' ? '满足规则' : row.result === '异常' ? '不满足规则' : '无法判断';
|
||
const tone = row.result === '正常' ? 'operator-comparison--normal' : row.result === '异常' ? 'operator-comparison--abnormal' : 'operator-comparison--unknown';
|
||
return `<div class="operator-comparison ${tone}">
|
||
<div class="operator-comparison__expression">${escapeHtml(comparison.expression ? `${formatExpression(comparison.expression)} · ${conclusion}` : conclusion)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderStatusTabs() {
|
||
targets.statusTabs.innerHTML = MAIL_STATUS_FILTERS.map(([key, label]) => `
|
||
<button class="operator-tab" type="button" role="tab" aria-selected="${state.activeMailStatus === key}" data-mail-status="${key}">${label}</button>
|
||
`).join('');
|
||
}
|
||
|
||
function errorText(error) {
|
||
return `<div class="operator-warning">${escapeHtml(error?.message || '请求未完成')}</div>`;
|
||
}
|
||
|
||
function renderMetrics() {
|
||
const selectedDocs = state.documents.length;
|
||
const pending = state.documents.filter((item) => item.operator_decision === '未处理' || !item.operator_decision).length;
|
||
const blocked = Boolean(state.mailbox?.blocked);
|
||
targets.metrics.innerHTML = [
|
||
['业务邮件', state.total, `当前第 ${state.page} 页`],
|
||
['当前单据', selectedDocs, state.selectedMailId ? '来自当前邮件' : '请选择邮件'],
|
||
['待人工确认', pending, '以服务端单据状态为准'],
|
||
['收件状态', blocked ? '已阻塞' : (state.mailbox?.monitoring ? '运行中' : '未启用'), blocked ? '需要恢复游标' : '服务端状态'],
|
||
].map(([label, current, meta]) => `<article class="metric-card"><p class="metric-card__label">${label}</p><p class="metric-card__value">${escapeHtml(String(current))}</p><p class="metric-card__meta">${escapeHtml(meta)}</p></article>`).join('');
|
||
}
|
||
|
||
function renderMailList() {
|
||
const visibleMails = state.mails.filter((mail) => (
|
||
state.activeMailStatus === 'all' || mailDisplayStatus(mail).key === state.activeMailStatus
|
||
));
|
||
if (!visibleMails.length) {
|
||
const activeLabel = MAIL_STATUS_FILTERS.find(([key]) => key === state.activeMailStatus)?.[1] || '业务';
|
||
renderEmpty(targets.list, `暂无${activeLabel}`, '当前状态下没有可展示的场外基金邮件。');
|
||
targets.pagination.innerHTML = '';
|
||
return;
|
||
}
|
||
targets.list.innerHTML = visibleMails.map((mail) => {
|
||
const active = mail.mail_id === state.selectedMailId;
|
||
const displayStatus = mailDisplayStatus(mail);
|
||
return `<div class="operator-list__item${active ? ' operator-list__item--active' : ''}" data-mail="${escapeHtml(mail.mail_id)}">
|
||
<div><strong>${escapeHtml(mail.subject || mail.mail_id || '无主题邮件')}</strong><small>${escapeHtml(mail.sender || '未知发件人')}</small><small>发送日期:${escapeHtml(formatDateTime(mail.sent_at || mail.received_at || mail.received_date))}</small></div>
|
||
<div class="operator-list__actions">${tag(displayStatus.label, displayStatus.tone)}${actionButton('删除', `delete-mail:${encodeURIComponent(mail.mail_id)}`, 'danger')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
const pages = Math.max(1, Math.ceil(state.total / state.pageSize));
|
||
targets.pagination.innerHTML = `<span>第 ${state.page} / ${pages} 页,共 ${state.total} 封</span>${actionButton('上一页', 'page:prev', '')}${actionButton('下一页', 'page:next', '')}`;
|
||
targets.pagination.querySelector('[data-action="page:prev"]')?.toggleAttribute('disabled', state.page <= 1);
|
||
targets.pagination.querySelector('[data-action="page:next"]')?.toggleAttribute('disabled', state.page >= pages);
|
||
}
|
||
|
||
function fieldNames(base, effective, extra = []) {
|
||
const names = [];
|
||
[...Object.keys(base || {}), ...Object.keys(effective || {}), ...extra].forEach((name) => {
|
||
if (name && !names.includes(name)) names.push(name);
|
||
});
|
||
return names;
|
||
}
|
||
|
||
function renderFieldGrid(fields, draft, scope, key, statuses = {}) {
|
||
const names = fieldNames(fields, draft);
|
||
if (!names.length) return '<div class="operator-inline-note">暂无可展示字段。</div>';
|
||
return `<div class="operator-field-grid">${names.map((name) => {
|
||
const statusLabel = fieldStatusLabel(statuses[name]);
|
||
const shouldShowStatus = statuses[name] !== undefined && statuses[name] !== null && statuses[name] !== '' && statusLabel !== '未查询';
|
||
const fieldValue = draft[name] ?? fields?.[name] ?? '';
|
||
return `<label class="form-field"><span class="form-field__label operator-field-label"><span>${escapeHtml(name)}</span>${shouldShowStatus ? `<span class="operator-field-confidence">状态:${escapeHtml(statusLabel)}</span>` : ''}</span><input class="form-field__input" data-${scope}-field="${escapeHtml(key)}" data-field-name="${escapeHtml(name)}" value="${escapeHtml(formatFieldValue(name, fieldValue))}"></label>`;
|
||
}).join('')}</div>`;
|
||
}
|
||
|
||
function renderDetail() {
|
||
if (!state.mail) {
|
||
renderEmpty(targets.detail, '请选择一封邮件', '邮件详情会展示正文、附件原件、OCR 识别字段和关联单据。');
|
||
return;
|
||
}
|
||
const attachments = state.mail.attachments || [];
|
||
targets.detail.innerHTML = `<dl class="operator-kv"><div><dt>邮件编号</dt><dd>${escapeHtml(value(state.mail.mail_id))}</dd></div><div><dt>发件人</dt><dd>${escapeHtml(value(state.mail.sender))}</dd></div><div><dt>接收时间</dt><dd>${escapeHtml(formatDateTime(state.mail.received_at || state.mail.received_date))}</dd></div></dl>
|
||
<section><h3>邮件正文</h3><pre class="operator-text">${escapeHtml(state.mail.body_text || state.mail.body_html || '暂无正文')}</pre></section>
|
||
<section><div class="operator-section-heading"><h3>附件原件</h3><span class="operator-inline-note">${attachments.length} 个附件</span></div>${attachments.map(renderAttachment).join('') || '<div class="operator-inline-note">暂无附件。</div>'}</section>`;
|
||
}
|
||
|
||
function renderAttachment(item) {
|
||
const docs = item.documents || [];
|
||
const itemRecognition = (state.recognition?.attachments || []).find((row) => row.attachment_id === item.attachment_id);
|
||
const draft = state.ocrDrafts[item.attachment_id] || {};
|
||
const missing = itemRecognition?.missing_fields || [];
|
||
const names = fieldNames(itemRecognition?.extracted_fields, itemRecognition?.effective_fields, missing);
|
||
return `<article class="operator-attachment"><div class="operator-attachment__header"><div><strong>${escapeHtml(item.filename || item.attachment_id)}</strong><p class="operator-meta">OCR ${escapeHtml(itemRecognition?.ocr_status || '未记录')}</p></div><div class="operator-actions">${actionButton(PREVIEW_TYPES.has(String(item.media_type || '').toLowerCase()) ? '预览原件' : '下载原件', `file:${encodeURIComponent(item.attachment_id)}`)} </div></div>${missing.length ? `<div class="operator-warning">缺失字段:${escapeHtml(missing.join('、'))}</div>` : ''}<div class="operator-subsection"><h4>OCR 识别字段</h4>${renderFieldGrid(itemRecognition?.effective_fields || itemRecognition?.extracted_fields, draft, 'ocr', item.attachment_id, itemRecognition?.field_confidence || {})}<div class="operator-actions">${actionButton('保存', `save-ocr:${encodeURIComponent(item.attachment_id)}`, 'primary')}${actionButton('重试', docs[0]?.task_id ? `retry:${encodeURIComponent(docs[0].task_id)}` : 'noop')}</div></div></article>`;
|
||
}
|
||
|
||
function renderDocuments() {
|
||
if (!state.selectedMailId) {
|
||
renderEmpty(targets.documents, '请选择邮件后处理单据', '单据来自邮件附件关联记录。');
|
||
return;
|
||
}
|
||
if (!state.documents.length) {
|
||
renderEmpty(targets.documents, '当前邮件没有业务单据', '如果附件识别异常,可在上方查看原件并提交识别重试。');
|
||
return;
|
||
}
|
||
targets.documents.innerHTML = state.documents.map((document) => {
|
||
const taskId = document.task_id;
|
||
const nl = state.nl2sql[taskId];
|
||
const rule = state.rules[taskId];
|
||
const draft = state.nlDrafts[taskId] || {};
|
||
const ruleRows = rule?.rules || [];
|
||
const displayStatus = documentDisplayStatus(document);
|
||
const notificationType = documentNotificationType(document);
|
||
const recalculating = state.recalculatingTasks.has(taskId);
|
||
const encodedTaskId = encodeURIComponent(String(taskId || '').trim());
|
||
const notificationAction = notificationType
|
||
? `notice:${encodedTaskId}:${notificationType}`
|
||
: `notice:${encodedTaskId}`;
|
||
return `<article class="operator-document"><div class="operator-document__header"><div><strong>${escapeHtml(taskId)}</strong><p class="operator-meta">${escapeHtml(document.document_type || '单据')} · ${escapeHtml(document.fund_name || document.fund_code || '--')} · ${escapeHtml(document.application_no || '--')}</p></div><div>${tag(document.operator_decision || displayStatus.label, document.operator_decision === '确认异常' ? 'high' : displayStatus.tone)}</div></div><dl class="operator-kv"><div><dt>申请日期</dt><dd>${escapeHtml(value(document.application_date))}</dd></div><div><dt>申购金额 / 赎回份额</dt><dd>${escapeHtml(formatAmount(document.subscription_amount_yuan))} / ${escapeHtml(formatAmount(document.redemption_shares))}</dd></div><div><dt>机构</dt><dd>${escapeHtml(value(document.agency))}</dd></div><div><dt>基金代码</dt><dd>${escapeHtml(value(document.fund_code))}</dd></div></dl>
|
||
<div class="operator-subsection"><div class="operator-section-heading"><h3>NL2SQL 返回字段</h3><span class="operator-inline-note">${escapeHtml(nl?.updated_at ? formatDateTime(nl.updated_at, true) : '尚未核对')}</span></div>${nl?.error ? errorText({ message: nl.error }) : renderFieldGrid(nl?.effective_fields || nl?.fields, draft, 'nl', taskId, nl?.field_status || {})}<div class="operator-actions">${actionButton('保存', `save-nl:${encodeURIComponent(taskId)}`, 'primary')}${actionButton('进入 NL2SQL', `open-nl:${encodeURIComponent(taskId)}`)}</div></div>
|
||
<div class="operator-subsection"><div class="operator-section-heading"><h3>规则结果</h3>${rule ? tag(documentStatusLabel(rule.document_status)) : ''}</div>${rule?.error ? errorText({ message: rule.error }) : `<div class="operator-table-wrap"><table class="operator-table"><thead><tr><th>规则</th><th>结果</th><th>单据值</th><th>规则判断</th></tr></thead><tbody>${ruleRows.map((row) => `<tr><td>${escapeHtml(row.rule_name || row.rule_code || '--')}</td><td>${tag(row.result)}</td><td>${escapeHtml(renderDocumentValue(row))}</td><td>${renderRuleComparison(row)}</td></tr>`).join('') || '<tr><td colspan="4">暂无规则结果,请先执行 NL2SQL 核对。</td></tr>'}</tbody></table></div>`}<div class="operator-actions">${actionButton(recalculating ? '正在核对并判定规则' : '重新核对并判定规则', `recalculate:${encodeURIComponent(taskId)}`, 'primary', recalculating)}${actionButton('确认正常', `confirm:${encodeURIComponent(taskId)}:确认正常`)}${actionButton('确认异常', `confirm:${encodeURIComponent(taskId)}:确认异常`, 'danger')}${actionButton(notificationType ? `创建${NOTIFICATION_LABELS[notificationType]}` : '请先确认正常或异常', notificationAction, '', !notificationType)}</div></div></article>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderNotice() {
|
||
if (!state.notice) {
|
||
targets.noticePanel.hidden = true;
|
||
return;
|
||
}
|
||
targets.noticePanel.hidden = false;
|
||
targets.notice.innerHTML = `<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">通知编号</span><input class="form-field__input" value="${escapeHtml(state.notice.notification_id)}" readonly></label><label class="form-field"><span class="form-field__label">关联单据</span><input class="form-field__input" value="${escapeHtml(state.noticeTaskId)}" readonly></label><label class="form-field"><span class="form-field__label">通知类型</span><select class="form-field__input" data-notice-type>${NOTIFICATION_TYPES.map(([key, label]) => `<option value="${key}"${key === state.noticeType ? ' selected' : ''}>${label}</option>`).join('')}</select></label></div><label class="form-field"><span class="form-field__label">最终发送正文</span><textarea class="form-field__input" rows="5" maxlength="10000" data-notice-content>${escapeHtml(state.noticeDraft)}</textarea></label><div class="operator-actions">${actionButton('发送通知', 'send-notice', 'primary')}${actionButton('清除通知', 'clear-notice')}</div>`;
|
||
}
|
||
|
||
function renderStatistics() {
|
||
const items = Array.isArray(state.stats?.items)
|
||
? state.stats.items
|
||
: state.stats ? [state.stats] : [];
|
||
const blocks = items.map((item) => `<article class="operator-result-card"><div class="operator-section-heading"><div><h3>${escapeHtml(value(item.fund_name, '未识别基金'))}</h3><p class="operator-meta">基金代码:${escapeHtml(value(item.fund_code))}</p></div><span class="operator-inline-note">${escapeHtml(value(item.application_date))}</span></div><dl class="operator-kv"><div><dt>申购金额 / 笔数</dt><dd>${escapeHtml(formatAmount(item.subscription_amount_yuan ?? '0'))} / ${escapeHtml(value(item.subscription_count, '0'))}</dd></div><div><dt>赎回份额 / 金额</dt><dd>${escapeHtml(formatAmount(item.redemption_shares ?? '0'))} / ${escapeHtml(formatAmount(item.redemption_amount_yuan))}</dd></div><div><dt>净流入 / 流出</dt><dd>${escapeHtml(formatAmount(item.net_flow_amount_yuan))}</dd></div><div><dt>最新净值</dt><dd>${escapeHtml(formatNav(item.latest_nav))}</dd></div></dl></article>`).join('');
|
||
targets.statistics.innerHTML = `<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">基金代码(可选)</span><input class="form-field__input" data-stat-field="fundCode" value="${escapeHtml(state.statsForm.fundCode)}"></label><label class="form-field"><span class="form-field__label">申请日期</span><input class="form-field__input" type="date" data-stat-field="applicationDate" value="${escapeHtml(state.statsForm.applicationDate)}"></label></div>${state.stats ? (items.length ? `<p class="operator-inline-note">共 ${items.length} 只基金,每只基金单独汇总。</p><div class="operator-stack">${blocks}</div>` : '<div class="operator-inline-note">当天没有符合条件的清算单据。</div>') : ''}`;
|
||
}
|
||
|
||
function render() {
|
||
renderMetrics(); renderStatusTabs(); renderMailList(); renderDetail(); renderDocuments(); renderNotice(); renderStatistics();
|
||
}
|
||
|
||
/**
|
||
* 把识别接口返回的**单据标准字段**合并进 `state.documents`。
|
||
*
|
||
* 为什么必须合并:邮件详情的 `attachments[].documents[]` 只是一个**摘要**
|
||
* (`task_id` / `document_type` / `status` / `operator_decision`),
|
||
* 基金代码、申请日期、申购金额、机构这些字段**只在识别接口的返回里**
|
||
* (`_mail_detail` vs `_recognition_payload`)。只用摘要渲染,
|
||
* "单据核对与运营动作"里的单据信息就全是 `--`。
|
||
*
|
||
* 合并口径(**逐条按 task_id 匹配**,不是整段替换):
|
||
* - 两侧都有 → 识别侧字段优先,摘要里独有的键(status/operator_decision)保留;
|
||
* - 只有摘要侧 → 原样保留(不能因为识别接口没提到就丢掉单据);
|
||
* - 只有识别侧 → 也要出现(例如刚生成、邮件摘要还没刷新)。
|
||
*/
|
||
function syncDocumentsFromRecognition(payload) {
|
||
const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
|
||
if (!attachments.length || !state.mail) return;
|
||
const recognitionDocuments = new Map();
|
||
attachments.forEach((attachment) => {
|
||
(attachment.documents || []).forEach((document) => {
|
||
if (!document?.task_id) return;
|
||
recognitionDocuments.set(document.task_id, {
|
||
...document,
|
||
attachment_id: attachment.attachment_id,
|
||
});
|
||
});
|
||
});
|
||
if (!recognitionDocuments.size) return;
|
||
state.mail = {
|
||
...state.mail,
|
||
attachments: (state.mail.attachments || []).map((attachment) => {
|
||
const summaries = attachment.documents || [];
|
||
const merged = summaries.map((document) => {
|
||
const recognized = recognitionDocuments.get(document.task_id);
|
||
return recognized ? { ...document, ...recognized } : document;
|
||
});
|
||
const known = new Set(summaries.map((document) => document.task_id));
|
||
return {
|
||
...attachment,
|
||
documents: merged.concat(
|
||
[...recognitionDocuments.values()].filter(
|
||
(document) => document.attachment_id === attachment.attachment_id
|
||
&& !known.has(document.task_id),
|
||
),
|
||
),
|
||
};
|
||
}),
|
||
};
|
||
state.mailDetails[state.mail.mail_id] = state.mail;
|
||
state.documents = state.mail.attachments.flatMap((attachment) => (
|
||
attachment.documents || []
|
||
));
|
||
}
|
||
|
||
async function loadMail(mailId) {
|
||
const [mailResponse, recognitionResponse] = await Promise.all([
|
||
apiClient.get('OFFSITE_MAIL', { pathParams: { mailId } }),
|
||
apiClient.get('OFFSITE_RECOGNITION', { pathParams: { mailId } }).catch((error) => ({ data: { error: error.message, attachments: [] } })),
|
||
]);
|
||
state.selectedMailId = mailId;
|
||
state.mail = mailResponse.data || {};
|
||
state.mailDetails[mailId] = state.mail;
|
||
state.recognition = recognitionResponse.data || {};
|
||
state.ocrDrafts = Object.fromEntries((state.recognition.attachments || []).map((item) => [item.attachment_id, { ...(item.effective_fields || item.extracted_fields || {}) }]));
|
||
state.documents = (state.mail.attachments || []).flatMap((attachment) => (attachment.documents || []).map((document) => ({ ...document, attachment_id: document.attachment_id || attachment.attachment_id })));
|
||
// 邮件详情给的单据只是摘要,标准化字段在识别接口那一份里 ⇒ 这里必须再合并一次。
|
||
// 缺了这一步,"单据核对与运营动作"的单据信息(申请日期/申购金额/机构/基金代码)全是 `--`。
|
||
syncDocumentsFromRecognition(state.recognition);
|
||
const taskIds = [...new Set(state.documents.map((item) => item.task_id).filter(Boolean))];
|
||
const results = await Promise.all(taskIds.map(async (taskId) => {
|
||
const [nlResult, ruleResult] = await Promise.allSettled([
|
||
apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }),
|
||
apiClient.get('OFFSITE_RULE_RESULTS', { pathParams: { taskId } }),
|
||
]);
|
||
return { taskId, nl: nlResult.status === 'fulfilled' ? nlResult.value.data : { error: nlResult.reason?.message || '读取失败' }, rule: ruleResult.status === 'fulfilled' ? ruleResult.value.data : { error: ruleResult.reason?.message || '读取失败' } };
|
||
}));
|
||
state.nl2sql = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, nl]));
|
||
state.nlDrafts = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, { ...(nl.effective_fields || nl.fields || {}) }]));
|
||
state.rules = Object.fromEntries(results.map(({ taskId, rule }) => [taskId, rule]));
|
||
const first = state.documents.find((item) => item.fund_code && item.application_date);
|
||
if (first) {
|
||
state.statsForm.applicationDate = String(first.application_date).slice(0, 10);
|
||
}
|
||
render();
|
||
}
|
||
|
||
async function loadMailStatusDetails(mails) {
|
||
const candidates = mails.filter((mail) => String(mail.status || '') === 'processing');
|
||
await Promise.allSettled(candidates.map(async (mail) => {
|
||
const response = await apiClient.get('OFFSITE_MAIL', { pathParams: { mailId: mail.mail_id } });
|
||
state.mailDetails[mail.mail_id] = response.data || {};
|
||
}));
|
||
}
|
||
|
||
async function load() {
|
||
renderLoading(targets.list, 5); renderLoading(targets.detail, 3); renderLoading(targets.documents, 3);
|
||
try {
|
||
const [mails, mailbox] = await Promise.all([
|
||
apiClient.get('OFFSITE_MAILS', { query: { page: state.page, page_size: state.pageSize } }),
|
||
apiClient.get('OFFSITE_MAILBOX'),
|
||
]);
|
||
const payload = mails.data || {};
|
||
state.mails = Array.isArray(payload.items) ? payload.items : [];
|
||
state.total = Number(payload.total || 0);
|
||
state.mailbox = mailbox.data || {};
|
||
await loadMailStatusDetails(state.mails);
|
||
if (state.requestedMailId && state.mails.some((item) => item.mail_id === state.requestedMailId)) {
|
||
state.selectedMailId = state.requestedMailId;
|
||
} else if (!state.selectedMailId || !state.mails.some((item) => item.mail_id === state.selectedMailId)) {
|
||
state.selectedMailId = state.mails[0]?.mail_id || '';
|
||
}
|
||
if (state.selectedMailId) await loadMail(state.selectedMailId);
|
||
else { state.mail = null; state.documents = []; render(); }
|
||
} catch (error) {
|
||
apiClient.reportError(error); renderError(targets.list, error, load); renderError(targets.detail, error, load); renderError(targets.documents, error, load);
|
||
}
|
||
}
|
||
|
||
async function fileAction(attachmentId) {
|
||
const result = await apiClient.file('OFFSITE_ATTACHMENT_FILE', { pathParams: { attachmentId }, query: { disposition: 'inline' } });
|
||
const url = URL.createObjectURL(result.blob);
|
||
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
||
if (!opened) {
|
||
const link = document.createElement('a'); link.href = url; link.download = attachmentId; link.click();
|
||
}
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||
}
|
||
|
||
/**
|
||
* 重新核对单个单据:触发 NL2SQL → 拉取返回字段 → 重新判定规则。
|
||
* 返回是否**全部成功**(`query_failed` 视为未成功,菜单里会提示,但不抛错)。
|
||
*
|
||
* 抽成函数是因为它有两个入口:面板上的"重新核对并判定规则"按钮,
|
||
* 以及保存 OCR 识别字段之后(见 `save-ocr:`)—— 两处必须走同一条路径,
|
||
* 否则"保存后字段不显示"这类不一致会再次出现。
|
||
*/
|
||
async function recalculateDocument(taskId) {
|
||
if (!taskId || state.recalculatingTasks.has(taskId)) return true;
|
||
state.recalculatingTasks.add(taskId);
|
||
render();
|
||
try {
|
||
const triggerResponse = await apiClient.post(
|
||
'OFFSITE_TRIGGER_NL2SQL',
|
||
{ operator_id: operatorId, manual_confirmed: true },
|
||
{ pathParams: { taskId } },
|
||
);
|
||
const [fieldsResponse, rulesResponse] = await Promise.all([
|
||
apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }),
|
||
apiClient.post(
|
||
'OFFSITE_RULE_RECALCULATE',
|
||
{ operator_id: operatorId },
|
||
{ pathParams: { taskId } },
|
||
),
|
||
]);
|
||
state.nl2sql[taskId] = fieldsResponse.data;
|
||
state.nlDrafts[taskId] = {
|
||
...(fieldsResponse.data?.effective_fields || fieldsResponse.data?.fields || {}),
|
||
};
|
||
state.rules[taskId] = rulesResponse.data;
|
||
return triggerResponse.data?.status !== 'query_failed';
|
||
} finally {
|
||
state.recalculatingTasks.delete(taskId);
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function run(action) {
|
||
try {
|
||
if (action === 'refresh') { await load(); showToast('邮件列表已刷新'); return; }
|
||
if (action.startsWith('select-mail:')) {
|
||
await loadMail(decodeURIComponent(action.slice(12)));
|
||
return;
|
||
}
|
||
if (action === 'recover-mailbox') { await apiClient.post('OFFSITE_MAILBOX_RECOVER', { operator_id: operatorId }); await load(); showToast('收件游标已恢复'); return; }
|
||
if (action.startsWith('page:')) { const pages = Math.max(1, Math.ceil(state.total / state.pageSize)); state.page = Math.min(pages, Math.max(1, state.page + (action.endsWith('next') ? 1 : -1))); await load(); return; }
|
||
if (action.startsWith('delete-mail:')) {
|
||
const mailId = decodeURIComponent(action.slice(12));
|
||
if (!window.confirm('确认彻底删除这封邮件吗?邮件、附件、OCR、NL2SQL、规则、确认和通知记录都会删除,删除审计会保留。')) return;
|
||
await apiClient.post('OFFSITE_MAIL_DELETE', { operator_id: operatorId }, { pathParams: { mailId } });
|
||
state.selectedMailId = ''; await load(); showToast('邮件已删除'); return;
|
||
}
|
||
if (action.startsWith('file:')) { await fileAction(decodeURIComponent(action.slice(5))); return; }
|
||
if (action.startsWith('save-ocr:')) {
|
||
const attachmentId = decodeURIComponent(action.slice(9));
|
||
const response = await apiClient.post('OFFSITE_RECOGNITION_SAVE', { operator_id: operatorId, attachments: [{ attachment_id: attachmentId, fields: state.ocrDrafts[attachmentId] || {} }] }, { pathParams: { mailId: state.selectedMailId } });
|
||
state.recognition = response.data;
|
||
const saved = state.recognition.attachments?.find((item) => item.attachment_id === attachmentId);
|
||
state.ocrDrafts[attachmentId] = { ...(saved?.effective_fields || {}) };
|
||
syncDocumentsFromRecognition(state.recognition);
|
||
await loadMail(state.selectedMailId);
|
||
// ★ 保存识别字段 == 运营已人工确认这张单据的内容,因此顺手把**该附件关联的单据**
|
||
// 重新核对一次(触发 NL2SQL → 拉字段 → 重新判定规则)。
|
||
//
|
||
// 为什么必须在这里做:核对结果("单据核对与运营动作"里的 NL2SQL 返回字段与规则结果)
|
||
// 只在触发核对时才生成;保存识别字段本身不触发它。少了这一步,运营改完字段点保存后,
|
||
// 那两个区块要么停留在**上一次**核对的状态(例如仍是"申请前持有份额 null / 无法判断"),
|
||
// 要么整块是空的,必须再手动点一次"重新核对并判定规则"—— 而运营会以为"保存没生效"。
|
||
// 只核对本附件关联的单据,不动同一封邮件里的其它单据。
|
||
const recalcTasks = (state.documents || [])
|
||
.filter((document) => document.attachment_id === attachmentId)
|
||
.map((document) => document.task_id)
|
||
.filter(Boolean);
|
||
let recalcFailed = 0;
|
||
for (const taskId of recalcTasks) {
|
||
if (!await recalculateDocument(taskId)) recalcFailed += 1;
|
||
}
|
||
await loadMail(state.selectedMailId);
|
||
showToast(
|
||
recalcFailed ? 'OCR 识别字段已保存;核对未全部成功' : 'OCR 识别字段已保存并已重新核对',
|
||
recalcFailed ? 'error' : 'success',
|
||
);
|
||
return;
|
||
}
|
||
if (action.startsWith('save-nl:')) {
|
||
const taskId = decodeURIComponent(action.slice(8));
|
||
const response = await apiClient.post('OFFSITE_NL2SQL_FIELDS_SAVE', { operator_id: operatorId, fields: state.nlDrafts[taskId] || {} }, { pathParams: { taskId } });
|
||
state.nl2sql[taskId] = response.data;
|
||
state.nlDrafts[taskId] = { ...(response.data.effective_fields || response.data.fields || {}) };
|
||
await apiClient.post('OFFSITE_RULE_RECALCULATE', { operator_id: operatorId }, { pathParams: { taskId } });
|
||
await loadMail(state.selectedMailId);
|
||
showToast('NL2SQL 字段修正已保存'); return;
|
||
}
|
||
if (action.startsWith('retry:')) {
|
||
const taskId = decodeURIComponent(action.slice(6));
|
||
if (!window.confirm('是否重新对该文件进行 OCR 识别?')) return;
|
||
await apiClient.post('OFFSITE_RECOGNITION_RETRY', { operator_id: operatorId }, { pathParams: { taskId } });
|
||
await loadMail(state.selectedMailId); showToast('已提交识别重试'); return;
|
||
}
|
||
if (action.startsWith('recalculate:')) {
|
||
const taskId = decodeURIComponent(action.slice(12));
|
||
if (state.recalculatingTasks.has(taskId)) return;
|
||
const ok = await recalculateDocument(taskId);
|
||
showToast(ok ? '规则已重新核对并判定' : '已重新核对,但存在查询失败',
|
||
ok ? 'success' : 'error');
|
||
return;
|
||
}
|
||
if (action.startsWith('confirm:')) {
|
||
const [, encodedTask, decision] = action.split(':');
|
||
const taskId = decodeURIComponent(encodedTask).trim();
|
||
await apiClient.post('OFFSITE_CONFIRM', { decision, operator_id: operatorId }, { pathParams: { taskId } });
|
||
await loadMail(state.selectedMailId); showToast(`单据已${decision}`); return;
|
||
}
|
||
if (action.startsWith('open-nl:')) {
|
||
const taskId = decodeURIComponent(action.slice(8));
|
||
const params = new URLSearchParams({ task_id: taskId, mail_id: state.selectedMailId, page: String(state.page) });
|
||
window.location.href = `/portal/employee-operations/nl2sql/?${params.toString()}`;
|
||
return;
|
||
}
|
||
if (action.startsWith('notice:')) {
|
||
const actionPayload = action.slice(7);
|
||
const separator = actionPayload.lastIndexOf(':');
|
||
const encodedTaskId = separator >= 0 ? actionPayload.slice(0, separator) : actionPayload;
|
||
const noticeType = separator >= 0 ? actionPayload.slice(separator + 1) : '';
|
||
const taskId = decodeURIComponent(encodedTaskId).trim();
|
||
if (!noticeType) throw new Error('请先确认正常或确认异常后再创建通知');
|
||
const response = await apiClient.post('OFFSITE_NOTIFICATION_CREATE', { notification_type: noticeType, operator_id: operatorId }, { pathParams: { taskId } });
|
||
state.notice = response.data || {}; state.noticeType = noticeType; state.noticeTaskId = taskId; state.noticeDraft = `${taskId} 待发送${NOTIFICATION_LABELS[noticeType]}`; showToast(`${NOTIFICATION_LABELS[noticeType]}已创建`); render(); return;
|
||
}
|
||
if (action === 'send-notice') {
|
||
if (!state.notice?.notification_id) throw new Error('请先创建通知');
|
||
if (!window.confirm('确认发送这条通知吗?发送后将影响业务状态。')) return;
|
||
const response = await apiClient.post('OFFSITE_NOTIFICATION_SEND', { operator_id: operatorId, operator_confirmed: true, final_content: state.noticeDraft }, { pathParams: { notificationId: state.notice.notification_id } });
|
||
state.notice = { ...state.notice, ...response.data }; showToast(`通知状态:${response.data?.status || '已提交'}`); render(); return;
|
||
}
|
||
if (action === 'clear-notice') { state.notice = null; state.noticeTaskId = ''; render(); return; }
|
||
if (action === 'statistics') {
|
||
if (!state.statsForm.applicationDate) throw new Error('请填写申请日期');
|
||
const response = await apiClient.post('OFFSITE_SETTLEMENT_RECALCULATE', { fund_code: state.statsForm.fundCode || null, application_date: state.statsForm.applicationDate });
|
||
state.stats = response.data; showToast('清算统计已刷新'); render(); return;
|
||
}
|
||
} catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); }
|
||
}
|
||
|
||
root.addEventListener('click', (event) => {
|
||
const statusKey = event.target.closest('[data-mail-status]')?.dataset.mailStatus;
|
||
if (statusKey) {
|
||
state.activeMailStatus = statusKey;
|
||
render();
|
||
return;
|
||
}
|
||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||
if (action) { event.preventDefault(); run(action); return; }
|
||
const mailId = event.target.closest('[data-mail]')?.dataset.mail;
|
||
if (mailId) { run(`select-mail:${encodeURIComponent(mailId)}`); }
|
||
});
|
||
root.addEventListener('input', (event) => {
|
||
const target = event.target;
|
||
if (target.matches('[data-ocr-field]')) { state.ocrDrafts[target.dataset.ocrField] ||= {}; state.ocrDrafts[target.dataset.ocrField][target.dataset.fieldName] = isNumericDisplayField(target.dataset.fieldName) || /净值|nav/i.test(target.dataset.fieldName) ? target.value.replace(/,/g, '') : target.value; }
|
||
if (target.matches('[data-nl-field]')) { state.nlDrafts[target.dataset.nlField] ||= {}; state.nlDrafts[target.dataset.nlField][target.dataset.fieldName] = isNumericDisplayField(target.dataset.fieldName) || /净值|nav/i.test(target.dataset.fieldName) ? target.value.replace(/,/g, '') : target.value; }
|
||
if (target.matches('[data-stat-field]')) { state.statsForm[target.dataset.statField] = target.value; }
|
||
if (target.matches('[data-notice-content]')) state.noticeDraft = target.value;
|
||
});
|
||
document.querySelector('[data-mail-refresh]').addEventListener('click', () => run('refresh'));
|
||
document.querySelector('[data-mailbox-recover]').addEventListener('click', () => run('recover-mailbox'));
|
||
document.querySelector('[data-stat-refresh]').addEventListener('click', () => run('statistics'));
|
||
load();
|
||
}
|