修复投顾工作台白板:补上模块拆分时漏掉的 import

## 问题(合并进来的故障,不是本次会话改坏的)

合并 `origin/qyqy_develop`(f5d1b24 / 3134fe5)后 `pytest` 红了一条:
`test_advisor_dashboard_is_composed_from_feature_modules`。

查下去发现是**拆分做了一半**:

- 新建了 `advisor-config.js` / `actions-module.js` / `published-module.js`
- 把 `CONTENT_TYPE_LABELS`、`actionLabels`、`resultMessages` 从 `dashboard.js` 删掉了
- **但没有在 `dashboard.js` 里 import 它们**,`dashboard.js` 仍是拆分前的内联版本,
  第 39 行还在用 `CONTENT_TYPE_LABELS`

后果不只是测试红:投顾工作台一打开就 `ReferenceError: CONTENT_TYPE_LABELS is not defined`,
页面渲染不出来;同时那两个新模块是**死代码**(没有任何地方 import 它们)。
`index.html` 是单入口(只加载 `dashboard.js`),所以模块必须由它 import。

## 修法:把重构接完,而不是把测试改掉

- `dashboard.js` 变成薄组合层:挂 shell、取 DOM、组合两个模块,其余逻辑不再内联
- `advisor-config.js` 收拢 `GOAL_STATUS_LABELS` / `BOOK_STATUS_LABELS`(原来内联在 dashboard.js)
- `actions-module.js` 接管「目标确认与方案书」
- `published-module.js` 直接可用

⚠️ 关键点:`bind()` 会给**所有** `[data-action]` 按钮挂 `open()`,而 `actions-module.js`
原先不认识 `goal-status` —— 直接接线会让它掉到最后一行的兜底分支、被当成
「资产配置」发出去(点"目标确认与方案书"却收到一份配置建议)。
所以把「目标确认与方案书」一并做进 `open()` 的分支里,并在两处留了注释说明这个约束。

「目标确认与方案书」这条功能本身要保留:此前工作台只有 4 个"生成草案"操作 + 1 个只读列表,
而确认目标与查看方案书这两个端点**有接口没入口**,导致目标永远停在 `pending_confirmation`、
方案书永远停在 `pending`(实测客户 9001 正是如此)。

## 防回归:tools/check_portal_modules.py(新)

上面那个 bug **不能靠现有断言发现** —— 那些测试断言的是"某个字符串在文件里出现",
而这里的问题是"定义搬走了、使用处还在",浏览器里才炸,Python 测试全绿。

新检查做四件事:`node --check` 按 ES module 解析语法、相对 import 的目标文件存在、
import 的名字在目标文件里真有 `export`、**用到的全大写常量必须有来源**。

第 4 条是抓这个 bug 的关键。写的时候踩了两次坑,都已修正并记录在文件里:

1. 第一版用 `(?<![\w.$])` 排除属性访问、却把**模板字符串整体**当字符串剔除了 ——
   而 `CONTENT_TYPE_LABELS[row.content_type]` 恰好写在模板字符串里,
   于是漏报、检查全绿。现在只剔除单双引号字符串,模板字符串保留(`${}` 里是真代码)。
2. 用负面验证确认它真的有效:把 `published-module.js` 的 import 拿掉后,
   检查精确报出 `使用了 'CONTENT_TYPE_LABELS',但既没 import 也没在本文件声明`(exit 1);
   恢复后 exit 0。没有这一步,这个检查就是个摆设。

同时接进测试:`test_portal_feature_modules_have_consistent_imports` 调用它,
保证以后每次 `pytest tests/unit` 都会执行。

## 实测

- `pytest tests/unit tests/contract` -> **1400 passed, 2 skipped, 0 failed**
  (合并后未修时是 1399 passed + 1 failed)
