From e096ffab22f8ceaae684f91e4e00526b9570694f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Mon, 14 Sep 2026 02:07:44 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=95=E9=A1=BE=E5=B7=A5?=
=?UTF-8?q?=E4=BD=9C=E5=8F=B0=E7=99=BD=E6=9D=BF=EF=BC=9A=E8=A1=A5=E4=B8=8A?=
=?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8B=86=E5=88=86=E6=97=B6=E6=BC=8F=E6=8E=89?=
=?UTF-8?q?=E7=9A=84=20import?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 问题(合并进来的故障,不是本次会话改坏的)
合并 `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. 第一版用 `(? **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
---
.../dashboard/actions-module.js | 108 ++++++++-
.../dashboard/advisor-config.js | 17 ++
.../employee-advisor/dashboard/dashboard.js | 215 +++---------------
tests/unit/api/test_portal_frontend.py | 22 ++
tools/check_portal_modules.py | 182 +++++++++++++++
5 files changed, 355 insertions(+), 189 deletions(-)
create mode 100644 tools/check_portal_modules.py
diff --git a/app/static/portal/employee-advisor/dashboard/actions-module.js b/app/static/portal/employee-advisor/dashboard/actions-module.js
index cb94b53..6503bb6 100644
--- a/app/static/portal/employee-advisor/dashboard/actions-module.js
+++ b/app/static/portal/employee-advisor/dashboard/actions-module.js
@@ -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 = '
查到目标后可在此确认目标、查看方案书。
';
+ 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 = '正在查询客户目标…
';
+ 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 = '该客户暂无投资目标。
';
+ return;
+ }
+ showAlert(error.message || '查询未完成,请稍后重试。');
+ output.innerHTML = '查询失败,请检查权限或稍后重试。
';
+ }
+ }
+
+ 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 = `${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
方案书${escapeHtml(BOOK_STATUS_LABELS[book.review_status] || book.review_status || '--')}
${canConfirm ? '' : ''}
${canConfirm ? '确认后目标才可用于资产配置与推荐;方案书需由管理员审核发布。' : '方案书的审核与发布由管理员完成,投顾侧到此为止。'}
`;
+ 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 = `${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
${(data.content?.disclosures || []).map((item) => `${escapeHtml(item)}
`).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 = '';
output.querySelector('[data-submit-recommend]').addEventListener('click', () => run('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案'));
diff --git a/app/static/portal/employee-advisor/dashboard/advisor-config.js b/app/static/portal/employee-advisor/dashboard/advisor-config.js
index bc588e2..edeabad 100644
--- a/app/static/portal/employee-advisor/dashboard/advisor-config.js
+++ b/app/static/portal/employee-advisor/dashboard/advisor-config.js
@@ -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: '已退回',
+});
diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js
index 4bc7773..ce24bdd 100644
--- a/app/static/portal/employee-advisor/dashboard/dashboard.js
+++ b/app/static/portal/employee-advisor/dashboard/dashboard.js
@@ -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]) => `${label}
${value}
${meta}
`).join('');
- if (!rows.length) { renderEmpty(list, '暂未发布方案', '当前账号暂无已审核发布的客户方案。'); return; }
- list.innerHTML = rows.map((row) => `${escapeHtml(CONTENT_TYPE_LABELS[row.content_type] || '方案')} · 客户 ${escapeHtml(row.customer_id || '--')}
发布时间:${escapeHtml(formatDateTime(row.published_at))}
${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}
`).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 = `${message ? `${escapeHtml(message)}
` : `${escapeHtml(JSON.stringify(data, null, 2))}`}`;
- }
- function renderGoalForm() {
- output.innerHTML = ``;
- output.querySelector('[data-goal-form]').addEventListener('submit', submitGoal);
- }
- // ---- 目标确认与方案书:把"录入目标"之后断掉的流程接上 ----
- //
- // 此前工作台只有 4 个"生成草案"的操作 + 1 个只读列表,而**确认目标**与
- // **查看方案书**这两个端点虽然存在却没有入口,于是目标永远停在
- // `pending_confirmation`、方案书永远停在 `pending`(实测客户 9001 正是如此)。
- function renderGoalStatusForm() {
- output.innerHTML = `查到目标后可在此确认目标、查看方案书。
`;
- 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 = '正在查询客户目标…
';
- 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 = '该客户暂无投资目标。
';
- return;
- }
- showAlert(error.message || '查询未完成,请稍后重试。');
- output.innerHTML = '查询失败,请检查权限或稍后重试。
';
- }
- }
+ 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 = `${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
方案书${escapeHtml(BOOK_STATUS_LABELS[book.review_status] || book.review_status || '--')}
${canConfirm ? '' : ''}
${canConfirm ? '确认后目标才可用于资产配置与推荐;方案书需由管理员审核发布。' : '方案书的审核与发布由管理员完成,投顾侧到此为止。'}
`;
- 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 = `${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
${(data.content?.disclosures || []).map((item) => `${escapeHtml(item)}
`).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 = '正在保存客户目标…
';
- 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 = '请求失败,请检查权限或稍后重试。
';
- }
- }
- async function runAction(endpoint, body, title) {
- clearAlert(); output.innerHTML = '正在请求服务端分析…
';
- 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 = '请求失败,请检查权限或稍后重试。
'; }
- }
- function openAction(action) {
- clearAlert();
- if (action === 'goal') { renderGoalForm(); return; }
- if (action === 'goal-status') { renderGoalStatusForm(); return; }
- if (action === 'recommend') {
- output.innerHTML = '';
- 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();
}
diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py
index 1b80898..7fda51f 100644
--- a/tests/unit/api/test_portal_frontend.py
+++ b/tests/unit/api/test_portal_frontend.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import subprocess
+import sys
from pathlib import Path
import httpx
@@ -127,6 +129,26 @@ def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
assert "ACTION_LABELS" in config
+def test_portal_feature_modules_have_consistent_imports() -> None:
+ """拆分前端模块时最容易漏 import:定义搬走了,使用处却留在原文件。
+
+ 这类问题**上面那些字符串断言全都看不见** —— 只会在浏览器里以
+ `ReferenceError: XXX is not defined` 爆出来,表现为"投顾工作台打开是白板",
+ 而 Python 测试一片绿。2026-09-13 合并进来的提交就真的发生了:
+ `dashboard.js` 还在用已经搬进 `advisor-config.js` 的 `CONTENT_TYPE_LABELS`。
+
+ 检查逻辑在 `tools/check_portal_modules.py`(语法 + import 可解析 + 常量有来源),
+ 这里只是把它接进测试,保证以后每次跑测试都会执行到。
+ """
+ result = subprocess.run(
+ [sys.executable, str(ROOT / "tools" / "check_portal_modules.py")],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert result.returncode == 0, f"{result.stdout}\n{result.stderr}"
+
+
def test_risk_scan_endpoint_uses_extended_timeout() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
assert (
diff --git a/tools/check_portal_modules.py b/tools/check_portal_modules.py
new file mode 100644
index 0000000..d1b660e
--- /dev/null
+++ b/tools/check_portal_modules.py
@@ -0,0 +1,182 @@
+"""验证投顾工作台四个前端模块:语法正确、import 能解析、用到的符号有来源。
+
+## 为什么要这个检查
+
+2026-09-13 合并组员提交后发现:`dashboard.js` 被拆成三个模块后,
+**`CONTENT_TYPE_LABELS` 的定义搬走了、import 却忘了加** —— 文件里还在用这个常量。
+这类问题单元测试抓不到(前端 js 不过 Python 的导入检查),
+只会在浏览器里以 `ReferenceError: CONTENT_TYPE_LABELS is not defined` 的形式爆出来,
+表现为「投顾工作台打开是白板」,而且**测试全绿**。
+
+所以这里做三件事:
+1. 每个文件交给 `node --check` 按 ES module 解析(语法错当场暴露);
+2. 每个相对 import 的目标文件必须存在;
+3. 每个 import 进来的名字,在目标文件里必须真的有 `export`;
+4. 文件里用到的全大写常量(`FOO_BAR` 形态、且不是 `x.FOO_BAR` 属性访问),
+ 必须在「import 进来的 + 本文件声明的 + 已知全局」里 —— 第 4 条正是抓上面那个 bug 的。
+
+第 4 条是启发式,会刻意避开引号内的字符串与对象字面量的 key,减少误报。
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+PORTAL = PROJECT_ROOT / "app" / "static" / "portal"
+DASHBOARD_DIR = PORTAL / "employee-advisor" / "dashboard"
+
+MODULES = [
+ "dashboard.js",
+ "actions-module.js",
+ "published-module.js",
+ "advisor-config.js",
+]
+
+#: 浏览器与运行时天然存在的名字,不算"没来源"。
+KNOWN_GLOBALS = {
+ "JSON", "Math", "Number", "String", "Boolean", "Object", "Array", "Promise",
+ "Date", "Error", "Map", "Set", "RegExp", "Symbol", "URL", "URLSearchParams",
+ "NaN", "Infinity", "undefined", "null", "true", "false", "console", "window",
+ "document", "localStorage", "sessionStorage", "fetch", "setTimeout",
+ "clearTimeout", "setInterval", "clearInterval", "requestAnimationFrame",
+ "HTMLElement", "Node", "Event", "CustomEvent", "AbortController",
+}
+
+IMPORT_RE = re.compile(
+ r"import\s+(?:(?P\{[^}]*\})|(?P[\w$]+))\s+from\s+['\"](?P[^'\"]+)['\"]"
+)
+EXPORT_RE = re.compile(
+ r"export\s+(?:const|let|var|function|class)\s+(?P[\w$]+)"
+ r"|export\s*\{(?P[^}]*)\}"
+)
+#: 全大写常量:至少两个字符、全大写/数字/下划线,且不是"前面带点"的属性访问。
+CONST_USE_RE = re.compile(r"(? set[str]:
+ source = path.read_text(encoding="utf-8")
+ names: set[str] = set()
+ for match in EXPORT_RE.finditer(source):
+ if match.group("name"):
+ names.add(match.group("name"))
+ if match.group("list"):
+ for piece in match.group("list").split(","):
+ piece = piece.strip()
+ if not piece:
+ continue
+ names.add(piece.split(" as ")[-1].strip())
+ return names
+
+
+def resolve_import(source: Path, spec: str) -> Path | None:
+ """相对 import 才能解析到本地文件;`/static/...` 这类站内绝对路径跳过。"""
+ if not spec.startswith("."):
+ return None
+ target = (source.parent / spec.split("?")[0]).resolve()
+ return target if target.is_file() else None
+
+
+def check_syntax(paths: list[Path]) -> list[str]:
+ """用 node 按 ES module 解析。装不了 node 就跳过(不让环境问题变成假失败)。"""
+ node = shutil.which("node")
+ if node is None:
+ return []
+
+ problems: list[str] = []
+ with tempfile.TemporaryDirectory() as tmp:
+ for path in paths:
+ # `node --check` 按扩展名决定模块类型,所以复制成 .mjs 再检查。
+ probe = Path(tmp) / (path.stem + ".mjs")
+ probe.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
+ result = subprocess.run(
+ [node, "--check", str(probe)], capture_output=True, text=True, check=False
+ )
+ if result.returncode != 0:
+ tail = (result.stderr or "").strip().splitlines()
+ problems.append(f"{path.name}: 语法错误 {tail[-1] if tail else ''}")
+ return problems
+
+
+def main() -> int:
+ paths = [DASHBOARD_DIR / name for name in MODULES]
+ missing = [p.name for p in paths if not p.is_file()]
+ if missing:
+ print(f"[失败] 缺少文件:{missing}")
+ return 1
+
+ problems = check_syntax(paths)
+
+ for path in paths:
+ source = path.read_text(encoding="utf-8")
+
+ # ---- import 的目标必须存在,且名字必须真被导出 ----
+ imported: set[str] = set()
+ for match in IMPORT_RE.finditer(source):
+ spec = match.group("src")
+ if match.group("names"):
+ for piece in match.group("names").strip("{}").split(","):
+ piece = piece.strip()
+ if piece:
+ imported.add(piece.split(" as ")[-1].strip())
+ if match.group("default"):
+ imported.add(match.group("default"))
+
+ target = resolve_import(path, spec)
+ if target is None:
+ if spec.startswith("."):
+ problems.append(f"{path.name}: import '{spec}' 指向的文件不存在")
+ continue
+ available = exported_names(target)
+ wanted = set()
+ if match.group("names"):
+ for piece in match.group("names").strip("{}").split(","):
+ piece = piece.strip()
+ if piece:
+ wanted.add(piece.split(" as ")[0].strip())
+ for name in wanted - available:
+ problems.append(
+ f"{path.name}: 从 {target.name} 导入了 '{name}',但那边没有导出它"
+ )
+
+ # ---- 用到的全大写常量必须有来源 ----
+ declared = set(re.findall(r"(?:const|let|var|function|class)\s+([A-Z][A-Z0-9_]{2,})\b", source))
+ declared |= exported_names(path)
+ # 剔除字符串字面量与对象 key,避免把文案/枚举 key 当成变量使用。
+ stripped = OBJECT_KEY_RE.sub(" ", STRING_RE.sub(" ", source))
+ for match in CONST_USE_RE.finditer(stripped):
+ name = match.group(1)
+ if name in imported or name in declared or name in KNOWN_GLOBALS:
+ continue
+ problems.append(f"{path.name}: 使用了 '{name}',但既没 import 也没在本文件声明")
+
+ print(f"检查 {len(paths)} 个模块:{', '.join(p.name for p in paths)}")
+ if problems:
+ print(f"\n发现 {len(problems)} 个问题:")
+ for problem in problems:
+ print(f" · {problem}")
+ print(
+ "\n提示:拆分模块时最容易漏 import —— 定义搬走了、使用处还留在原文件,"
+ "\n 浏览器里表现为页面白板 + ReferenceError,而 Python 测试全绿。"
+ )
+ return 1
+
+ print("全部通过:语法正确、import 可解析、常量都有来源。")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())