174 lines
10 KiB
JavaScript
174 lines
10 KiB
JavaScript
import { apiClient } from '/static/portal/common/api-client.js?v=20260920';
|
|
import { getAuthContext, requireOperator } from '/static/portal/common/auth.js';
|
|
import { escapeHtml } from '/static/portal/common/formatters.js';
|
|
import { mountShell } from '/static/portal/common/layout/app-shell.js';
|
|
import { showToast } from '/static/portal/common/notifications.js';
|
|
|
|
const FINANCIAL_NL2SQL_AGENT_TYPE = 'financial_nl2sql';
|
|
|
|
if (requireOperator()) {
|
|
mountShell({ active: 'operator-nl2sql', mode: 'operator' });
|
|
const context = getAuthContext();
|
|
const operatorId = String(context?.userId || context?.username || '');
|
|
const pageParams = new URLSearchParams(location.search);
|
|
const state = {
|
|
offsiteTask: pageParams.get('task_id') || '',
|
|
returnMailId: pageParams.get('mail_id') || '',
|
|
returnPage: pageParams.get('page') || '1',
|
|
offsiteNaturalLanguage: '',
|
|
offsiteResult: null,
|
|
generalRun: null,
|
|
timer: null,
|
|
};
|
|
const offsiteTask = document.querySelector('[data-offsite-task]');
|
|
const offsiteNaturalLanguage = document.querySelector('[data-offsite-natural-language]');
|
|
const offsiteResult = document.querySelector('[data-offsite-result]');
|
|
const generalResult = document.querySelector('[data-general-result]');
|
|
offsiteTask.value = state.offsiteTask;
|
|
document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员';
|
|
document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
|
|
const backLink = document.querySelector('[data-offsite-back]');
|
|
|
|
const STATUS_LABELS = {
|
|
planned: '核对完成',
|
|
query_failed: '查询失败',
|
|
success: '查询成功',
|
|
error: '查询失败',
|
|
pending: '待执行',
|
|
running: '执行中',
|
|
queued: '排队中',
|
|
processing: '处理中',
|
|
succeeded: '查询成功',
|
|
failed: '查询失败',
|
|
cancelled: '已取消',
|
|
};
|
|
|
|
const RULE_LABELS = {
|
|
subscription_holding_ratio: '申购后单一投资者持有比例',
|
|
subscription_single_share_limit: '申购单笔份额上限',
|
|
redemption_large_ratio: '赎回巨额比例',
|
|
redemption_available_quantity: '账户可用份额',
|
|
};
|
|
|
|
function statusLabel(status) {
|
|
return STATUS_LABELS[String(status || '').toLowerCase()] || status || '等待执行';
|
|
}
|
|
|
|
function ruleLabel(item) {
|
|
return item.rule_name || RULE_LABELS[item.rule_code] || item.rule_code || '查询规则';
|
|
}
|
|
|
|
function statusTag(status) { return `<span class="tag">${escapeHtml(status || '等待执行')}</span>`; }
|
|
function generalResultData(run) {
|
|
const result = run?.result;
|
|
return result?.data || result?.result?.data || null;
|
|
}
|
|
function generalGeneratedSql(run) {
|
|
return run?.result?.sql || run?.result?.generated_sql || '';
|
|
}
|
|
function generalResultRows(run) {
|
|
const data = generalResultData(run);
|
|
return Array.isArray(data?.rows) ? data.rows : [];
|
|
}
|
|
function renderGeneralResultDetail(run) {
|
|
const rows = generalResultRows(run);
|
|
if (rows.length) {
|
|
const columns = [...new Set(rows.flatMap((row) => Object.keys(row || {})))];
|
|
return `<div class="operator-table-wrap"><table class="operator-table"><thead><tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${columns.map((column) => `<td>${escapeHtml(String(row?.[column] ?? '--'))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
|
}
|
|
const data = generalResultData(run);
|
|
const emptyMessage = data?.total !== undefined
|
|
? `查询返回 ${data.total} 条记录`
|
|
: '本次查询没有返回可展示的字段值';
|
|
return `<div class="operations-result">${escapeHtml(emptyMessage)}</div>`;
|
|
}
|
|
function renderGeneralSql(run) {
|
|
const sql = generalGeneratedSql(run);
|
|
return sql
|
|
? `<pre class="nl2sql-sql">${escapeHtml(sql)}</pre>`
|
|
: '<div class="operations-result">本次运行未生成可执行 SQL。</div>';
|
|
}
|
|
function buildNaturalLanguage(fields) {
|
|
const fundCode = String(fields?.fund_code || '').trim();
|
|
if (!fundCode) throw new Error('当前单据缺少基金代码,无法生成自然语言');
|
|
let base = `基金代码为${fundCode}`;
|
|
const accountIdentifier = String(fields?.account_identifier || '').trim();
|
|
if (accountIdentifier) base += `,账户标识为${accountIdentifier}`;
|
|
if (fields.document_type === 'subscription') {
|
|
return [
|
|
`${base},查询基金最新总份额、最新净值和申请前持有份额`,
|
|
`${base},查询基金最新总份额和最新净值`,
|
|
].join('\n');
|
|
}
|
|
return [
|
|
`${base},查询产品最新总份额`,
|
|
`${base},查询账户当前最新可用份额`,
|
|
].join('\n');
|
|
}
|
|
function renderOffsite() {
|
|
const result = state.offsiteResult;
|
|
offsiteNaturalLanguage.value = state.offsiteNaturalLanguage;
|
|
if (backLink) {
|
|
backLink.href = state.offsiteTask
|
|
? `/portal/employee-operations/offsite/?${new URLSearchParams({ task_id: state.offsiteTask, mail_id: state.returnMailId, page: state.returnPage }).toString()}`
|
|
: '/portal/employee-operations/offsite/';
|
|
}
|
|
document.querySelector('[data-offsite-status]').outerHTML = `<span class="tag" data-offsite-status>${escapeHtml(statusLabel(result?.status))}</span>`;
|
|
offsiteResult.innerHTML = result ? `<dl class="operator-kv"><div><dt>单据</dt><dd>${escapeHtml(result.task_id || state.offsiteTask)}</dd></div><div><dt>执行状态</dt><dd>${statusTag(statusLabel(result.status))}</dd></div><div><dt>查询数量</dt><dd>${escapeHtml(String(result.queries?.length || 0))}</dd></div><div><dt>返回说明</dt><dd>${escapeHtml(result.message || '已完成服务端核对')}</dd></div></dl><div class="operator-table-wrap"><table class="operator-table"><thead><tr><th>核对规则</th><th>状态</th><th>返回行数</th><th>错误信息</th></tr></thead><tbody>${(result.queries || []).map((item) => `<tr><td>${escapeHtml(ruleLabel(item))}<small class="operator-inline-note">${escapeHtml(item.rule_code || '')}</small></td><td>${escapeHtml(statusLabel(item.status))}</td><td>${escapeHtml(String(item.row_count ?? 0))}</td><td>${escapeHtml(item.error_message || '--')}</td></tr>`).join('') || '<tr><td colspan="4">暂无查询记录。</td></tr>'}</tbody></table></div><pre class="operator-json">${escapeHtml(JSON.stringify(result, null, 2))}</pre>` : '<div class="operations-empty">输入 task_id 后执行一次只读核对。</div>';
|
|
}
|
|
function renderGeneral() {
|
|
const run = state.generalRun;
|
|
generalResult.innerHTML = run ? `<dl class="operator-kv"><div><dt>状态</dt><dd>${escapeHtml(statusLabel(run.status))}</dd></div></dl><section class="nl2sql-output"><h3 class="nl2sql-output__title">最终查询结果</h3>${renderGeneralResultDetail(run)}</section><section class="nl2sql-output"><h3 class="nl2sql-output__title">AI 生成的 SQL</h3>${renderGeneralSql(run)}</section>` : '<div class="operations-empty">提交问题后,前端会自动轮询运行状态。</div>';
|
|
}
|
|
async function poll(runId) {
|
|
if (state.timer) clearTimeout(state.timer);
|
|
const response = await apiClient.get('AGENT_RUN', { pathParams: { runId } });
|
|
state.generalRun = response.data; renderGeneral();
|
|
if (['queued', 'running', 'processing', 'pending'].includes(String(state.generalRun.status).toLowerCase())) {
|
|
state.timer = window.setTimeout(() => poll(runId).catch((error) => showToast(error.message, 'error')), 1200);
|
|
} else showToast(state.generalRun.status === 'succeeded' ? '通用查询已完成' : `查询状态:${state.generalRun.status}`, state.generalRun.status === 'succeeded' ? 'success' : 'error');
|
|
}
|
|
async function run(action) {
|
|
try {
|
|
if (action === 'generate-offsite') {
|
|
const taskId = offsiteTask.value.trim();
|
|
if (!taskId) throw new Error('请输入单据 task_id');
|
|
const response = await apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } });
|
|
state.offsiteTask = taskId;
|
|
state.offsiteNaturalLanguage = buildNaturalLanguage(response.data);
|
|
renderOffsite();
|
|
showToast('自然语言已生成,可人工修改');
|
|
return;
|
|
}
|
|
if (action === 'run-offsite') {
|
|
const taskId = offsiteTask.value.trim();
|
|
if (!taskId) throw new Error('请输入单据 task_id');
|
|
if (!document.querySelector('[data-manual-confirmed]').checked) throw new Error('请先确认原始文件内容');
|
|
const response = await apiClient.post('OFFSITE_TRIGGER_NL2SQL', { operator_id: operatorId, manual_confirmed: true }, { pathParams: { taskId } });
|
|
state.offsiteResult = response.data; renderOffsite(); showToast(response.data?.status === 'query_failed' ? '核对完成,但存在查询失败' : '只读核对已完成'); return;
|
|
}
|
|
if (action === 'run-general') {
|
|
const message = document.querySelector('[data-query-text]').value.trim();
|
|
if (!message) throw new Error('请输入业务问题');
|
|
const sessionId = `portal-${crypto.randomUUID()}`;
|
|
const response = await apiClient.post('AGENT_RUN_CREATE', { agent_type: FINANCIAL_NL2SQL_AGENT_TYPE, message, session_id: sessionId, idempotency_key: crypto.randomUUID().replaceAll('-', '') });
|
|
state.generalRun = response.data; renderGeneral(); showToast('查询已提交,正在获取结果'); poll(response.data.run_id); return;
|
|
}
|
|
} catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); }
|
|
}
|
|
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; });
|
|
}));
|
|
document.querySelector('main').addEventListener('click', (event) => { const action = event.target.closest('[data-action]')?.dataset.action; if (action) run(action); });
|
|
offsiteTask.addEventListener('input', () => {
|
|
state.offsiteTask = offsiteTask.value;
|
|
state.offsiteNaturalLanguage = '';
|
|
offsiteNaturalLanguage.value = '';
|
|
});
|
|
offsiteNaturalLanguage.addEventListener('input', () => {
|
|
state.offsiteNaturalLanguage = offsiteNaturalLanguage.value;
|
|
});
|
|
renderOffsite(); renderGeneral();
|
|
}
|