- `pytest tests/unit/api/test_portal_frontend.py` -> 39 passed(38 + 新增 1 条)
- `python tools/check_portal_modules.py` -> 全部通过;负面验证 exit 1
- `ruff check app tests tools alembic hq.py` -> All checks passed
This commit is contained in:
2026-09-14 02:07:44 +08:00
parent d95ef09627
commit e096ffab22
5 changed files with 355 additions and 189 deletions
@@ -1,6 +1,11 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { escapeHtml } from '/static/portal/common/formatters.js';
import { ACTION_LABELS, RESULT_MESSAGES } from './advisor-config.js';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import {
ACTION_LABELS,
BOOK_STATUS_LABELS,
GOAL_STATUS_LABELS,
RESULT_MESSAGES,
} from './advisor-config.js';
export function createActionsModule({ output, alert }) {
function showAlert(message, kind = 'error') {
@@ -61,12 +66,111 @@ export function createActionsModule({ output, alert }) {
await run('ADVISOR_CREATE_GOAL', body, '客户目标已提交,等待确认与审核');
}
// ---- 目标确认与方案书:把「录入目标」之后断掉的流程接上 ----
//
// 此前工作台只有 4 个「生成草案」的操作 + 1 个只读列表,而**确认目标**与
// **查看方案书**这两个端点虽然存在却没有入口,于是目标永远停在
// `pending_confirmation`、方案书永远停在 `pending`(实测客户 9001 正是如此)。
//
// ⚠️ 这两个操作**必须**在这里处理:`bind()` 会给所有 `[data-action]` 按钮挂上
// `open()`,若 `open()` 不认识 `goal-status`,它会掉到最后一行被当成
// 「资产配置」发出去 —— 点「目标确认与方案书」却收到一份配置建议。
function renderGoalStatusForm() {
output.innerHTML = '<div class="advisor-inline-form"><label class="form-field"><span class="form-field__label">客户 ID</span><input class="form-field__input" data-goal-customer type="number" min="1" value="9001"></label><button class="button button--primary" type="button" data-load-goal>查询目标</button></div><p class="advisor-output__message">查到目标后可在此确认目标、查看方案书。</p>';
output.querySelector('[data-load-goal]').addEventListener('click', () => {
const value = Number(output.querySelector('[data-goal-customer]').value);
if (!value) {
showAlert('请填写客户 ID。');
return;
}
loadGoalStatus(value);
});
}
async function loadGoalStatus(customerId) {
clearAlert();
output.innerHTML = '<div class="advisor-output__loading">正在查询客户目标…</div>';
try {
const response = await apiClient.get('ADVISOR_CUSTOMER_GOAL', { pathParams: { customerId } });
renderGoalStatus(response.data ?? response);
} catch (error) {
apiClient.reportError(error);
// 404「当前投资目标不存在」是**正常情况**(客户还没录入目标),
// 要给出下一步提示,而不是当成故障报警。
if (error.status === 404) {
showAlert(`客户 ${customerId} 还没有投资目标,请先用「录入客户目标」创建。`, 'info');
output.innerHTML = '<div class="advisor-output__placeholder">该客户暂无投资目标。</div>';
return;
}
showAlert(error.message || '查询未完成,请稍后重试。');
output.innerHTML = '<div class="advisor-output__placeholder">查询失败,请检查权限或稍后重试。</div>';
}
}
function renderGoalStatus(goal) {
const goalNo = goal.goal_no;
const status = goal.status;
const book = goal.goal_book || {};
const canConfirm = status === 'pending_confirmation';
const rows = [
['目标编号', goalNo],
['客户', goal.customer_id],
['年化收益区间', `${goal.annualized_return_lower_pct}% ~ ${goal.annualized_return_upper_pct}%`],
['最大回撤', `${goal.max_drawdown_pct}%`],
['投资期限', `${goal.investment_horizon_months} 个月`],
['业绩基准', goal.benchmark_name],
['确认时间', goal.confirmed_at ? formatDateTime(goal.confirmed_at) : '尚未确认'],
];
output.innerHTML = `<div class="advisor-output__header"><strong>客户目标</strong><span class="status-tag status-tag--active">${escapeHtml(GOAL_STATUS_LABELS[status] || status || '--')}</span></div><div class="detail-facts">${rows.map(([label, value]) => `<div class="detail-facts__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value ?? '--'))}</strong></div>`).join('')}<div class="detail-facts__row"><span>方案书</span><strong>${escapeHtml(BOOK_STATUS_LABELS[book.review_status] || book.review_status || '--')}</strong></div></div><div class="advisor-inline-form">${canConfirm ? '<button class="button button--primary" type="button" data-confirm-goal>确认目标</button>' : ''}<button class="button" type="button" data-view-book>查看方案书</button></div><p class="advisor-output__message">${canConfirm ? '确认后目标才可用于资产配置与推荐;方案书需由管理员审核发布。' : '方案书的审核与发布由管理员完成,投顾侧到此为止。'}</p>`;
output.querySelector('[data-confirm-goal]')?.addEventListener('click', () => confirmGoal(goalNo));
output.querySelector('[data-view-book]')?.addEventListener('click', () => viewGoalBook(goalNo));
}
async function confirmGoal(goalNo) {
clearAlert();
try {
await apiClient.post('ADVISOR_CONFIRM_GOAL', { confirmed: true }, { pathParams: { goalNo } });
showAlert('目标已确认。', 'info');
const customerId = Number(output.querySelector('[data-goal-customer]')?.value || 9001);
await loadGoalStatus(customerId);
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '确认未完成,请稍后重试。');
}
}
async function viewGoalBook(goalNo) {
clearAlert();
try {
const response = await apiClient.get('ADVISOR_GOAL_BOOK', { pathParams: { goalNo } });
const data = response.data ?? response;
const sections = data.content?.sections || {};
const objective = sections.investment_objective || {};
const expectation = objective.annualized_return_expectation_pct || {};
const rows = [
['业绩基准', objective.benchmark_name],
['年化收益期望', `${expectation.lower ?? '--'}% ~ ${expectation.upper ?? '--'}%`],
['最大回撤', sections.risk_boundary?.maximum_drawdown_pct ? `${sections.risk_boundary.maximum_drawdown_pct}%` : null],
['投资期限', sections.investment_horizon?.months ? `${sections.investment_horizon.months} 个月` : null],
['流动性', sections.liquidity?.description],
];
output.innerHTML = `<div class="advisor-output__header"><strong>投资目标方案书</strong><span class="status-tag status-tag--active">${escapeHtml(BOOK_STATUS_LABELS[data.review_status] || data.review_status || '--')}</span></div><div class="detail-facts">${rows.map(([label, value]) => `<div class="detail-facts__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value ?? '--'))}</strong></div>`).join('')}</div>${(data.content?.disclosures || []).map((item) => `<p class="advisor-output__message">${escapeHtml(item)}</p>`).join('')}`;
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '方案书读取失败。');
}
}
function open(action) {
clearAlert();
if (action === 'goal') {
renderGoalForm();
return;
}
if (action === 'goal-status') {
renderGoalStatusForm();
return;
}
if (action === 'recommend') {
output.innerHTML = '<div class="advisor-inline-form"><label class="form-field"><span class="form-field__label">推荐数量</span><select class="form-field__input" data-recommend-limit><option value="1">1 个</option><option value="2">2 个</option><option value="3" selected>3 个</option></select></label><button class="button button--primary" type="button" data-submit-recommend>生成草案</button></div>';
output.querySelector('[data-submit-recommend]').addEventListener('click', () => run('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案'));
@@ -17,3 +17,20 @@ export const RESULT_MESSAGES = Object.freeze({
no_positions: '当前客户暂无可分析的持仓。',
valuation_required: '持仓缺少可用市值,暂不能计算集中度。',
});
//: 目标与方案书的状态口径(`investment_goal_service` 的状态机):
//: 目标 `pending_confirmation` --确认--> `confirmed`;
//: 方案书 `pending` --管理员审核--> `approved` --发布--> `published`。
//: ⚠️ 审核与发布都要求 `admin=True`(见 `review_book` / `publish_book`),
//: 所以投顾侧到"确认目标 + 查看方案书"为止,剩下两步归管理员。
export const GOAL_STATUS_LABELS = Object.freeze({
pending_confirmation: '待确认',
confirmed: '已确认',
});
export const BOOK_STATUS_LABELS = Object.freeze({
pending: '待审核',
approved: '审核通过',
published: '已发布',
rejected: '已退回',
});
@@ -1,197 +1,38 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { getAuthContext, requireAdvisor } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
//: 目标与方案书的状态口径(`investment_goal_service` 的状态机):
//: 目标 `pending_confirmation` --确认--> `confirmed`;
//: 方案书 `pending` --管理员审核--> `approved` --发布--> `published`。
//: ⚠️ 审核与发布都要求 `admin=True`(见 `review_book` / `publish_book`),
//: 所以投顾这边到"确认目标 + 查看方案书"为止,剩下两步归管理员。
const GOAL_STATUS_LABELS = {
pending_confirmation: '待确认',
confirmed: '已确认',
};
const BOOK_STATUS_LABELS = {
pending: '待审核',
approved: '审核通过',
published: '已发布',
rejected: '已退回',
};
import { createActionsModule } from './actions-module.js';
import { createPublishedModule } from './published-module.js';
// 投顾工作台。本文件只做**组合**,具体能力拆在三个模块里:
//
// · advisor-config.js 状态与操作的文案表(唯一来源)
// · published-module.js 已发布方案列表 + 概览指标
// · actions-module.js 五项操作:组合分析 / 资产配置 / 推荐草案 /
// 录入客户目标 / 目标确认与方案书
//
// 每个模块各自 import 它需要的公共件(apiClient / formatters / state-view),
// 本层不再重复 import —— 同一个东西两处 import,改一处漏一处是这类拆分的典型退化。
//
// ⚠️ 新增 `data-action` 按钮时**必须**同时在 `actions-module.js` 的 `open()` 里加分支:
// `bind()` 会给所有 `[data-action]` 挂上 `open()`,不认识的 action 会掉到最后一行的
// 兜底分支、被当成「资产配置」发出去 —— 点了没反应算好的,发错请求才麻烦。
if (requireAdvisor()) {
mountShell({ active: 'advisor-dashboard', mode: 'advisor' });
const context = getAuthContext();
const list = document.querySelector('[data-recommendations]');
const metrics = document.querySelector('[data-advisor-metrics]');
const output = document.querySelector('[data-action-output]');
const alert = document.querySelector('[data-action-alert]');
document.querySelector('[data-advisor-name]').textContent = context?.username || '投顾人员';
document.querySelector('[data-advisor-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
async function load() {
renderLoading(list, 3);
try {
const response = await apiClient.get('ADVISOR_PUBLISHED');
const rows = Array.isArray(response.data) ? response.data : [];
metrics.innerHTML = [['已发布方案', rows.length, '服务端返回'], ['协作客户', '按归属', '数据范围内'], ['审核状态', '留痕', '发布前需复核']].map(([label, value, meta]) => `<article class="metric-card"><p class="metric-card__label">${label}</p><p class="metric-card__value">${value}</p><p class="metric-card__meta">${meta}</p></article>`).join('');
if (!rows.length) { renderEmpty(list, '暂未发布方案', '当前账号暂无已审核发布的客户方案。'); return; }
list.innerHTML = rows.map((row) => `<article class="advisor-card"><h3 class="advisor-card__title">${escapeHtml(CONTENT_TYPE_LABELS[row.content_type] || '方案')} · 客户 ${escapeHtml(row.customer_id || '--')}</h3><p class="advisor-card__meta">发布时间:${escapeHtml(formatDateTime(row.published_at))}</p><div class="advisor-card__content">${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}</div></article>`).join('');
} catch (error) { apiClient.reportError(error); renderError(list, error, load); }
}
const actionLabels = { portfolio: '组合分析', allocation: '资产配置', recommend: '生成推荐草案', goal: '录入客户目标' };
const resultMessages = {
profile_required: '客户尚未完成风险测评,请先完成测评后再分析。',
investment_goal_required: '暂无已确认投资目标,暂不能生成配置或推荐。',
investment_goal_invalid: '投资目标数据不完整,请检查客户目标。',
no_positions: '当前客户暂无可分析的持仓。',
valuation_required: '持仓缺少可用市值,暂不能计算集中度。',
};
function showAlert(message, kind = 'error') {
alert.textContent = message;
alert.className = `form-alert form-alert--visible${kind === 'info' ? ' form-alert--info' : ''}`;
}
function clearAlert() { alert.textContent = ''; alert.className = 'form-alert'; }
function renderResult(title, data) {
const status = data?.status;
const message = resultMessages[status];
output.innerHTML = `<div class="advisor-output__header"><strong>${escapeHtml(title)}</strong><span class="status-tag status-tag--active">${escapeHtml(status || 'ready')}</span></div>${message ? `<p class="advisor-output__message">${escapeHtml(message)}</p>` : `<pre class="json-view">${escapeHtml(JSON.stringify(data, null, 2))}</pre>`}`;
}
function renderGoalForm() {
output.innerHTML = `<form class="advisor-form" data-goal-form><div class="advisor-form__grid"><label class="form-field"><span class="form-field__label">客户 ID(可选)</span><input class="form-field__input" name="customer_id" type="number" min="1" placeholder="留空表示当前账号"></label><label class="form-field"><span class="form-field__label">基准名称</span><input class="form-field__input" name="benchmark_name" required maxlength="128" value="中证全债指数"></label><label class="form-field"><span class="form-field__label">年化收益下限(%)</span><input class="form-field__input" name="annualized_return_lower_pct" type="number" min="0" max="100" step="0.01" required value="3"></label><label class="form-field"><span class="form-field__label">年化收益上限(%)</span><input class="form-field__input" name="annualized_return_upper_pct" type="number" min="0" max="100" step="0.01" required value="6"></label><label class="form-field"><span class="form-field__label">最大回撤(%)</span><input class="form-field__input" name="max_drawdown_pct" type="number" min="0" max="100" step="0.01" required value="10"></label><label class="form-field"><span class="form-field__label">投资期限(月)</span><input class="form-field__input" name="investment_horizon_months" type="number" min="1" max="600" required value="36"></label><label class="form-field"><span class="form-field__label">流动性要求</span><select class="form-field__input" name="liquidity_requirement"><option value="daily">可随时使用</option><option value="within_7_days">7 日内可使用</option><option value="within_30_days">30 日内可使用</option><option value="over_30_days">30 日后可使用</option></select></label><label class="form-field advisor-form__wide"><span class="form-field__label">备注</span><textarea class="form-field__input advisor-form__textarea" name="notes" maxlength="1000" rows="3" placeholder="记录客户目标背景(不得填写收益承诺)"></textarea></label></div><button class="button button--primary" type="submit">保存客户目标</button></form>`;
output.querySelector('[data-goal-form]').addEventListener('submit', submitGoal);
}
// ---- 目标确认与方案书:把"录入目标"之后断掉的流程接上 ----
//
// 此前工作台只有 4 个"生成草案"的操作 + 1 个只读列表,而**确认目标**与
// **查看方案书**这两个端点虽然存在却没有入口,于是目标永远停在
// `pending_confirmation`、方案书永远停在 `pending`(实测客户 9001 正是如此)。
function renderGoalStatusForm() {
output.innerHTML = `<div class="advisor-inline-form"><label class="form-field"><span class="form-field__label">客户 ID</span><input class="form-field__input" data-goal-customer type="number" min="1" value="9001"></label><button class="button button--primary" type="button" data-load-goal>查询目标</button></div><p class="advisor-output__message">查到目标后可在此确认目标、查看方案书。</p>`;
output.querySelector('[data-load-goal]').addEventListener('click', () => {
const value = Number(output.querySelector('[data-goal-customer]').value);
if (!value) { showAlert('请填写客户 ID。'); return; }
loadGoalStatus(value);
});
}
document.querySelector('[data-advisor-scope]').textContent =
`数据范围:${context?.dataScope || 'assigned'}`;
async function loadGoalStatus(customerId) {
clearAlert();
output.innerHTML = '<div class="advisor-output__loading">正在查询客户目标…</div>';
try {
const response = await apiClient.get('ADVISOR_CUSTOMER_GOAL', { pathParams: { customerId } });
renderGoalStatus(response.data ?? response);
} catch (error) {
apiClient.reportError(error);
// 404「当前投资目标不存在」是**正常情况**(客户还没录入目标),
// 要给出下一步提示,而不是当成故障报警。
if (error.status === 404) {
showAlert(`客户 ${customerId} 还没有投资目标,请先用「录入客户目标」创建。`, 'info');
output.innerHTML = '<div class="advisor-output__placeholder">该客户暂无投资目标。</div>';
return;
}
showAlert(error.message || '查询未完成,请稍后重试。');
output.innerHTML = '<div class="advisor-output__placeholder">查询失败,请检查权限或稍后重试。</div>';
}
}
const published = createPublishedModule({
list: document.querySelector('[data-recommendations]'),
metrics: document.querySelector('[data-advisor-metrics]'),
});
const actions = createActionsModule({
output: document.querySelector('[data-action-output]'),
alert: document.querySelector('[data-action-alert]'),
});
function renderGoalStatus(goal) {
const goalNo = goal.goal_no;
const status = goal.status;
const book = goal.goal_book || {};
const canConfirm = status === 'pending_confirmation';
const rows = [
['目标编号', goalNo],
['客户', goal.customer_id],
['年化收益区间', `${goal.annualized_return_lower_pct}% ~ ${goal.annualized_return_upper_pct}%`],
['最大回撤', `${goal.max_drawdown_pct}%`],
['投资期限', `${goal.investment_horizon_months} 个月`],
['业绩基准', goal.benchmark_name],
['确认时间', goal.confirmed_at ? formatDateTime(goal.confirmed_at) : '尚未确认'],
];
output.innerHTML = `<div class="advisor-output__header"><strong>客户目标</strong><span class="status-tag status-tag--active">${escapeHtml(GOAL_STATUS_LABELS[status] || status || '--')}</span></div><div class="detail-facts">${rows.map(([label, value]) => `<div class="detail-facts__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value ?? '--'))}</strong></div>`).join('')}<div class="detail-facts__row"><span>方案书</span><strong>${escapeHtml(BOOK_STATUS_LABELS[book.review_status] || book.review_status || '--')}</strong></div></div><div class="advisor-inline-form">${canConfirm ? '<button class="button button--primary" type="button" data-confirm-goal>确认目标</button>' : ''}<button class="button" type="button" data-view-book>查看方案书</button></div><p class="advisor-output__message">${canConfirm ? '确认后目标才可用于资产配置与推荐;方案书需由管理员审核发布。' : '方案书的审核与发布由管理员完成,投顾侧到此为止。'}</p>`;
output.querySelector('[data-confirm-goal]')?.addEventListener('click', () => confirmGoal(goalNo));
output.querySelector('[data-view-book]')?.addEventListener('click', () => viewGoalBook(goalNo));
}
async function confirmGoal(goalNo) {
clearAlert();
try {
await apiClient.post('ADVISOR_CONFIRM_GOAL', { confirmed: true }, { pathParams: { goalNo } });
showAlert('目标已确认。', 'info');
const customerId = Number(output.querySelector('[data-goal-customer]')?.value || 9001);
await loadGoalStatus(customerId);
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '确认未完成,请稍后重试。');
}
}
async function viewGoalBook(goalNo) {
clearAlert();
try {
const response = await apiClient.get('ADVISOR_GOAL_BOOK', { pathParams: { goalNo } });
const data = response.data ?? response;
const sections = data.content?.sections || {};
const objective = sections.investment_objective || {};
const expectation = objective.annualized_return_expectation_pct || {};
const rows = [
['业绩基准', objective.benchmark_name],
['年化收益期望', `${expectation.lower ?? '--'}% ~ ${expectation.upper ?? '--'}%`],
['最大回撤', sections.risk_boundary?.maximum_drawdown_pct ? `${sections.risk_boundary.maximum_drawdown_pct}%` : null],
['投资期限', sections.investment_horizon?.months ? `${sections.investment_horizon.months} 个月` : null],
['流动性', sections.liquidity?.description],
];
output.innerHTML = `<div class="advisor-output__header"><strong>投资目标方案书</strong><span class="status-tag status-tag--active">${escapeHtml(BOOK_STATUS_LABELS[data.review_status] || data.review_status || '--')}</span></div><div class="detail-facts">${rows.map(([label, value]) => `<div class="detail-facts__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value ?? '--'))}</strong></div>`).join('')}</div>${(data.content?.disclosures || []).map((item) => `<p class="advisor-output__message">${escapeHtml(item)}</p>`).join('')}`;
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '方案书读取失败。');
}
}
async function submitGoal(event) {
event.preventDefault(); clearAlert();
const form = event.currentTarget; const value = (name) => form.elements[name].value.trim();
const lower = Number(value('annualized_return_lower_pct')); const upper = Number(value('annualized_return_upper_pct'));
if (lower > upper) { showAlert('年化收益下限不能高于上限。'); return; }
const body = { annualized_return_lower_pct: value('annualized_return_lower_pct'), annualized_return_upper_pct: value('annualized_return_upper_pct'), max_drawdown_pct: value('max_drawdown_pct'), liquidity_requirement: value('liquidity_requirement'), investment_horizon_months: Number(value('investment_horizon_months')), benchmark_name: value('benchmark_name'), notes: value('notes') || null };
if (value('customer_id')) body.customer_id = Number(value('customer_id'));
clearAlert();
output.innerHTML = '<div class="advisor-output__loading">正在保存客户目标…</div>';
try {
const response = await apiClient.post('ADVISOR_CREATE_GOAL', body);
const data = response.data ?? response;
// 创建出的目标处于 `pending_confirmation`,**必须确认才能用于后续分析**。
// 所以这里直接跳到目标状态页,把确认按钮摆在眼前,而不是停在"已提交"。
if (body.customer_id) {
showAlert('客户目标已保存,请确认目标。', 'info');
await loadGoalStatus(body.customer_id);
return;
}
renderResult('客户目标已保存,等待确认与审核', data);
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '保存未完成,请稍后重试。');
output.innerHTML = '<div class="advisor-output__placeholder">请求失败,请检查权限或稍后重试。</div>';
}
}
async function runAction(endpoint, body, title) {
clearAlert(); output.innerHTML = '<div class="advisor-output__loading">正在请求服务端分析…</div>';
try { const response = await apiClient.post(endpoint, body); const data = response.data ?? response; renderResult(title, data); }
catch (error) { apiClient.reportError(error); showAlert(error.message || '请求未完成,请稍后重试。'); output.innerHTML = '<div class="advisor-output__placeholder">请求失败,请检查权限或稍后重试。</div>'; }
}
function openAction(action) {
clearAlert();
if (action === 'goal') { renderGoalForm(); return; }
if (action === 'goal-status') { renderGoalStatusForm(); return; }
if (action === 'recommend') {
output.innerHTML = '<div class="advisor-inline-form"><label class="form-field"><span class="form-field__label">推荐数量</span><select class="form-field__input" data-recommend-limit><option value="1">1 个</option><option value="2">2 个</option><option value="3" selected>3 个</option></select></label><button class="button button--primary" type="button" data-submit-recommend>生成草案</button></div>';
output.querySelector('[data-submit-recommend]').addEventListener('click', () => runAction('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案'));
return;
}
runAction(action === 'portfolio' ? 'ADVISOR_ANALYSIS' : 'ADVISOR_ALLOCATION', {}, actionLabels[action]);
}
document.querySelectorAll('[data-action]').forEach((button) => button.addEventListener('click', () => openAction(button.dataset.action)));
document.querySelector('[data-refresh]').addEventListener('click', load);
load();
actions.bind();
document.querySelector('[data-refresh]').addEventListener('click', published.load);
published.load();
}