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 = `
客户目标${escapeHtml(GOAL_STATUS_LABELS[status] || status || '--')}
${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 = `
投资目标方案书${escapeHtml(BOOK_STATUS_LABELS[data.review_status] || data.review_status || '--')}
${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 = `
${escapeHtml(title)}${escapeHtml(status || 'ready')}
${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 = `
客户目标${escapeHtml(GOAL_STATUS_LABELS[status] || status || '--')}
${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 = `
投资目标方案书${escapeHtml(BOOK_STATUS_LABELS[data.review_status] || data.review_status || '--')}
${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())