diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js
index 75457c7..3dcf77b 100644
--- a/app/static/portal/common/api-client.js
+++ b/app/static/portal/common/api-client.js
@@ -1,6 +1,9 @@
import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js?v=20260913';
const ENDPOINTS = Object.freeze({
+ // 健康检查没有 `data` 信封(是裸的 `{"status": ...}`),所以必须 `raw: true` ——
+ // 否则调用方拿到 `payload.data`(undefined),会把"后端在线"判成"离线"。
+ HEALTH: { method: 'GET', path: '/health', auth: false, raw: true },
A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false },
V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true },
P001: { method: 'GET', path: '/api/v1/products' },
@@ -77,8 +80,16 @@ const ENDPOINTS = Object.freeze({
// 固定成一张清单,**删注册会破坏它**。它对应 AD002,当前页面确实没调用
// (投顾本人没有"自己的投资目标",调它返回 404)—— 但**注册与调用是两件事**。
ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' },
- ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis' },
- ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation' },
+ // ⚠️ 这三个 POST 的响应形状**取决于是否带 `Idempotency-Key`**:
+ // · 不带键(ALLOCATION / ANALYSIS 的常态)→ **裸业务文档**,顶层键是 `status` / `allocation` / `summary`…,
+ // 必须标 `raw`,否则 `payload.data` 取到 `undefined`,整包被丢掉(2026-09-14 踩过)。
+ // · 带键(RECOMMEND 标了 `idempotent`,浏览器必带)→ **`{data, meta}` 信封**,且 `data` 是
+ // `{content_id, status, plan:{…}}` —— 真正的文档嵌在 `plan` 里(方案已落库待审核)。
+ // 所以 RECOMMEND **不能**标 `raw`,让 `request()` 正常解包;`plan` 这层嵌套由
+ // `actions-module` 的 `normalizeRecommend()` 归一。曾把 raw 误加到 RECOMMEND 上,
+ // 结果信封被当成数据,页面显示「后端返回状态:undefined」。
+ ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis', raw: true },
+ ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation', raw: true },
ADVISOR_RECOMMEND: { method: 'POST', path: '/api/v1/advisor/recommendations', idempotent: true },
ADVISOR_CREATE_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals', idempotent: true },
ADVISOR_CUSTOMER_GOAL: { method: 'GET', path: '/api/v1/advisor/customers/{customerId}/investment-goals/current' },
diff --git a/app/static/portal/employee-advisor/dashboard/actions-module.js b/app/static/portal/employee-advisor/dashboard/actions-module.js
index 6503bb6..60ec4b8 100644
--- a/app/static/portal/employee-advisor/dashboard/actions-module.js
+++ b/app/static/portal/employee-advisor/dashboard/actions-module.js
@@ -1,13 +1,90 @@
-import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
-import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
+// 操作模块:七项操作的表单、请求与结果渲染。
+//
+// 三条设计约束,都是踩过的坑换来的:
+//
+// 1. **同一动作只有一个请求入口** —— `resolve()` 决定"真实后端还是本地引擎"并返回
+// 归一化数据;`open()`(点卡片)和 `assistant-module`(自然语言)都走它。
+// 渲染也用同一批函数,所以真实响应与本地结果共用一条渲染分支。
+// 2. **`[data-action]` 的新增动作必须在这里的 `FORMS` / `DIRECT` 里登记**
+// —— `bind()` 会给所有 `[data-action]` 挂上 `open()`,没登记的会掉进兜底分支。
+// 原来的注释就写着:点了没反应算好的,发错请求才麻烦。
+// 3. **合规熔断在前端** —— `SuitabilityService` 目前只判 `valid_until` 是否为空、
+// 不比较是否过期,所以 FM-03 只能在这里拦。
+
+import { apiClient } from '/static/portal/common/api-client.js?v=20260914-3';
+import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
import {
+ ACTION_DESCRIPTIONS,
+ ACTION_ENDPOINTS,
ACTION_LABELS,
BOOK_STATUS_LABELS,
+ CUSTOMER_SCOPED_ACTIONS,
+ FUSE_GUARDED,
GOAL_STATUS_LABELS,
+ PIPELINE_BLOCK_INDEX,
RESULT_MESSAGES,
-} from './advisor-config.js';
+ SCORING_WEIGHTS,
+} from './advisor-config.js?v=20260914-advisor9';
+import {
+ assessmentFuseHits,
+ engineAllocation,
+ engineDiagnosis,
+ engineRebalance,
+ engineRecommend,
+ engineScoring,
+ isAssessmentExpiring,
+} from './advisor-engine.js?v=20260914-advisor9';
+
+const READY_STATUSES = ['ready', 'pending_review'];
+//: 需要先出小表单再执行的动作(推荐数量 / 客户目标 / 目标查询)。
+const FORMS = ['recommend', 'goal', 'goal-status'];
+//: 直接执行的动作。
+const DIRECT = ['portfolio', 'allocation', 'scoring', 'rebalance'];
+
+// 推荐接口的响应形状**取决于是否带 Idempotency-Key**(该端点标了 `idempotent`,浏览器必带):
+// 带键 → `{data:{content_id, status, plan:{products, disclosures,…}}, meta}`(方案已落库待审核)
+// 不带键 → 裸文档 `{status, products, disclosures,…}`
+// 这里把两种形状归一成「文档 + 方案编号 + 状态」,渲染层就不用写两套。
+// `plan` 里的文档自己不带 `status`,外层的才是权威状态。
+function normalizeRecommend(data) {
+ if (!data || typeof data !== 'object') return data;
+ if (typeof data.plan !== 'object' || data.plan === null) return data;
+ return { ...data.plan, status: data.status ?? data.plan.status, content_id: data.content_id };
+}
+
+function table(headers, rows) {
+ if (!rows.length) return '
暂无数据。
';
+ return ''
+ + headers.map((head) => `| ${escapeHtml(head)} | `).join('')
+ + '
'
+ + rows.map((cells) => `${cells.map((cell) => `| ${cell} | `).join('')}
`).join('')
+ + '
';
+}
+
+function factGrid(rows) {
+ return `${rows.map(([label, value]) => `
${escapeHtml(label)}${value}`).join('')}
`;
+}
+
+function tag(text, className = 'status-tag status-tag--active') {
+ return `${escapeHtml(text)}`;
+}
+
+function header(title, source, badge) {
+ const sourceTag = `${escapeHtml(source === 'real' ? '真实后端' : '本地演示引擎')}`;
+ return ``;
+}
+
+function disclaimer(text) {
+ return `${escapeHtml(text)}
`;
+}
+
+function note(text, tone = 'info') {
+ return `${escapeHtml(text)}
`;
+}
+
+export function createActionsModule({ output, alert, steps, amountInput, horizonSelect, log, customers, online }) {
+ let pendingLimit = 3;
-export function createActionsModule({ output, alert }) {
function showAlert(message, kind = 'error') {
alert.textContent = message;
alert.className = `form-alert form-alert--visible${kind === 'info' ? ' form-alert--info' : ''}`;
@@ -18,27 +95,323 @@ export function createActionsModule({ output, alert }) {
alert.className = 'form-alert';
}
- function renderResult(title, data) {
- const status = data?.status;
- const message = RESULT_MESSAGES[status];
- output.innerHTML = `${message ? `${escapeHtml(message)}
` : `${escapeHtml(JSON.stringify(data, null, 2))}`}`;
+ function setLoading(label) {
+ output.innerHTML = `正在请求服务端:${escapeHtml(label)}…
`;
}
- async function run(endpoint, body, title) {
+ function animate(blockedIndex, done) {
+ const items = [...steps.children];
+ items.forEach((item) => { item.className = 'advisor-step'; });
+ let index = 0;
+ const timer = window.setInterval(() => {
+ if (index > 0) {
+ const previous = items[index - 1];
+ const blocked = blockedIndex !== null && index - 1 === blockedIndex;
+ previous.className = `advisor-step ${blocked ? 'advisor-step--blocked' : 'advisor-step--done'}`;
+ }
+ if ((blockedIndex !== null && index === blockedIndex + 1) || index >= items.length) {
+ window.clearInterval(timer);
+ done();
+ return;
+ }
+ items[index].className = 'advisor-step advisor-step--doing';
+ index += 1;
+ }, 300);
+ }
+
+ // ---- 合规熔断闸门 ----
+
+ function guard(action) {
+ const customer = customers.selected();
+ if (!customer) return null;
+ const hits = assessmentFuseHits(customer);
+ if (!hits.length) {
+ if (isAssessmentExpiring(customer) && FUSE_GUARDED.includes(action)) {
+ log(`提醒:${customer.name} 的风评 ${365 - customer.assessedDays} 天后到期,请提示客户复评(本次仍放行)`, 'warning');
+ }
+ return null;
+ }
+ if (FUSE_GUARDED.includes(action)) return { hits, customer };
+ log(`提醒:${customer.name} 风评超期;${ACTION_LABELS[action]}为只读或数据登记类操作,仍可执行`, 'warning');
+ return null;
+ }
+
+ function fuseMarkup(action, hits, customer) {
+ return header(ACTION_LABELS[action], 'local', tag('FM-03 熔断', 'status-tag status-tag--failed'))
+ + `⛔ 风评有效性熔断拦截${escapeHtml(hits.join(';'))}
`
+ + `
客户「${escapeHtml(customer.name)}」风险测评已失效(${customer.assessedDays} 天前,超 12 个月)。按 FM-03 冻结购买权限,当前仅可办理赎回;${escapeHtml(ACTION_LABELS[action])}已中止,请先完成风险测评复评后重试。
`
+ + note('仍可执行:组合分析(只读分析)、赎回。生成推荐方案 / 资产配置 / 调仓建议 / 画像评分 均要求风评处于有效期内。', 'info')
+ + disclaimer(`合规留痕:本次拦截已记录(客户 ${customer.name} · 风评超期 ${customer.assessedDays} 天 · 动作 ${ACTION_LABELS[action]} · 规则 FM-03)。`);
+ }
+
+ // ---- 请求入口(真实后端 / 本地引擎) ----
+
+ function willUseReal(action) {
+ return online() && CUSTOMER_SCOPED_ACTIONS.includes(action);
+ }
+
+ function localResult(action, customerId) {
+ if (action === 'portfolio') return engineDiagnosis(customerId);
+ if (action === 'allocation') return engineAllocation(customerId, horizonSelect.value);
+ if (action === 'recommend') return engineRecommend(customerId, Number(amountInput.value) || 0);
+ if (action === 'scoring') return engineScoring(customerId);
+ return engineRebalance(customerId);
+ }
+
+ async function resolve(action) {
+ if (!willUseReal(action)) return { data: localResult(action, customers.selectedId()), source: 'local' };
+ const dbId = customers.selectedDbId();
+ const body = dbId ? { customer_id: dbId } : {};
+ if (action === 'recommend') body.limit = pendingLimit;
+ const response = await apiClient.post(ACTION_ENDPOINTS[action], body);
+ const data = response.data ?? response;
+ return { data: action === 'recommend' ? normalizeRecommend(data) : data, source: 'real' };
+ }
+
+ // ---- 结果渲染 ----
+
+ function renderRecommend(data, source) {
+ if (data.status === 'blocked_by_fuse') {
+ return header('生成推荐方案', source, tag('FM-03 熔断', 'status-tag status-tag--failed'))
+ + `⛔ 熔断拦截${escapeHtml((data.fuse_hits || []).join(';'))}
`
+ + '
流程中止,转人工处理。客户当前仅可办理赎回。
';
+ }
+ const message = RESULT_MESSAGES[data.status];
+ if (message) {
+ return header('生成推荐方案', source, tag(data.status)) + note(message, 'info');
+ }
+ if (!READY_STATUSES.includes(data.status)) {
+ return header('生成推荐方案', source, tag(data.status || '--')) + note(`后端返回状态:${data.status}`);
+ }
+
+ // 本地演示引擎的结果
+ if (data.plans) {
+ let html = header('生成推荐方案', source, tag('草稿 pending_review'))
+ + note(`本地演示引擎草稿 ${data.content_id}:仅为规则模拟,不构成真实推荐。`, 'info');
+ if ((data.blocked || []).length) {
+ html += table(['被适当性硬拦截的产品', '原因'], data.blocked.map((item) => [
+ escapeHtml(item.name), escapeHtml(item.reason),
+ ]));
+ }
+ html += table(['产品', '风险级', '配比', '依据'], data.plans.map((plan) => [
+ `${escapeHtml(plan.name)}${escapeHtml(plan.code)} · ${escapeHtml(plan.manager)}`,
+ escapeHtml(plan.risk),
+ `${plan.ratio_pct}% ≈ ${plan.amount_wan} 万`,
+ escapeHtml(plan.reason),
+ ]));
+ (data.warn_alternatives || []).forEach((item) => {
+ html += note(`[越级备选] ${item.name}(${item.risk}):${item.note}`, 'info');
+ });
+ return html + disclaimer(`${data.disclaimer} 留痕 ≥ 20 年。`);
+ }
+
+ // 真实后端推荐
+ const products = data.products || [];
+ const badge = data.analysis_only ? '草稿 analysis_only' : '草稿 pending_review';
+ let html = header('生成推荐方案', source, tag(badge));
+ if (data.content_id) {
+ html += note(`方案已生成(编号 ${data.content_id}),状态待审核;须管理员审核发布后才对客户可见。`, 'info');
+ }
+ if (!products.length) {
+ html += note('当前没有通过适当性与证据校验的产品。', 'info');
+ } else {
+ html += table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => {
+ const evidence = product.recommendation_evidence_card || {};
+ return [
+ `${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.product_category)}`,
+ escapeHtml((evidence.suitability || {}).risk_level || '--'),
+ escapeHtml(String(product.score ?? '--')),
+ escapeHtml(product.reason || '--'),
+ ];
+ }));
+ }
+ (data.disclosures || []).forEach((text) => { html += disclaimer(text); });
+ return html;
+ }
+
+ function renderAllocation(data, source) {
+ const message = RESULT_MESSAGES[data.status];
+ if (message) return header('资产配置', source, tag(data.status)) + note(message, 'info');
+ if (!READY_STATUSES.includes(data.status)) {
+ return header('资产配置', source, tag(data.status || '--')) + note(`后端返回状态:${data.status}`);
+ }
+ let html = header('资产配置', source, tag(data.rows ? '草稿 pending_review' : '草稿 analysis_only'));
+ if (data.rows) {
+ html += table(['资产类别', '当前', '目标', '差额', '优先级'], data.rows.map((row) => [
+ escapeHtml(row.category), `${row.current_pct}%`, `${row.target_pct}%`,
+ `${row.diff_pct > 0 ? '+' : ''}${row.diff_pct}%`,
+ escapeHtml(row.priority),
+ ]));
+ html += disclaimer(`预期收益区间:${data.expected_return}(业绩比较基准 ≠ 收益承诺)。${data.disclaimer}`);
+ return html;
+ }
+ const allocation = data.allocation || [];
+ if (allocation.length) {
+ html += table(['资产类别', '目标比例'], allocation.map((item) => [
+ escapeHtml(item.label || item.asset_class), `${item.target_pct}%`,
+ ]));
+ }
+ const constraints = data.constraints;
+ if (constraints) {
+ html += factGrid([
+ ['年化收益下限', escapeHtml(String(constraints.annualized_return_lower_pct ?? '--'))],
+ ['最大回撤', escapeHtml(String(constraints.max_drawdown_pct ?? '--'))],
+ ['流动性要求', escapeHtml(String(constraints.liquidity_requirement ?? '--'))],
+ ['投资期限', `${escapeHtml(String(constraints.investment_horizon_months ?? '--'))} 个月`],
+ ]);
+ }
+ return html + disclaimer('分析仅用于方案参考,不构成交易指令。');
+ }
+
+ function renderPortfolio(data, source) {
+ const message = RESULT_MESSAGES[data.status];
+ if (message) return header('组合分析', source, tag(data.status)) + note(message, 'info');
+ if (!READY_STATUSES.includes(data.status)) {
+ return header('组合分析', source, tag(data.status || '--')) + note(`后端返回状态:${data.status}`);
+ }
+ let html = header('组合分析', source, tag(data.dims ? '草稿 pending_review' : '只读分析'));
+
+ // 本地演示引擎:8 维度诊断
+ if (data.dims) {
+ html += table(['诊断维度', '结论'], data.dims.map((item) => [
+ escapeHtml(item.dim), `${escapeHtml(item.verdict)} ${escapeHtml(item.detail)}`,
+ ]));
+ return html + disclaimer(`${data.disclaimer} 报告经 CRM 审核后可对客使用。`);
+ }
+
+ const summary = data.summary || {};
+ html += factGrid([
+ ['持仓 / 已估值', `${escapeHtml(String(summary.position_count ?? '--'))} / ${escapeHtml(String(summary.valued_position_count ?? '--'))}`],
+ ['总市值', escapeHtml(String(summary.total_market_value ?? '--'))],
+ ['行业穿透覆盖', `${escapeHtml(String(summary.industry_coverage_pct ?? '--'))}%`],
+ ['指标覆盖', `${escapeHtml(String(summary.metrics_coverage_pct ?? '--'))}%`],
+ ]);
+ const product = data.product_concentration;
+ if (product && product.rows) {
+ html += `产品集中度 · HHI ${escapeHtml(String(product.hhi ?? '--'))}
`;
+ html += table(['产品', '市值', '占比'], product.rows.map((row) => [
+ `${escapeHtml(row.product_name)}${escapeHtml(row.product_code)}`,
+ escapeHtml(String(row.market_value)), `${escapeHtml(String(row.share_pct))}%`,
+ ]));
+ }
+ const industry = data.industry_concentration;
+ if (industry && (industry.rows || []).length) {
+ html += `行业集中度 · HHI ${escapeHtml(String(industry.hhi ?? '--'))}${industry.conclusion_available ? '' : '(覆盖不足)'}
`;
+ html += table(['行业', '市值', '占比'], industry.rows.map((row) => [
+ escapeHtml(row.industry_name), escapeHtml(String(row.market_value)), `${escapeHtml(String(row.share_pct))}%`,
+ ]));
+ }
+ (data.warnings || []).forEach((warning) => {
+ html += note(`${warning.code}:${warning.message}`, 'info');
+ });
+ return html + disclaimer(data.disclaimer || '分析结果仅供参考,不生成交易指令。');
+ }
+
+ function renderScoring(data, source) {
+ if (!READY_STATUSES.includes(data.status)) {
+ return header('画像评分', source, tag(data.status || '--')) + note(RESULT_MESSAGES[data.status] || `状态:${data.status}`, 'info');
+ }
+ let html = header('画像评分', source, tag(`总分 ${data.total_score} / 100`));
+ html += factGrid([
+ ['AI 评级', escapeHtml(data.ai_level)],
+ ['申报档位', `${escapeHtml(data.declared_level)} → 采用 ${escapeHtml(data.final_level)}`],
+ ['一致性判定', escapeHtml(data.consistency)],
+ ['置信度', escapeHtml(String(data.confidence))],
+ ]);
+ html += table(['维度', '权重', '得分', '指标'], Object.entries(data.dims).map(([name, dim]) => [
+ escapeHtml(name), `${Math.round(dim.weight * 100)}%`,
+ `${dim.score} / ${SCORING_WEIGHTS[name] ?? '--'}`,
+ escapeHtml(Object.entries(dim.indicators).map(([key, value]) => `${key} ${Array.isArray(value) ? value.join('、') : value}`).join(' · ')),
+ ]));
+ return html + disclaimer(`评分依据《投资者风险画像研判规则》四维度加权模型;缺失信息按保守原则兜底。${data.disclaimer}`);
+ }
+
+ function renderRebalance(data, source) {
+ if (!data.allocation) {
+ return header('调仓建议', source, tag(data.status || '--')) + note(RESULT_MESSAGES[data.status] || '暂无持仓。', 'info');
+ }
+ let html = header('调仓建议', source, tag('草稿 pending_review'));
+ html += table(['资产类别', '当前', '目标', '差额', '优先级'], data.allocation.map((row) => [
+ escapeHtml(row.category), `${row.current_pct}%`, `${row.target_pct}%`,
+ `${row.diff_pct > 0 ? '+' : ''}${row.diff_pct}%`, escapeHtml(row.priority),
+ ]));
+ html += table(['优先级', '动作', '说明'], data.steps.map((step) => [
+ `P${step.priority}`, `${escapeHtml(step.action)} · ${escapeHtml(step.category)}`,
+ escapeHtml(step.detail),
+ ]));
+ return html + disclaimer(`${data.disclaimer} 调仓为意向建议,须经审核与客户确认后走工单执行。`);
+ }
+
+ function markupFor(action, data, source) {
+ if (action === 'portfolio') return renderPortfolio(data, source);
+ if (action === 'allocation') return renderAllocation(data, source);
+ if (action === 'recommend') return renderRecommend(data, source);
+ if (action === 'scoring') return renderScoring(data, source);
+ return renderRebalance(data, source);
+ }
+
+ function present(action, data, source) {
+ const fuse = data && data.status === 'blocked_by_fuse';
+ const blockedIndex = fuse
+ ? PIPELINE_BLOCK_INDEX.assessment
+ : (data && data.status && !READY_STATUSES.includes(data.status) ? PIPELINE_BLOCK_INDEX.default : null);
+ animate(blockedIndex, () => {
+ output.innerHTML = markupFor(action, data, source);
+ });
+ }
+
+ async function execute(action) {
clearAlert();
- output.innerHTML = '正在请求服务端分析…
';
+ const label = ACTION_LABELS[action];
+ log(`▶ ${label} · ${customers.selectedLabel()} · ${willUseReal(action) ? '真实后端' : '本地演示引擎'}`);
+ setLoading(label);
try {
- const response = await apiClient.post(endpoint, body);
- renderResult(title, response.data ?? response);
+ const { data, source } = await resolve(action);
+ present(action, data, source);
+ log(`${label} 完成(${source === 'real' ? '真实后端' : '本地演示引擎'})`, 'success');
} catch (error) {
apiClient.reportError(error);
+ if (error.status === 403) log('403 权限不足(该操作需管理员或缺少权限)', 'danger');
+ else if (error.status === 401) log('401 未认证,请重新登录', 'danger');
+ else log(`请求失败:${error.message || error}`, 'danger');
showAlert(error.message || '请求未完成,请稍后重试。');
output.innerHTML = '请求失败,请检查权限或稍后重试。
';
}
}
+ // ---- 带表单的动作 ----
+
+ function renderRecommendForm() {
+ output.innerHTML = '';
+ output.querySelector('[data-submit-recommend]').addEventListener('click', () => {
+ pendingLimit = Number(output.querySelector('[data-recommend-limit]').value) || 3;
+ execute('recommend');
+ });
+ }
+
function renderGoalForm() {
- output.innerHTML = ``;
+ const dbId = customers.selectedDbId();
+ output.innerHTML = '';
output.querySelector('[data-goal-form]').addEventListener('submit', submitGoal);
}
@@ -47,9 +420,7 @@ export function createActionsModule({ output, alert }) {
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) {
+ if (Number(value('annualized_return_lower_pct')) > Number(value('annualized_return_upper_pct'))) {
showAlert('年化收益下限不能高于上限。');
return;
}
@@ -63,20 +434,31 @@ export function createActionsModule({ output, alert }) {
notes: value('notes') || null,
};
if (value('customer_id')) body.customer_id = Number(value('customer_id'));
- await run('ADVISOR_CREATE_GOAL', body, '客户目标已提交,等待确认与审核');
+ log(`▶ ${ACTION_LABELS.goal} · 客户 ${value('customer_id') || '当前账号'}`);
+ try {
+ const response = await apiClient.post('ADVISOR_CREATE_GOAL', body);
+ const data = response.data ?? response;
+ output.innerHTML = header('录入客户目标', 'real', tag(GOAL_STATUS_LABELS[data.status] || data.status || '已提交'))
+ + factGrid([
+ ['目标编号', escapeHtml(String(data.goal_no ?? '--'))],
+ ['客户', escapeHtml(String(data.customer_id ?? '--'))],
+ ['状态', escapeHtml(GOAL_STATUS_LABELS[data.status] || String(data.status ?? '--'))],
+ ['提交时间', escapeHtml(formatDateTime(data.created_at))],
+ ])
+ + note('目标已提交,处于「待确认」;确认后才可用于资产配置与推荐。', 'info');
+ log(`${ACTION_LABELS.goal} 已提交`, 'success');
+ } catch (error) {
+ apiClient.reportError(error);
+ showAlert(error.message || '提交未完成,请稍后重试。');
+ }
}
- // ---- 目标确认与方案书:把「录入目标」之后断掉的流程接上 ----
- //
- // 此前工作台只有 4 个「生成草案」的操作 + 1 个只读列表,而**确认目标**与
- // **查看方案书**这两个端点虽然存在却没有入口,于是目标永远停在
- // `pending_confirmation`、方案书永远停在 `pending`(实测客户 9001 正是如此)。
- //
- // ⚠️ 这两个操作**必须**在这里处理:`bind()` 会给所有 `[data-action]` 按钮挂上
- // `open()`,若 `open()` 不认识 `goal-status`,它会掉到最后一行被当成
- // 「资产配置」发出去 —— 点「目标确认与方案书」却收到一份配置建议。
function renderGoalStatusForm() {
- output.innerHTML = '查到目标后可在此确认目标、查看方案书。
';
+ const dbId = customers.selectedDbId();
+ output.innerHTML = ''
+ + ``
+ + '
'
+ + '查到目标后可在此确认目标、查看投资目标方案书。
';
output.querySelector('[data-load-goal]').addEventListener('click', () => {
const value = Number(output.querySelector('[data-goal-customer]').value);
if (!value) {
@@ -89,10 +471,10 @@ export function createActionsModule({ output, alert }) {
async function loadGoalStatus(customerId) {
clearAlert();
- output.innerHTML = '正在查询客户目标…
';
+ output.innerHTML = `正在查询客户 ${escapeHtml(String(customerId))} 的目标…
`;
try {
const response = await apiClient.get('ADVISOR_CUSTOMER_GOAL', { pathParams: { customerId } });
- renderGoalStatus(response.data ?? response);
+ renderGoalStatus(customerId, response.data ?? response);
} catch (error) {
apiClient.reportError(error);
// 404「当前投资目标不存在」是**正常情况**(客户还没录入目标),
@@ -107,31 +489,36 @@ export function createActionsModule({ output, alert }) {
}
}
- function renderGoalStatus(goal) {
- const goalNo = goal.goal_no;
- const status = goal.status;
+ function renderGoalStatus(customerId, goal) {
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));
+ const canConfirm = goal.status === 'pending_confirmation';
+ let html = header('目标确认与方案书', 'real', tag(GOAL_STATUS_LABELS[goal.status] || goal.status || '--'));
+ html += factGrid([
+ ['目标编号', escapeHtml(String(goal.goal_no ?? '--'))],
+ ['客户', escapeHtml(String(goal.customer_id ?? customerId))],
+ ['年化收益区间', `${escapeHtml(String(goal.annualized_return_lower_pct ?? '--'))}% ~ ${escapeHtml(String(goal.annualized_return_upper_pct ?? '--'))}%`],
+ ['最大回撤', `${escapeHtml(String(goal.max_drawdown_pct ?? '--'))}%`],
+ ['投资期限', `${escapeHtml(String(goal.investment_horizon_months ?? '--'))} 个月`],
+ ['业绩基准', escapeHtml(String(goal.benchmark_name ?? '--'))],
+ ['确认时间', escapeHtml(goal.confirmed_at ? formatDateTime(goal.confirmed_at) : '尚未确认')],
+ ['方案书', escapeHtml(BOOK_STATUS_LABELS[book.review_status] || String(book.review_status ?? '--'))],
+ ]);
+ html += ''
+ + (canConfirm ? '' : '')
+ + '
';
+ html += note(canConfirm
+ ? '确认后目标才可用于资产配置与推荐;方案书需由管理员审核发布。'
+ : '方案书的审核与发布由管理员完成,投顾侧到此为止。', 'info');
+ output.innerHTML = html;
+ output.querySelector('[data-confirm-goal]')?.addEventListener('click', () => confirmGoal(customerId, goal.goal_no));
+ output.querySelector('[data-view-book]')?.addEventListener('click', () => viewGoalBook(goal.goal_no));
}
- async function confirmGoal(goalNo) {
+ async function confirmGoal(customerId, 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);
+ log(`客户 ${customerId} 的目标已确认`, 'success');
await loadGoalStatus(customerId);
} catch (error) {
apiClient.reportError(error);
@@ -144,39 +531,50 @@ export function createActionsModule({ output, alert }) {
try {
const response = await apiClient.get('ADVISOR_GOAL_BOOK', { pathParams: { goalNo } });
const data = response.data ?? response;
- const sections = data.content?.sections || {};
+ 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('')}`;
+ let html = header('投资目标方案书', 'real', tag(BOOK_STATUS_LABELS[data.review_status] || data.review_status || '--'));
+ html += factGrid([
+ ['业绩基准', escapeHtml(String(objective.benchmark_name ?? '--'))],
+ ['年化收益期望', `${escapeHtml(String(expectation.lower ?? '--'))}% ~ ${escapeHtml(String(expectation.upper ?? '--'))}%`],
+ ['最大回撤', sections.risk_boundary?.maximum_drawdown_pct ? `${escapeHtml(String(sections.risk_boundary.maximum_drawdown_pct))}%` : '--'],
+ ['投资期限', sections.investment_horizon?.months ? `${escapeHtml(String(sections.investment_horizon.months))} 个月` : '--'],
+ ['流动性', escapeHtml(String(sections.liquidity?.description ?? '--'))],
+ ]);
+ (data.content?.disclosures || []).forEach((item) => { html += note(item, 'info'); });
+ output.innerHTML = html;
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '方案书读取失败。');
}
}
- function open(action) {
+ // ---- 入口 ----
+
+ function open(action, options = {}) {
clearAlert();
- if (action === 'goal') {
- renderGoalForm();
+ if (!customers.selected()) {
+ showAlert('请先在「我的客户」中选择一位客户。');
return;
}
- if (action === 'goal-status') {
- renderGoalStatusForm();
+ const blocked = guard(action);
+ if (blocked) {
+ animate(PIPELINE_BLOCK_INDEX.assessment, () => {
+ output.innerHTML = fuseMarkup(action, blocked.hits, blocked.customer);
+ });
+ log(`⛔ FM-03 熔断拦截:${ACTION_LABELS[action]} · ${customers.selectedLabel()}(风评 ${blocked.customer.assessedDays} 天前)`, 'danger');
return;
}
- if (action === 'recommend') {
- output.innerHTML = '';
- output.querySelector('[data-submit-recommend]').addEventListener('click', () => run('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案'));
+ if (options.present) {
+ present(action, options.present.data, options.present.source);
return;
}
- run(action === 'portfolio' ? 'ADVISOR_ANALYSIS' : 'ADVISOR_ALLOCATION', {}, ACTION_LABELS[action]);
+ if (action === 'recommend') { renderRecommendForm(); return; }
+ if (action === 'goal') { renderGoalForm(); return; }
+ if (action === 'goal-status') { renderGoalStatusForm(); return; }
+ if (DIRECT.includes(action)) { execute(action); return; }
+ showAlert(`未登记的操作:${action}`);
}
function bind() {
@@ -185,5 +583,5 @@ export function createActionsModule({ output, alert }) {
});
}
- return Object.freeze({ bind });
+ return Object.freeze({ bind, open, resolve, present, execute, guard });
}
diff --git a/app/static/portal/employee-advisor/dashboard/advisor-config.js b/app/static/portal/employee-advisor/dashboard/advisor-config.js
index edeabad..99fc40b 100644
--- a/app/static/portal/employee-advisor/dashboard/advisor-config.js
+++ b/app/static/portal/employee-advisor/dashboard/advisor-config.js
@@ -1,3 +1,16 @@
+// 投顾工作台的**文案与配置唯一来源**。
+//
+// 页面结构(index.html)只放骨架,字面量与规则全部从这里取 —— 同一句话在
+// 多个模块里各写一份,是这类多模块页面最常见的退化方式。
+//
+// 分区:
+// · 状态与内容类型文案 CONTENT_TYPE_LABELS / RESULT_MESSAGES / GOAL_STATUS_LABELS / BOOK_STATUS_LABELS
+// · 操作定义 ACTION_LABELS / ACTION_DESCRIPTIONS / ACTION_ENDPOINTS / CUSTOMER_SCOPED_ACTIONS
+// · 合规熔断规则 FUSE_GUARDED / FUSE_RULES / ASSESSMENT_VALID_DAYS / EXPIRING_DAYS
+// · 推荐流水线 PIPELINE_STEPS / PIPELINE_BLOCK_INDEX
+// · 自然语言意图路由 INTENT_ROUTES / INTENT_HELP
+// · 本地演示引擎数据 DEMO_PRODUCTS / DEMO_CUSTOMERS / DEMO_ADVISOR_CUSTOMERS 等
+
export const CONTENT_TYPE_LABELS = Object.freeze({
investment_goal_book: '投资目标方案书',
advisor_recommendation_plan: '产品推荐方案',
@@ -6,23 +19,48 @@ export const CONTENT_TYPE_LABELS = Object.freeze({
export const ACTION_LABELS = Object.freeze({
portfolio: '组合分析',
allocation: '资产配置',
- recommend: '生成推荐草案',
+ recommend: '生成推荐方案',
goal: '录入客户目标',
+ 'goal-status': '目标确认与方案书',
+ scoring: '画像评分',
+ rebalance: '调仓建议',
});
+export const ACTION_DESCRIPTIONS = Object.freeze({
+ portfolio: '持仓诊断:集中度、行业穿透与流动性',
+ allocation: '按风评与目标给出资产配比与偏离',
+ recommend: '适当性与证据校验后生成选品方案',
+ goal: '登记客户收益、回撤与流动性约束',
+ 'goal-status': '确认目标、查看投资目标方案书',
+ scoring: '四维度加权评分与一致性判定',
+ rebalance: '当前与目标配比差额及执行优先级',
+});
+
+//: 走真实后端的操作 → 端点编号(端点表登记在 common/api-client.js)。
+export const ACTION_ENDPOINTS = Object.freeze({
+ portfolio: 'ADVISOR_ANALYSIS',
+ allocation: 'ADVISOR_ALLOCATION',
+ recommend: 'ADVISOR_RECOMMEND',
+});
+
+//: 哪些操作会带上所选客户的 customer_id(后端按该客户出方案)。
+//: 其余操作(画像评分 / 调仓建议)由本地演示引擎计算 —— 后端没有对应接口。
+export const CUSTOMER_SCOPED_ACTIONS = Object.freeze(['portfolio', 'allocation', 'recommend']);
+
export const RESULT_MESSAGES = Object.freeze({
profile_required: '客户尚未完成风险测评,请先完成测评后再分析。',
investment_goal_required: '暂无已确认投资目标,暂不能生成配置或推荐。',
investment_goal_invalid: '投资目标数据不完整,请检查客户目标。',
no_positions: '当前客户暂无可分析的持仓。',
valuation_required: '持仓缺少可用市值,暂不能计算集中度。',
+ blocked_by_fuse: '客户风险测评已失效,购买与评分类操作已按 FM-03 熔断。',
});
//: 目标与方案书的状态口径(`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: '已确认',
@@ -34,3 +72,106 @@ export const BOOK_STATUS_LABELS = Object.freeze({
published: '已发布',
rejected: '已退回',
});
+
+// ---- 合规熔断:风评有效性 ----
+//
+// FM-03:风险测评超过 12 个月即失效,冻结购买权限(仅可赎回)。
+// 拦截发生在**前端闸门**(后端 `SuitabilityService` 目前只判 `valid_until` 是否为空,
+// 不比较是否已过期),所以这里是唯一的拦截点,规则文案必须集中在配置表里。
+export const FUSE_GUARDED = Object.freeze(['recommend', 'allocation', 'scoring', 'rebalance']);
+export const ASSESSMENT_VALID_DAYS = 365;
+export const EXPIRING_DAYS = 335;
+export const FUSE_RULES = Object.freeze({
+ minor: 'FM-01:未满 18 岁禁止开户',
+ assessmentExpired: 'FM-03:风评超 12 个月,冻结购买权限(仅可赎回),请先完成复评',
+});
+
+// ---- 推荐流水线 ----
+export const PIPELINE_STEPS = Object.freeze([
+ '① 鉴权', '② 画像', '③ 筛选', '④ 方案', '⑤ 合规', '⑥ 审核留痕',
+]);
+//: 流线在哪一步停住:风评熔断停在「② 画像」,其余失败停在「③ 筛选」。
+export const PIPELINE_BLOCK_INDEX = Object.freeze({ assessment: 1, default: 2 });
+
+export const DISCLAIMER = '本内容仅为投资分析参考,不构成任何直接投资建议,市场有风险,投资须谨慎。';
+
+// ---- 概览指标(hero 下方的 4 张卡) ----
+//
+// 卡片的**取值口径**写在这里,渲染在 `dashboard.js` 的 `renderMetrics()`:
+// 与「平台治理工作台」`workspace.js` / 「风险工作台」`dashboard.js` 同一形态
+// (label / value / meta 三元组 + 可选 tone),换页不换套路。
+export const METRIC_HINTS = Object.freeze({
+ published: '管理员审核通过后在此汇总',
+ customers: '名下归属客户',
+ fuse: '风评超期,购买类动作已熔断',
+ source: '会话来源',
+});
+
+// ---- 本地演示引擎数据 ----
+//
+// ⚠️ 下面这张产品/客户表**只服务离线演示与后端无接口的两个操作**
+// (画像评分 / 调仓建议)。真实数据一律走 `/api/v1/advisor/**`。
+export const DEMO_PRODUCTS = Object.freeze([
+ { code: '202308', name: '南方收益宝货币B', risk: 'R1', cat: '货币', fee: 0.2, min_amt: 100, benchmark: '七天通知存款税后利率', manager: '邓文/蔡奕奕', redeem_days: 1, ret_1y: 1.8, vol: 0.1, max_dd: 0, industries: ['同业存单', '短债'] },
+ { code: '007161', name: '南方恒庆一年定开债券', risk: 'R2', cat: '债券', fee: 0.2, min_amt: 100, benchmark: '一年定存利率+1.5%', manager: '黄河', redeem_days: 365, ret_1y: 3.2, vol: 0.8, max_dd: -0.4, industries: ['利率债', '信用债'] },
+ { code: '003776', name: '南方宣利定开债券A', risk: 'R2', cat: '债券', fee: 0.5, min_amt: 100, benchmark: '中债信用债总指数', manager: '杜才超', redeem_days: 90, ret_1y: 3.6, vol: 1.1, max_dd: -0.8, industries: ['信用债', '二级资本债'] },
+ { code: '018019', name: '南方核心科技一年混合A', risk: 'R3', cat: '混合', fee: 1.4, min_amt: 100, benchmark: '中证800×70%+港股通×10%+中债×20%', manager: '罗安安', redeem_days: 365, ret_1y: 12.5, vol: 14.2, max_dd: -18.6, industries: ['半导体', '云计算', '科技'] },
+ { code: '016449', name: '南方新材料股票发起A', risk: 'R3', cat: '股票', fee: 1.4, min_amt: 100, benchmark: '中证新材料×75%+国债×20%+恒生原材料×5%', manager: '都逸敏', redeem_days: 3, ret_1y: 8.4, vol: 16.8, max_dd: -21.3, industries: ['新材料', '化工'] },
+ { code: '008264', name: '南方ESG主题股票A', risk: 'R3', cat: '股票', fee: 1.4, min_amt: 100, benchmark: '中证800ESG×75%+港股通×10%+国债×15%', manager: '章晖', redeem_days: 3, ret_1y: 6.9, vol: 13.5, max_dd: -16.2, industries: ['电子', '医药', '消费'] },
+ { code: '014189', name: '南方专精特新混合A', risk: 'R4', cat: '混合', fee: 1.4, min_amt: 100, benchmark: '中证1000×70%+港股通×10%+中债×20%', manager: '雷嘉源/罗安安', redeem_days: 3, ret_1y: 15.2, vol: 19.6, max_dd: -26.8, industries: ['专精特新', '科技', '高端制造'] },
+ { code: '020553', name: '南方半导体产业股票A', risk: 'R4', cat: '股票', fee: 1.4, min_amt: 100, benchmark: '全指半导体×70%+港股通×15%+国债×15%', manager: '郑晓曦', redeem_days: 3, ret_1y: 22.7, vol: 26.4, max_dd: -32.5, industries: ['半导体', '科技'] },
+ { code: 'QDII01', name: '全球精选QDII基金', risk: 'R4', cat: 'QDII', fee: 1.6, min_amt: 1000, benchmark: 'MSCI全球指数', manager: '国际业务部', redeem_days: 7, ret_1y: 11.3, vol: 15.8, max_dd: -19.4, industries: ['全球股票', '美股', '科技'] },
+]);
+
+//: `dbId` 是真实后端(jr_agent)里的客户 id —— 推荐 / 资产配置 / 组合分析按它出方案。
+//: 对应数据见仓库根目录 `_seed_demo_customers.py`(风险测评 + 已确认目标 + 持仓)。
+//: `assessedDays` = 距上次风险测评的天数,> 365 触发 FM-03 熔断。
+export const DEMO_CUSTOMERS = Object.freeze({
+ C10086: { dbId: 9101, name: '张总', level: 'C4', age: 46, assetsWan: 3000, annualIncomeWan: 200, assessedDays: 120, openDays: 1500, horizon: '>5年', holdings: [['018019', 0.5], ['014189', 0.3], ['202308', 0.2]], tags: ['企业主', '科技股偏好'] },
+ C10087: { dbId: 9102, name: '李阿姨', level: 'C1', age: 68, assetsWan: 120, annualIncomeWan: 14, assessedDays: 200, openDays: 2100, horizon: '1-3年', holdings: [['202308', 0.6], ['007161', 0.4]], tags: ['退休教师', '老年客户'] },
+ C10088: { dbId: 9103, name: '王工', level: 'C3', age: 32, assetsWan: 80, annualIncomeWan: 45, assessedDays: 400, openDays: 900, horizon: '3-5年', holdings: [['003776', 0.5], ['018019', 0.3], ['202308', 0.2]], tags: ['程序员', '风评超期'] },
+ C10089: { dbId: 9104, name: '陈医生', level: 'C3', age: 41, assetsWan: 260, annualIncomeWan: 60, assessedDays: 340, openDays: 25, horizon: '3-5年', holdings: [['003776', 0.4], ['008264', 0.35], ['202308', 0.25]], tags: ['医生', '新开户'] },
+});
+
+export const DEMO_ADVISOR_CUSTOMERS = Object.freeze({
+ E1001: ['C10086', 'C10087', 'C10088', 'C10089'],
+});
+
+//: 适当性矩阵:[可买最高档, 需签确认书的越级档]。
+export const SUITABILITY_MATRIX = Object.freeze({
+ C1: ['R1', null], C2: ['R2', null], C3: ['R3', 'R4'], C4: ['R4', 'R5'], C5: ['R5', null],
+});
+
+export const RISK_RANK = Object.freeze({ R1: 1, R2: 2, R3: 3, R4: 4, R5: 5 });
+
+//: 目标配置:[现金, 债券, 混合, 股票, QDII],按客户档位 × 投资期限取值。
+export const ALLOCATION_MATRIX = Object.freeze({
+ C1: { '<1年': [50, 0, 0, 0, 50], '1-3年': [60, 0, 0, 0, 40], '3-5年': [70, 0, 0, 0, 30], '>5年': [70, 0, 0, 0, 30] },
+ C2: { '<1年': [50, 10, 0, 0, 40], '1-3年': [60, 15, 0, 0, 25], '3-5年': [60, 20, 0, 0, 20], '>5年': [50, 30, 0, 0, 20] },
+ C3: { '<1年': [40, 20, 0, 0, 40], '1-3年': [40, 30, 0, 0, 30], '3-5年': [40, 50, 0, 0, 10], '>5年': [30, 60, 0, 0, 10] },
+ C4: { '<1年': [20, 30, 15, 0, 35], '1-3年': [25, 35, 25, 0, 15], '3-5年': [20, 30, 35, 10, 5], '>5年': [10, 25, 40, 20, 5] },
+ C5: { '<1年': [10, 25, 35, 0, 30], '1-3年': [15, 25, 40, 10, 10], '3-5年': [10, 25, 35, 20, 10], '>5年': [10, 20, 35, 30, 5] },
+});
+
+export const ASSET_CATEGORIES = Object.freeze(['债券', '混合', '股票', 'QDII', '现金']);
+
+export const EXPECTED_RETURN = Object.freeze({
+ C1: '3-3.5%', C2: '4-5%', C3: '5-7%', C4: '7-10%', C5: '10-15%',
+});
+
+export const SCORING_LEVEL = Object.freeze({ C1: 3, C2: 5, C3: 6.5, C4: 8, C5: 9.5 });
+
+export const SCORING_WEIGHTS = Object.freeze({
+ 基础属性: 25, 投资经验: 25, 风险偏好: 30, 行为异常: 20,
+});
+
+//: 自然语言入口的意图路由:命中即转对应操作。
+export const INTENT_ROUTES = Object.freeze([
+ { pattern: '持仓|集中|分散|分布|诊断|风险', action: 'portfolio' },
+ { pattern: '推荐|买什么|买哪|选基|标的|产品', action: 'recommend' },
+ { pattern: '配置|配比|比例|仓位', action: 'allocation' },
+ { pattern: '评分|画像', action: 'scoring' },
+ { pattern: '调仓', action: 'rebalance' },
+]);
+
+export const INTENT_HELP = '我可以基于真实后端回答:
· 持仓 / 集中度 / 风险 → 组合分析
· 推荐 / 买什么 / 选基 → 生成推荐方案
· 配置 / 配比 / 仓位 → 资产配置
· 评分 / 画像、调仓 → 本地演示引擎(示例客户)
也可直接点上方对应操作卡片。';
diff --git a/app/static/portal/employee-advisor/dashboard/advisor-engine.js b/app/static/portal/employee-advisor/dashboard/advisor-engine.js
new file mode 100644
index 0000000..9513a1f
--- /dev/null
+++ b/app/static/portal/employee-advisor/dashboard/advisor-engine.js
@@ -0,0 +1,331 @@
+// 投顾工作台的**本地演示引擎**。
+//
+// 用途只有两个,两者都不是"真实结论":
+// 1. 后端没有接口的操作(画像评分 / 调仓建议);
+// 2. 未登录(`?demo=1`)或所选客户没有真实 `dbId` 时的回退。
+//
+// ⚠️ 输出的字段名刻意**与后端契约保持一致**(snake_case,如 `ratio_pct` / `dims`),
+// 这样 `actions-module.js` 里同一套渲染函数可以同时吃真实响应和本地结果 ——
+// 否则两个来源会长出两条渲染分支,改一处漏一处。
+//
+// 本文件是纯函数:不碰 DOM、不发请求。
+
+import {
+ ALLOCATION_MATRIX,
+ ASSESSMENT_VALID_DAYS,
+ ASSET_CATEGORIES,
+ DEMO_CUSTOMERS,
+ DEMO_PRODUCTS,
+ DISCLAIMER,
+ EXPECTED_RETURN,
+ EXPIRING_DAYS,
+ FUSE_RULES,
+ RISK_RANK,
+ SCORING_LEVEL,
+ SCORING_WEIGHTS,
+ SUITABILITY_MATRIX,
+} from './advisor-config.js?v=20260914-advisor9';
+
+let sequence = 0;
+
+export function demoCustomer(customerId) {
+ return DEMO_CUSTOMERS[customerId] || null;
+}
+
+export function customerLabel(customerId) {
+ const customer = demoCustomer(customerId);
+ return customer ? `${customer.name}(${customer.level})` : String(customerId || '--');
+}
+
+export function customerDbId(customerId) {
+ const customer = demoCustomer(customerId);
+ return customer && customer.dbId ? customer.dbId : null;
+}
+
+export function productByCode(code) {
+ return DEMO_PRODUCTS.find((item) => item.code === code) || null;
+}
+
+// ---- 合规熔断:风评有效性 ----
+
+export function fuseHits(customer) {
+ const hits = [];
+ if (customer.age < 18) hits.push(FUSE_RULES.minor);
+ if (customer.assessedDays > ASSESSMENT_VALID_DAYS) hits.push(FUSE_RULES.assessmentExpired);
+ return hits;
+}
+
+export function assessmentFuseHits(customer) {
+ return fuseHits(customer).filter((hit) => hit.indexOf('FM-03') === 0);
+}
+
+export function isAssessmentExpiring(customer) {
+ return customer.assessedDays > EXPIRING_DAYS && customer.assessedDays <= ASSESSMENT_VALID_DAYS;
+}
+
+// ---- 适当性 ----
+
+export function suitability(level, risk, age) {
+ const [allowed, warn] = SUITABILITY_MATRIX[level];
+ const rank = RISK_RANK[risk];
+ if (age > 80 && rank > 2) return 'block';
+ if (rank <= RISK_RANK[allowed]) return 'allow';
+ if (warn && rank <= RISK_RANK[warn]) return 'warn';
+ return 'block';
+}
+
+// ---- 推荐方案 ----
+
+export function engineRecommend(customerId, amountWan) {
+ const customer = demoCustomer(customerId);
+ if (!customer) return { status: 'profile_required' };
+ const hits = fuseHits(customer);
+ if (hits.length) {
+ return {
+ status: 'blocked_by_fuse',
+ fuse_hits: hits,
+ steps: ['① 鉴权通过(本地引擎)', `② 画像:${customer.level},风评 ${customer.assessedDays} 天前`],
+ };
+ }
+ const passed = [];
+ const warns = [];
+ const blocked = [];
+ DEMO_PRODUCTS.forEach((product) => {
+ if (amountWan * 10000 < product.min_amt) return;
+ const verdict = suitability(customer.level, product.risk, customer.age);
+ if (verdict === 'allow') passed.push(product);
+ else if (verdict === 'warn') warns.push(product);
+ else blocked.push(product);
+ });
+ const chosen = passed
+ .sort((a, b) => RISK_RANK[b.risk] - RISK_RANK[a.risk] || a.fee - b.fee)
+ .slice(0, 3);
+ const total = chosen.reduce((sum, product) => sum + RISK_RANK[product.risk], 0) || 1;
+ sequence += 1;
+ return {
+ status: 'pending_review',
+ content_id: `LC${sequence}`,
+ customer_id: customerId,
+ plans: chosen.map((product) => {
+ const ratio = Math.round((RISK_RANK[product.risk] / total) * 100);
+ return {
+ code: product.code,
+ name: product.name,
+ risk: product.risk,
+ cat: product.cat,
+ ratio_pct: ratio,
+ amount_wan: Math.round(amountWan * ratio) / 100,
+ fee_pct: product.fee,
+ benchmark: product.benchmark,
+ manager: product.manager,
+ reason: `${product.cat}类,${customer.level} 适配;综合费率 ${product.fee}%;业绩基准≠收益承诺`,
+ };
+ }),
+ warn_alternatives: warns.map((product) => ({
+ code: product.code, name: product.name, risk: product.risk,
+ note: '越级产品:须签《风险不匹配自愿购买确认书》+ 双录',
+ })),
+ blocked: blocked.map((product) => ({
+ code: product.code, name: product.name, risk: product.risk,
+ reason: `${product.risk} 超出 ${customer.level} 可购范围`,
+ })),
+ rw_hits: [],
+ steps: [
+ '① 鉴权通过(本地引擎)',
+ `③ 筛选:allow ${passed.length} / warn ${warns.length} / block ${blocked.length}`,
+ '⑥ 草稿 pending_review(本地)',
+ ],
+ disclaimer: DISCLAIMER,
+ };
+}
+
+// ---- 资产配置 ----
+
+export function engineAllocation(customerId, horizon) {
+ const customer = demoCustomer(customerId);
+ if (!customer) return { status: 'profile_required' };
+ const target = ALLOCATION_MATRIX[customer.level][horizon] || ALLOCATION_MATRIX[customer.level]['3-5年'];
+ const current = { 债券: 0, 混合: 0, 股票: 0, QDII: 0, 现金: 0 };
+ customer.holdings.forEach(([code, weight]) => {
+ const product = productByCode(code);
+ if (product) current[product.cat === '货币' ? '现金' : product.cat] += weight * 100;
+ });
+ return {
+ status: 'ready',
+ customer_id: customerId,
+ c_level: customer.level,
+ horizon,
+ expected_return: EXPECTED_RETURN[customer.level],
+ disclaimer: DISCLAIMER,
+ rows: ASSET_CATEGORIES.map((name, index) => {
+ const diff = Math.round((target[index] - current[name]) * 10) / 10;
+ return {
+ category: name,
+ current_pct: Math.round(current[name] * 10) / 10,
+ target_pct: target[index],
+ diff_pct: diff,
+ priority: Math.abs(diff) >= 15 ? '高' : Math.abs(diff) >= 5 ? '中' : '低',
+ };
+ }),
+ };
+}
+
+// ---- 组合诊断(持仓集中度 / 行业穿透 / 流动性) ----
+
+export function engineDiagnosis(customerId) {
+ const customer = demoCustomer(customerId);
+ if (!customer) return { status: 'no_positions' };
+ const positions = customer.holdings
+ .map(([code, weight]) => [productByCode(code), weight])
+ .filter(([product]) => Boolean(product));
+ if (!positions.length) return { status: 'no_positions' };
+
+ const industries = {};
+ positions.forEach(([product, weight]) => {
+ product.industries.forEach((name) => {
+ industries[name] = (industries[name] || 0) + (weight / product.industries.length) * 100;
+ });
+ });
+ const topIndustries = Object.entries(industries).sort((a, b) => b[1] - a[1]).slice(0, 4);
+ const top3 = positions.map(([, weight]) => weight).sort((a, b) => b - a).slice(0, 3)
+ .reduce((sum, item) => sum + item, 0) * 100;
+ const weightedRisk = positions.reduce((sum, [product, weight]) => sum + RISK_RANK[product.risk] * weight, 0);
+ const declaredRank = RISK_RANK[`R${(customer.level || 'C3').slice(1)}`] || 3;
+ const gap = declaredRank - weightedRisk;
+ const weightedFee = positions.reduce((sum, [product, weight]) => sum + product.fee * weight, 0);
+ const illiquid = positions.filter(([product]) => product.redeem_days > 7).map(([product]) => product.name);
+
+ const byIndustry = {};
+ positions.forEach(([product, weight]) => {
+ product.industries.forEach((name) => {
+ (byIndustry[name] = byIndustry[name] || []).push(weight);
+ });
+ });
+ const overlaps = Object.entries(byIndustry)
+ .filter(([, weights]) => weights.length >= 2 && weights.reduce((sum, item) => sum + item, 0) > 0.05)
+ .map(([name, weights]) => `${name}(${weights.length} 只合计暴露 ${Math.round(weights.reduce((sum, item) => sum + item, 0) * 100)}%)`);
+
+ const ret = positions.reduce((sum, [product, weight]) => sum + product.ret_1y * weight, 0);
+ const vol = positions.reduce((sum, [product, weight]) => sum + product.vol * weight, 0);
+ const drawdown = positions.reduce((sum, [product, weight]) => sum + product.max_dd * weight, 0);
+ sequence += 1;
+
+ return {
+ status: 'pending_review',
+ content_id: `LC${sequence}`,
+ customer_id: customerId,
+ c_level: customer.level,
+ disclaimer: DISCLAIMER,
+ dims: [
+ { dim: '行业分布', verdict: '✓', detail: topIndustries.map(([name, pct]) => `${name} ${pct.toFixed(1)}%`).join(' / ') },
+ { dim: '集中度评估', verdict: top3 > 60 ? '⭐⭐' : '✓', detail: `前 3 大占 ${top3.toFixed(0)}%${top3 > 60 ? ',建议分散至 6-8 只' : ''}` },
+ { dim: '与风评匹配', verdict: Math.abs(gap) < 1 ? '✓' : '⭐', detail: `组合风险 R${weightedRisk.toFixed(1)} vs 风评 ${customer.level}${Math.abs(gap) < 1 ? ',匹配' : gap < 0 ? ',偏激进' : ',偏保守'}` },
+ { dim: '费率水平', verdict: weightedFee <= 0.7 ? '✓' : '⭐', detail: `加权 ${weightedFee.toFixed(2)}% vs 行业 0.70%` },
+ { dim: '流动性分析', verdict: illiquid.length ? '⭐' : '✓', detail: illiquid.length ? `封闭/定开产品:${illiquid.join('、')}` : '全部 T+1~T+7 可赎' },
+ { dim: '重复持仓检测', verdict: overlaps.length ? '⭐' : '✓', detail: overlaps.length ? overlaps.join(';') : '未发现显著重复暴露' },
+ { dim: '风险等级计算', verdict: '✓', detail: `组合整体 R${weightedRisk.toFixed(1)}` },
+ { dim: '收益与回撤', verdict: ret > 0 ? '✓' : '⭐', detail: `近 1 年加权收益 ${ret.toFixed(1)}%,波动率 ${vol.toFixed(1)}%,最大回撤 ${drawdown.toFixed(1)}%` },
+ ],
+ };
+}
+
+// ---- 画像评分(四维度加权) ----
+
+export function engineScoring(customerId) {
+ const customer = demoCustomer(customerId);
+ if (!customer) return { status: 'profile_required' };
+ const clamp = (value) => Math.max(0, Math.min(10, value));
+ const ageScore = customer.age >= 30 && customer.age <= 50 ? 10 : (customer.age <= 70 ? 4 : 2);
+ const basic = ((ageScore + 5 + 5 + clamp(customer.annualIncomeWan / 20) + clamp(customer.assetsWan / 300)) / 5) / 10 * 25;
+ const years = customer.openDays / 365;
+ const maxRisk = Math.max(...customer.holdings.map(([code]) => RISK_RANK[productByCode(code).risk]));
+ const experience = ((clamp(years * 1.5) + maxRisk * 2 + 6 + 6) / 4) / 10 * 25;
+ const base = SCORING_LEVEL[customer.level];
+ const appetite = clamp(base) / 10 * 30;
+ const behaviour = (customer.assessedDays > ASSESSMENT_VALID_DAYS ? 0 : 10) / 10 * 20;
+ const total = Math.round((basic + experience + appetite + behaviour) * 10) / 10;
+ const aiLevel = total <= 25 ? 'C1' : total <= 40 ? 'C2' : total <= 60 ? 'C3' : total <= 80 ? 'C4' : 'C5';
+ const gap = parseInt(customer.level[1], 10) - parseInt(aiLevel[1], 10);
+ return {
+ status: 'ready',
+ customer_id: customerId,
+ total_score: total,
+ ai_level: aiLevel,
+ declared_level: customer.level,
+ final_level: Math.abs(gap) <= 1 ? (customer.level < aiLevel ? customer.level : aiLevel) : aiLevel,
+ consistency: Math.abs(gap) <= 1 ? '一致(±1 档内,取更保守)' : gap === 2 ? '差 2 档:需网点面评' : '差 3 档:触发合规调查',
+ confidence: Math.round((0.6 + Math.min(total, 40) / 100) * 100) / 100,
+ disclaimer: DISCLAIMER,
+ dims: {
+ 基础属性: {
+ weight: 0.25,
+ score: Math.round(basic * 10) / 10,
+ indicators: {
+ 年龄: ageScore, '学历(默认)': 5, '职业(默认)': 5,
+ 年收入: Math.round(clamp(customer.annualIncomeWan / 20) * 10) / 10,
+ 资产规模: Math.round(clamp(customer.assetsWan / 300) * 10) / 10,
+ },
+ },
+ 投资经验: {
+ weight: 0.25,
+ score: Math.round(experience * 10) / 10,
+ indicators: {
+ 投资年限: Math.round(clamp(years * 1.5) * 10) / 10,
+ 产品复杂度: maxRisk * 2, '交易频率(默认)': 6, '历史收益(默认)': 6,
+ },
+ },
+ 风险偏好: {
+ weight: 0.30,
+ score: Math.round(appetite * 10) / 10,
+ indicators: { 风评基准: base, 情绪化扣分: 0 },
+ },
+ 行为异常: {
+ weight: 0.20,
+ score: Math.round(behaviour * 10) / 10,
+ indicators: { 异常项: customer.assessedDays > ASSESSMENT_VALID_DAYS ? ['风评超期未复评'] : ['无'] },
+ },
+ },
+ };
+}
+
+export function scoringMaxWeight() {
+ return SCORING_WEIGHTS;
+}
+
+// ---- 调仓建议 ----
+
+export function engineRebalance(customerId) {
+ const customer = demoCustomer(customerId);
+ if (!customer) return { status: 'no_positions' };
+ const allocation = engineAllocation(customerId, customer.horizon);
+ const over = allocation.rows.filter((row) => row.diff_pct < -5);
+ const under = allocation.rows.filter((row) => row.diff_pct > 5).sort((a, b) => b.diff_pct - a.diff_pct);
+ let steps = under.map((row, index) => {
+ const source = over.length ? over[0].category : '现金';
+ const catName = row.category === '现金' ? '货币' : row.category;
+ const candidates = DEMO_PRODUCTS
+ .filter((product) => product.cat === catName && suitability(customer.level, product.risk, customer.age) === 'allow')
+ .slice(0, 2);
+ return {
+ priority: index + 1,
+ action: row.category === '现金' ? '保留现金' : '申购',
+ category: row.category,
+ amount_pct: row.diff_pct,
+ detail: `从${source}类赎回约 ${Math.round(row.diff_pct)}%,申购${row.category}类适配产品:${candidates.length ? candidates.map((product) => product.name).join('、') : '(无适配产品)'}`,
+ };
+ });
+ if (!steps.length) {
+ steps = [{ priority: 1, action: '持有', category: '-', amount_pct: 0, detail: '偏离均在 5pp 以内,无需调整' }];
+ }
+ sequence += 1;
+ return {
+ status: 'pending_review',
+ content_id: `LC${sequence}`,
+ customer_id: customerId,
+ c_level: customer.level,
+ horizon: customer.horizon,
+ allocation: allocation.rows,
+ steps,
+ disclaimer: DISCLAIMER,
+ };
+}
diff --git a/app/static/portal/employee-advisor/dashboard/assistant-module.js b/app/static/portal/employee-advisor/dashboard/assistant-module.js
new file mode 100644
index 0000000..4f3963c
--- /dev/null
+++ b/app/static/portal/employee-advisor/dashboard/assistant-module.js
@@ -0,0 +1,157 @@
+// 统一对话入口:**前端意图路由**。
+//
+// 为什么不用后端 `/api/v1/agent-runs`:那条链路是异步 run(POST 只入队 `queued`),
+// 真正执行要靠 Agent Worker 进程,且还需要发布配置与模型端点。在工作台里让顾问
+// 「问一句就有答案」最直接的路径,是把话术路由到已有的三个 advisor 端点上。
+//
+// 呈现方式与操作卡片一致:**答案写进「结果」面板**(和点按钮同一个容器),
+// 不另开一个聊天记录区 —— 顾问在同一个地方看结论,不必两个区域来回对照。
+//
+// 路由表在 `advisor-config.js` 的 `INTENT_ROUTES`(文案与规则集中一处);
+// 请求本身复用 `actions-module` 的 `resolve()`,避免同一批端点在这里再写一遍。
+
+import { escapeHtml } from '/static/portal/common/formatters.js?v=20260913';
+import { ACTION_LABELS, INTENT_HELP, INTENT_ROUTES } from './advisor-config.js?v=20260914-advisor9';
+
+function escapeLines(text) {
+ return escapeHtml(text).replaceAll('\n', '
');
+}
+
+export function createAssistantModule({ log, output, form, input, actions, customers, online }) {
+ function answer(title, tag, body, tone = '') {
+ const toneTag = tone ? ` status-tag--${tone}` : '';
+ output.innerHTML = ``
+ + `${body}
`
+ + '回答由前端意图路由到既有投顾接口生成,结论仅供分析参考,不构成交易指令。
';
+ }
+
+ function matchAction(message) {
+ const route = INTENT_ROUTES.find((item) => new RegExp(item.pattern).test(message));
+ return route ? route.action : null;
+ }
+
+ // ---- 三个真实端点的文字摘要(与操作面板共用一份数据) ----
+
+ function summarise(action, data) {
+ if (action === 'portfolio') {
+ if (data.status === 'no_positions') return '当前客户没有可分析的持仓。';
+ if (data.status === 'valuation_required') return '持仓缺少可用市值,暂不能计算集中度。';
+ if (data.status !== 'ready' && data.status !== 'pending_review') return `后端返回状态:${data.status}`;
+ if (data.dims) {
+ return `本地演示引擎诊断(8 维度):
${data.dims.slice(0, 4).map((item) => `· ${escapeHtml(item.dim)}:${escapeHtml(item.detail)}`).join('
')}
详见「组合分析」。`;
+ }
+ const summary = data.summary || {};
+ const concentration = data.product_concentration || {};
+ const top = (concentration.rows || []).slice(0, 3);
+ let text = `已分析该客户持仓:共 ${summary.position_count ?? '--'} 只(已估值 ${summary.valued_position_count ?? '--'}),`
+ + `总市值 ${summary.total_market_value ?? '--'},产品集中度 HHI ${concentration.hhi ?? '--'}。
前三大:`;
+ text += top.map((row) => `${escapeHtml(row.product_name)}(${escapeHtml(String(row.share_pct))}%)`).join('、') || '--';
+ if ((data.warnings || []).length) text += `
提示:${(data.warnings).map((item) => escapeHtml(item.code)).join('、')}。`;
+ return `${text}
结果仅供分析参考,不构成交易指令。详见「组合分析」。`;
+ }
+
+ if (action === 'recommend') {
+ if (data.status === 'profile_required') return '缺少有效风险画像,暂不能推荐。';
+ if (data.status === 'investment_goal_required') return '缺少已确认的投资目标,暂不能推荐。';
+ if (data.status === 'blocked_by_fuse') return `风评已失效,已按 ${escapeHtml((data.fuse_hits || []).join(';'))} 熔断。`;
+ if (data.status !== 'ready' && data.status !== 'pending_review') return `后端返回状态:${data.status}`;
+ if (data.plans) {
+ return `本地演示引擎方案(须审核发布):
${data.plans.map((plan, index) => `${index + 1}. ${escapeHtml(plan.name)}(${escapeHtml(plan.risk)},配比 ${plan.ratio_pct}%)`).join('
')}
详见「生成推荐方案」。`;
+ }
+ const products = data.products || [];
+ if (!products.length) return '当前没有通过适当性与证据校验的产品。';
+ return `按该客户画像与目标,推荐 ${products.length} 只(草稿,须审核发布):
`
+ + products.map((product, index) => `${index + 1}. ${escapeHtml(product.product_code)} ${escapeHtml(product.product_name)}`
+ + `(${escapeHtml(((product.recommendation_evidence_card || {}).suitability || {}).risk_level || '--')},评分 ${escapeHtml(String(product.score ?? '--'))})`).join('
')
+ + '
方案须经审核发布,不构成交易指令。详见「生成推荐方案」。';
+ }
+
+ if (action === 'allocation') {
+ if (data.status === 'profile_required') return '缺少客户风险画像,暂不能给出资产配置。';
+ if (data.status === 'investment_goal_required') return '缺少已确认的投资目标,暂不能给出资产配置。';
+ if (data.status !== 'ready') return `后端返回状态:${data.status}`;
+ if (data.rows) {
+ return `本地演示引擎配置建议:
${data.rows.map((row) => `· ${escapeHtml(row.category)} 当前 ${row.current_pct}% → 目标 ${row.target_pct}%(差 ${row.diff_pct}%)`).join('
')}
详见「资产配置」。`;
+ }
+ const allocation = data.allocation || [];
+ const constraints = data.constraints || {};
+ return `已按该客户风险画像与目标生成配置建议:
${allocation.map((item) => `${escapeHtml(item.label || item.asset_class)} ${item.target_pct}%`).join('、')}
`
+ + `约束:年化下限 ${constraints.annualized_return_lower_pct ?? '--'},最大回撤 ${constraints.max_drawdown_pct ?? '--'},`
+ + `流动性 ${escapeHtml(String(constraints.liquidity_requirement ?? '--'))},期限 ${constraints.investment_horizon_months ?? '--'} 个月。
仅供方案参考,不构成交易指令。详见「资产配置」。`;
+ }
+
+ return '已完成。';
+ }
+
+ async function submit(event) {
+ event.preventDefault();
+ const message = (input.value || '').trim();
+ if (!message) return;
+ input.value = '';
+ log(`对话:${message}`);
+
+ if (!online()) {
+ answer('未连接', '提示', '当前未连接真实后端。请从平台登录后再试(未登录时只能用本地演示数据)。', 'medium');
+ return;
+ }
+
+ const action = matchAction(message);
+ if (!action) {
+ log('对话:未识别,已给出可用指令提示', 'warning');
+ answer('未识别的指令', '提示', INTENT_HELP);
+ return;
+ }
+
+ const label = ACTION_LABELS[action];
+ const blocked = actions.guard(action);
+ if (blocked) {
+ log(`⛔ FM-03 熔断拦截:${label} · ${customers.selectedLabel()}`, 'danger');
+ answer('风评熔断', 'FM-03', `⛔ ${escapeHtml(blocked.hits.join(';'))}
`
+ + `客户「${escapeHtml(customers.selectedLabel())}」风险测评已失效,按 FM-03 冻结购买权限,${escapeHtml(label)}已中止。`
+ + '仍可执行:组合分析(只读)、赎回。', 'failed');
+ actions.open(action);
+ return;
+ }
+
+ // 画像评分 / 调仓建议没有后端接口,直接交给操作面板渲染(这样点按钮和打字得到同一份结果)。
+ if (action === 'scoring' || action === 'rebalance') {
+ log(`对话 → ${label}(本地演示引擎)`, 'warning');
+ actions.open(action);
+ return;
+ }
+
+ // 走真实后端的三个意图:**回答本身就是结果**,不再额外渲染操作卡片 ——
+ // 否则刚写进「结果」面板的答复会被随后的渲染覆盖掉。
+ answer(label, '执行中', `正在执行「${escapeHtml(label)}」…`);
+ try {
+ const { data, source } = await actions.resolve(action);
+ answer(label, source === 'real' ? '真实后端' : '本地演示引擎', summarise(action, data));
+ log(`对话 → ${label}(${source === 'real' ? '真实后端' : '本地演示引擎'})`, 'success');
+ } catch (error) {
+ answer(label, '请求失败', escapeHtml(error.message || String(error)), 'failed');
+ log(`对话请求失败:${error.message || error}`, 'danger');
+ }
+ }
+
+ // 快捷问句:把按钮文案灌进输入框再触发同一个 submit —— 走的是**完全相同的路径**,
+ // 不另写一套「点按钮」逻辑。`requestSubmit()` 会正常触发 submit 事件与校验,
+ // 比直接调 `submit()` 更接近真实用户操作。
+ function bindPrompts(scope) {
+ scope = scope || document;
+ scope.querySelectorAll('[data-prompt]').forEach((button) => {
+ button.addEventListener('click', () => {
+ input.value = button.dataset.prompt || button.textContent.trim();
+ form.requestSubmit();
+ });
+ });
+ }
+
+ function bind() {
+ form.addEventListener('submit', submit);
+ bindPrompts();
+ }
+
+ return Object.freeze({ bind, bindPrompts });
+}
diff --git a/app/static/portal/employee-advisor/dashboard/customer-module.js b/app/static/portal/employee-advisor/dashboard/customer-module.js
new file mode 100644
index 0000000..dfa981d
--- /dev/null
+++ b/app/static/portal/employee-advisor/dashboard/customer-module.js
@@ -0,0 +1,103 @@
+// 我的客户:列表渲染 + 当前选中客户。
+//
+// 选中的客户是**整个工作台的上下文** —— 组合分析 / 资产配置 / 推荐方案会带上它的
+// 真实 `dbId`(`/api/v1/advisor/**` 的 `customer_id`),画像评分 / 调仓建议则用它跑
+// 本地演示引擎。所以选中状态集中放在这里,由入口把 `onChange` 广播给其它模块。
+
+import { escapeHtml } from '/static/portal/common/formatters.js?v=20260913';
+import { DEMO_ADVISOR_CUSTOMERS, DEMO_CUSTOMERS } from './advisor-config.js?v=20260914-advisor9';
+import { customerDbId, demoCustomer, assessmentFuseHits, isAssessmentExpiring } from './advisor-engine.js?v=20260914-advisor9';
+
+const DEFAULT_ADVISOR = 'E1001';
+
+// 风评状态一律问引擎(`assessmentFuseHits` / `isAssessmentExpiring`),
+// 不在这里再比一次天数 —— 「多少天算超期」只能有一个定义,否则标签与熔断闸门迟早对不上。
+function decorators(customer) {
+ const items = [];
+ if (customer.level) items.push({ text: customer.level, className: 'tag' });
+ if (assessmentFuseHits(customer).length) {
+ items.push({ text: '风评超期', className: 'status-tag status-tag--failed' });
+ } else if (isAssessmentExpiring(customer)) {
+ items.push({ text: '风评将到期', className: 'status-tag status-tag--medium' });
+ }
+ if (customer.age >= 65) items.push({ text: '老年客户', className: 'status-tag status-tag--medium' });
+ return items;
+}
+
+// 风评预警 = 名下客户里风评已超期(FM-03)的人数。给顾问一眼看见「有几个人的单子现在签不了」。
+function fuseCount(rows) {
+ return rows.filter((item) => assessmentFuseHits(item.customer).length).length;
+}
+
+export function createCustomerModule({ list, count, fuse, onChange }) {
+ let selectedId = null;
+
+ function items() {
+ return (DEMO_ADVISOR_CUSTOMERS[DEFAULT_ADVISOR] || [])
+ .map((id) => ({ id, customer: DEMO_CUSTOMERS[id] }))
+ .filter((item) => Boolean(item.customer));
+ }
+
+ function renderFuse(rows) {
+ if (!fuse) return;
+ const hits = fuseCount(rows);
+ fuse.hidden = hits === 0;
+ fuse.textContent = `风评预警 ${hits}`;
+ fuse.className = hits ? 'status-tag status-tag--failed' : 'status-tag status-tag--ok';
+ }
+
+ function markup(item) {
+ const { id, customer } = item;
+ const active = id === selectedId;
+ return `';
+ }
+
+ function render() {
+ const rows = items();
+ list.innerHTML = rows.map(markup).join('');
+ if (count) {
+ count.textContent = rows.length ? `共 ${rows.length} 位 · 已选 ${selectedId ? customerLabelOf(selectedId) : '--'}` : '暂无归属客户';
+ }
+ renderFuse(rows);
+ list.querySelectorAll('[data-customer]').forEach((button) => {
+ button.addEventListener('click', () => select(button.dataset.customer));
+ });
+ if (!selectedId && rows.length) select(rows[0].id);
+ }
+
+ function customerLabelOf(id) {
+ const customer = demoCustomer(id);
+ return customer ? customer.name : id;
+ }
+
+ function select(id) {
+ selectedId = id;
+ list.querySelectorAll('[data-customer]').forEach((button) => {
+ const active = button.dataset.customer === id;
+ button.classList.toggle('advisor-customer--active', active);
+ button.setAttribute('aria-pressed', String(active));
+ });
+ if (count) {
+ const rows = items();
+ count.textContent = `共 ${rows.length} 位 · 已选 ${customerLabelOf(id)}`;
+ }
+ if (typeof onChange === 'function') onChange(id);
+ }
+
+ return Object.freeze({
+ render,
+ select,
+ items,
+ selectedId: () => selectedId,
+ selected: () => demoCustomer(selectedId),
+ selectedDbId: () => customerDbId(selectedId),
+ selectedLabel: () => (selectedId ? customerLabelOf(selectedId) : '--'),
+ });
+}
diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.css b/app/static/portal/employee-advisor/dashboard/dashboard.css
index 675cc18..4926872 100644
--- a/app/static/portal/employee-advisor/dashboard/dashboard.css
+++ b/app/static/portal/employee-advisor/dashboard/dashboard.css
@@ -1,29 +1,197 @@
-.advisor-shell { --brand: #2d6f67; --brand-dark: #20574f; --brand-soft: #e1f0eb; --canvas: #f3f7f5; }
-.advisor-hero { background-image: linear-gradient(118deg, rgba(17, 50, 55, .96), rgba(39, 99, 88, .82) 64%, rgba(67, 126, 108, .68)), url('/static/portal/guest/home/assets/wealth_architecture_hero.jpg'); }
-.advisor-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: var(--space-4); }
-.advisor-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--space-4); align-items: start; }
-.advisor-panel { min-height: 300px; }
-.advisor-panel--accent { background: linear-gradient(145deg, var(--surface), var(--surface-soft)); }
-.advisor-actions { min-height: 0; }
-.action-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
-.action-card { min-height: 112px; padding: var(--space-4); display: grid; align-content: center; gap: var(--space-2); color: var(--ink); text-align: left; background: linear-gradient(145deg, var(--surface), var(--surface-soft)); border: 1px solid var(--line); border-radius: var(--radius-md); cursor: pointer; transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; }
-.action-card:hover, .action-card:focus-visible { border-color: var(--brand); box-shadow: 0 10px 24px rgba(34, 91, 79, .11); transform: translateY(-2px); outline: 0; }
-.action-card strong { font-size: 15px; font-weight: 700; }
-.action-card span { color: var(--muted); font-size: var(--fs-small); line-height: 1.55; }
-.advisor-output { margin-top: var(--space-4); min-height: 148px; padding: var(--space-4); background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius-md); }
-.advisor-output__placeholder, .advisor-output__loading { display: grid; min-height: 110px; place-items: center; color: var(--muted); text-align: center; }
-.advisor-output__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
-.advisor-output__message { margin: 0; color: var(--ink-soft); line-height: 1.7; }
-.advisor-inline-form { display: flex; align-items: end; gap: var(--space-3); }
-.advisor-inline-form .form-field { flex: 1; }
+/* 投顾工作台页面样式。
+ *
+ * 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层)
+ * → 本文件(只写投顾页自己的东西)。
+ *
+ * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 已发布)**。
+ * 约定:
+ * · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距;
+ * · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式;
+ * · 断点对齐 base.css:900px(平板)/ 620px(手机)。
+ *
+ * `.advisor-shell` 是本页的品牌覆盖(与 operations.css 里 `.portal-risk` / `.portal-admin`
+ * 同一手法),因为它挂在 上,只影响本页。
+ */
+.advisor-shell {
+ --brand: #2d6f67;
+ --brand-dark: #20574f;
+ --brand-soft: #e1f0eb;
+ --canvas: #f3f7f5;
+ --surface-soft: #eef4f1;
+}
+
+/* hero 大图按本页品牌色重打一层渐变(与 `.portal-risk` / `.portal-admin` 同一手法):
+ * 底图共用门户那张 `wealth_architecture_hero.jpg`,只换蒙版色,保证「同一套视觉体系、不同模块不同色调」。 */
+.advisor-shell .operations-hero {
+ background-image: linear-gradient(118deg, rgba(9, 38, 38, .96), rgba(20, 76, 70, .84) 64%, rgba(38, 112, 100, .72)),
+ url('/static/portal/guest/home/assets/wealth_architecture_hero.jpg');
+}
+
+/* hero 右下角的身份摘要:与左栏「投顾身份」同源(由 `dashboard.js` 同时写两处)。 */
+.advisor-shell .operations-hero__meta strong { font-size: var(--fs-body); font-weight: 700; }
+
+/* 概览指标卡:与 operations.css 的 `.operations-shell > .metric-grid` 同一形态,
+ * 这里只补本页的语义色 —— 风评预警有值时用危险色。 */
+.advisor-shell > .metric-grid .metric-card__meta { line-height: 1.55; }
+
+/* 模块加载失败时的兜底说明:入口模块跑起来后会把它移除(不需要内联脚本)。 */
+.advisor-notice {
+ padding: var(--space-4);
+ color: var(--ink-soft);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-left: 3px solid var(--accent);
+ border-radius: var(--radius-md);
+ line-height: 1.75;
+}
+.advisor-notice a { color: var(--brand-dark); text-decoration: underline; }
+
+/* 页头上的快捷退出按钮:外观向 `account-menu__trigger` 看齐,
+ * 用危险色表明它是会话动作(账号菜单里的登出项也是 --danger)。
+ * 特意不用 `.button--quiet` —— base.css 在 ≤620px 会把头部动作区的 quiet 按钮藏掉。 */
+.advisor-logout {
+ min-height: var(--control-height);
+ padding: 0 var(--space-3);
+ color: var(--danger);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ font-weight: 700;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
+}
+.advisor-logout:hover, .advisor-logout:focus-visible { color: var(--danger); background: var(--danger-soft); border-color: var(--danger); outline: 0; }
+
+/* ---- 两栏构图 ---- */
+.advisor-layout { display: grid; grid-template-columns: 336px minmax(0, 1fr); gap: var(--space-4); align-items: start; }
+.advisor-rail { display: grid; gap: var(--space-4); }
+.advisor-board { display: grid; gap: var(--space-4); }
+.advisor-rail .panel__body, .advisor-board .panel__body { padding: var(--space-4); }
+
+/* ---- 投顾身份 ---- */
+.advisor-identity__name { margin: 0; font-size: var(--fs-title); font-weight: 700; }
+.advisor-identity__meta { margin: var(--space-1) 0 var(--space-3); color: var(--muted); font-size: var(--fs-small); line-height: 1.6; overflow-wrap: anywhere; }
+
+/* ---- 客户列表 ---- */
+.advisor-counts { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
+.advisor-customers { display: grid; gap: var(--space-2); }
+.advisor-customer {
+ min-height: 0;
+ padding: var(--space-3);
+ display: grid;
+ gap: var(--space-1);
+ color: var(--ink);
+ text-align: left;
+ background: var(--surface-soft);
+ border: 1px solid var(--line);
+ border-left: 3px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: border-color 180ms ease, background 180ms ease, transform 180ms ease;
+}
+.advisor-customer:hover, .advisor-customer:focus-visible { border-color: var(--brand); outline: 0; transform: translateX(2px); }
+.advisor-customer--active { background: var(--brand-soft); border-color: var(--brand); border-left-color: var(--brand); }
+.advisor-customer__title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); }
+.advisor-customer__title strong { font-size: var(--fs-body); font-weight: 700; }
+.advisor-customer__meta { color: var(--muted); font-size: var(--fs-small); line-height: 1.55; }
+
+/* ---- 参数与动作(左栏竖排按钮) ---- */
+.advisor-toolbar { margin-top: var(--space-4); display: grid; gap: var(--space-2); }
+.advisor-actions { margin-top: var(--space-3); display: grid; gap: var(--space-2); }
+.advisor-actions .button { width: 100%; }
+.advisor-composer { margin-top: var(--space-3); }
+.advisor-composer .form-field__input { width: 100%; }
+.advisor-composer .button { width: 100%; margin-top: var(--space-2); }
+
+/* 快捷问句:三个常用意图的入口,点了等于把话打进上面的输入框。
+ * 用 chip 的形态(不是 `.button`)—— 它们是「建议」而非操作,抢视觉会让
+ * 上面那排真正的操作按钮失去层次。 */
+.advisor-prompts { margin-top: var(--space-2); display: flex; flex-wrap: wrap; gap: var(--space-2); }
+.advisor-prompt {
+ padding: var(--space-1) var(--space-3);
+ color: var(--brand-dark);
+ background: var(--brand-soft);
+ border: 1px solid transparent;
+ border-radius: 999px;
+ font-size: var(--fs-small);
+ font-weight: 600;
+ cursor: pointer;
+ transition: border-color 160ms ease, background 160ms ease;
+}
+.advisor-prompt:hover, .advisor-prompt:focus-visible { border-color: var(--brand); background: var(--surface); outline: 0; }
+
+.advisor-inline-form__note { margin: var(--space-3) 0 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
+.advisor-inline-form__note a { color: var(--brand-dark); text-decoration: underline; }
+
+/* ---- 推荐流水线 ---- */
+.advisor-steps { margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--space-1); list-style: none; }
+.advisor-step {
+ flex: 1 1 72px;
+ min-width: 72px;
+ padding: var(--space-2) var(--space-1);
+ color: var(--muted);
+ text-align: center;
+ font-size: var(--fs-small);
+ background: var(--surface-soft);
+ border: 1px dashed var(--line);
+ border-radius: var(--radius-sm);
+ transition: color 180ms ease, background 180ms ease, border-color 180ms ease;
+}
+.advisor-step--doing { color: var(--brand-dark); background: var(--brand-soft); border-color: var(--brand); font-weight: 700; }
+.advisor-step--done { color: var(--success); background: var(--success-soft); border-style: solid; border-color: var(--success); }
+.advisor-step--blocked { color: var(--danger); background: var(--danger-soft); border-style: solid; border-color: var(--danger); font-weight: 700; }
+.advisor-pipeline__note { margin: var(--space-3) 0 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.65; }
+
+/* ---- 结果区 ---- */
+.advisor-output { padding: var(--space-4); background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius-md); }
+.advisor-output__placeholder, .advisor-output__loading { display: grid; min-height: 132px; place-items: center; color: var(--muted); text-align: center; }
+.advisor-output__header { margin-bottom: var(--space-3); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
+.advisor-output__header strong { font-size: var(--fs-title); }
+.advisor-output__tags { display: inline-flex; align-items: center; gap: var(--space-2); }
+.advisor-output__message { margin: var(--space-2) 0 0; color: var(--ink-soft); line-height: 1.7; }
+.advisor-output__subtitle { margin: var(--space-4) 0 var(--space-2); font-size: var(--fs-body); }
+.advisor-output .data-table-wrap { margin-bottom: var(--space-3); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); }
+.advisor-output .detail-grid { margin-bottom: var(--space-3); }
+
+.advisor-fuse { margin-bottom: var(--space-3); padding: var(--space-4); color: var(--danger); background: var(--danger-soft); border: 1px solid var(--danger); border-left-width: 3px; border-radius: var(--radius-sm); line-height: 1.7; }
+.advisor-fuse strong { display: block; margin-bottom: var(--space-2); }
+.advisor-fuse p { margin: 0 0 var(--space-2); }
+.advisor-fuse p:last-child { margin-bottom: 0; }
+.advisor-disclaimer { margin: var(--space-3) 0 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.7; }
+
+/* ---- 表单 ---- */
.advisor-form { display: grid; gap: var(--space-4); }
.advisor-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.advisor-form__wide { grid-column: 1 / -1; }
.advisor-form__textarea { height: auto; padding-top: var(--space-3); padding-bottom: var(--space-3); resize: vertical; }
+.advisor-inline-form { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); }
+.advisor-inline-form .form-field { flex: 1 1 180px; }
+.advisor-output .form-alert { margin: var(--space-3) 0; }
+
+/* ---- 已发布交付物 ---- */
.advisor-card { padding: var(--space-4); display: grid; gap: var(--space-2); border-bottom: 1px solid var(--line); }
.advisor-card:last-child { border-bottom: 0; }
.advisor-card__title { margin: 0; font-size: 16px; font-weight: 680; }
.advisor-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
.advisor-card__content { margin: var(--space-2) 0 0; padding: var(--space-3); color: var(--ink-soft); background: var(--canvas); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
-@media (max-width: 900px) { .advisor-workspace { grid-template-columns: 1fr; } }
-@media (max-width: 760px) { .advisor-grid { grid-template-columns: 1fr; } .action-grid, .advisor-form__grid { grid-template-columns: 1fr; } .advisor-form__wide { grid-column: auto; } .advisor-inline-form { align-items: stretch; flex-direction: column; } }
+
+/* ---- 执行日志 ---- */
+.log-item { font-size: var(--fs-small); line-height: 1.6; overflow-wrap: anywhere; }
+.log-item--success { border-left-color: var(--success); }
+.log-item--warning { border-left-color: var(--accent); }
+.log-item--danger { border-left-color: var(--danger); }
+
+/* ---- 响应式 ---- */
+@media (max-width: 900px) {
+ .advisor-layout { grid-template-columns: 1fr; }
+}
+@media (max-width: 620px) {
+ .advisor-logout { padding: 0 var(--space-2); font-size: var(--fs-small); }
+ .advisor-form__grid { grid-template-columns: 1fr; }
+ .advisor-form__wide { grid-column: auto; }
+ .advisor-inline-form { align-items: stretch; flex-direction: column; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .advisor-customer, .advisor-step, .advisor-prompt { transition: none; }
+}
diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js
index ce24bdd..50885df 100644
--- a/app/static/portal/employee-advisor/dashboard/dashboard.js
+++ b/app/static/portal/employee-advisor/dashboard/dashboard.js
@@ -1,38 +1,208 @@
-import { getAuthContext, requireAdvisor } from '/static/portal/common/auth.js?v=20260913';
-import { mountShell } from '/static/portal/common/layout/app-shell.js';
-import { createActionsModule } from './actions-module.js';
-import { createPublishedModule } from './published-module.js';
-
-// 投顾工作台。本文件只做**组合**,具体能力拆在三个模块里:
+// 投顾工作台。本文件只做**组合**,能力拆在五个模块里:
//
-// · advisor-config.js 状态与操作的文案表(唯一来源)
-// · published-module.js 已发布方案列表 + 概览指标
-// · actions-module.js 五项操作:组合分析 / 资产配置 / 推荐草案 /
-// 录入客户目标 / 目标确认与方案书
+// · advisor-config.js 文案 / 操作 / 熔断规则 / 演示数据的唯一来源
+// · advisor-engine.js 本地演示引擎(纯函数:后端无接口的两个操作 + 离线回退)
+// · customer-module.js 我的客户列表与选中客户(整个工作台的上下文)
+// · actions-module.js 七项操作:表单、请求、熔断闸门、结果渲染
+// · published-module.js 已发布交付物(只读列表)
+// · assistant-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();
- document.querySelector('[data-advisor-name]').textContent = context?.username || '投顾人员';
- document.querySelector('[data-advisor-scope]').textContent =
- `数据范围:${context?.dataScope || 'assigned'}`;
+// ⚠️ 身份守卫与 `mountShell()` 必须成对出现:`mountShell({ mode: 'advisor' })` 决定顶部
+// 导航,`requireAdvisor()` 决定能不能进这个页面。`?demo=1` 是唯一绕过守卫的路径 ——
+// 它只用本地演示引擎,不碰真实数据。
- const published = createPublishedModule({
- list: document.querySelector('[data-recommendations]'),
- metrics: document.querySelector('[data-advisor-metrics]'),
+// ⚠️ **本目录内的相对 import 必须带 `?v=`**(与 `/static/portal/common/**` 的写法一致)。
+//
+// 浏览器按**完整 URL** 给模块建缓存键:`./actions-module.js` 与 `./actions-module.js?v=X`
+// 是两个 URL。不带版本号时,改了文件内容而 URL 不变 —— 浏览器继续用旧副本,
+// 修复"看起来没生效"。2026-09-14 就吃过这个:`api-client.js` 修好了解包逻辑但没有升版本号,
+// 页面上仍然是修之前的症状(后端返回状态:undefined)。
+//
+// 规则:**改动本页任一 js / css,就把下面这批版本号一起 +1**,且**同一批改动用同一个版本号**
+// —— 版本号不一致会让同一个文件被两个 URL 引入,模块被实例化成两份。
+// (import 说明符是静态的,没法用变量拼,只能逐处写。)
+import { clearAuthSession, getAccessToken, getAuthContext, requireAdvisor } from '/static/portal/common/auth.js?v=20260913';
+import { apiClient } from '/static/portal/common/api-client.js?v=20260914-3';
+import { escapeHtml } from '/static/portal/common/formatters.js?v=20260913';
+import { mountShell } from '/static/portal/common/layout/app-shell.js';
+import { renderLoading } from '/static/portal/common/state-view.js?v=20260913';
+import { METRIC_HINTS, PIPELINE_STEPS } from './advisor-config.js?v=20260914-advisor9';
+import { assessmentFuseHits } from './advisor-engine.js?v=20260914-advisor9';
+import { createActionsModule } from './actions-module.js?v=20260914-advisor9';
+import { createAssistantModule } from './assistant-module.js?v=20260914-advisor9';
+import { createCustomerModule } from './customer-module.js?v=20260914-advisor9';
+import { createPublishedModule } from './published-module.js?v=20260914-advisor9';
+
+const DEMO_MODE = new URLSearchParams(window.location.search).get('demo') === '1';
+
+function start(context) {
+ const token = DEMO_MODE ? '' : getAccessToken();
+ const online = () => Boolean(token);
+
+ mountShell({ active: 'advisor-dashboard', mode: 'advisor' });
+
+ const steps = document.querySelector('[data-steps]');
+ steps.innerHTML = PIPELINE_STEPS.map((label) => `${escapeHtml(label)}`).join('');
+ document.querySelector('[data-pipeline-note]').textContent =
+ '按所选客户执行:风评超期时停在 ② 画像(合规熔断),其余异常停在 ③ 筛选,通过则走完 ⑥ 步。';
+
+ // 身份信息在 hero 与左栏各出现一次(hero 是全局摘要,左栏是就近上下文),
+ // 两处都写,避免「改一处漏一处」。
+ const nameText = context?.username || '投顾人员';
+ const scopeText = online()
+ ? `数据范围:${context?.dataScope || 'assigned'} · 角色 ${(context?.roles || []).join('/') || '--'}`
+ : '本地演示引擎(未连接真实后端)';
+ document.querySelectorAll('[data-advisor-name]').forEach((node) => { node.textContent = nameText; });
+ document.querySelectorAll('[data-advisor-scope]').forEach((node) => { node.textContent = scopeText; });
+
+ const statusTag = document.querySelector('[data-backend-status]');
+ const setStatus = (text, tone) => {
+ statusTag.textContent = text;
+ statusTag.className = `status-tag${tone ? ` status-tag--${tone}` : ''}`;
+ };
+ setStatus(DEMO_MODE ? '演示模式 · 本地引擎' : '后端检测中…', DEMO_MODE ? 'medium' : null);
+
+ // 常显的退出入口。
+ //
+ // 账号菜单里本来就有「退出登录」,但它折叠在用户名后面,不点开看不到 —— 这里只是在
+ // 壳的头部动作区加一个快捷按钮。**不另写登出逻辑**:动作与 app-shell.js 的 `[data-logout]`
+ // 一字不差(同一个 `clearAuthSession()` + 同一个回跳地址),会话清理的实现始终只有 auth.js 一份。
+ // 演示模式(`?demo=1`)下没有会话可退,不显示。
+ if (online()) {
+ const logoutButton = document.createElement('button');
+ logoutButton.type = 'button';
+ logoutButton.className = 'advisor-logout';
+ logoutButton.dataset.advisorLogout = '';
+ logoutButton.textContent = '退出登录';
+ logoutButton.addEventListener('click', () => {
+ clearAuthSession();
+ window.location.assign('/portal/guest/home/?reason=signed-out');
+ });
+ document.querySelector('.site-header__actions')?.prepend(logoutButton);
+ }
+
+ // ---- 执行日志 ----
+ const logBox = document.querySelector('[data-log]');
+ const log = (message, tone = '') => {
+ const item = document.createElement('li');
+ item.className = `operations-list__item log-item${tone ? ` log-item--${tone}` : ''}`;
+ item.textContent = message;
+ logBox.append(item);
+ while (logBox.children.length > 60) logBox.firstElementChild.remove();
+ logBox.scrollTop = logBox.scrollHeight;
+ };
+ log(DEMO_MODE
+ ? '演示模式:不会调用真实后端,所有结论均为本地规则模拟'
+ : `已复用平台会话:${context?.username || '-'} · 角色 ${(context?.roles || []).join('/') || '--'}`,
+ DEMO_MODE ? 'warning' : 'success');
+
+ // ---- 概览指标(hero 下方 4 张卡) ----
+ //
+ // 形态与「平台治理工作台」「风险工作台」一致:label / value / meta 三元组。
+ // 数据分两批到:客户相关的一开始就能算(本地数据),已发布方案数要等接口回来
+ // (见下面 `published` 的 `onClick` 回调)。所以先渲染一次、拿到数再渲染一次,
+ // 单张卡在非登录态显示「--」而不是假 0 —— 0 和「不知道」是两回事。
+ const metricBox = document.querySelector('[data-advisor-metrics]');
+ let publishedCount = null;
+ let backendOnline = null;
+
+ function renderMetrics() {
+ if (!metricBox) return;
+ if (backendOnline === null && online()) {
+ renderLoading(metricBox, 4);
+ return;
+ }
+ const rows = customers.items();
+ const fused = rows.filter((item) => assessmentFuseHits(item.customer).length).length;
+ const demo = backendOnline === false || DEMO_MODE;
+ const metrics = [
+ ['已发布方案数', publishedCount === null ? '--' : publishedCount, METRIC_HINTS.published],
+ ['服务客户数', rows.length, METRIC_HINTS.customers],
+ ['风评预警数', fused, METRIC_HINTS.fuse, fused ? 'negative' : ''],
+ ['数据源', demo ? '本地引擎' : '真实后端', demo
+ ? '未连接后端,结论为本地规则模拟'
+ : `FastAPI · ${backendOnline === null ? '检测中' : '在线'}`],
+ ];
+ metricBox.innerHTML = metrics.map(([label, value, meta, tone]) => ``
+ + `${escapeHtml(label)}
`
+ + `${escapeHtml(value)}
`
+ + `${escapeHtml(meta)}
`).join('');
+ }
+
+ // ---- 模块装配(客户先建,因为操作模块要读"选中客户") ----
+ const selectedTag = document.querySelector('[data-selected-customer]');
+ const customers = createCustomerModule({
+ list: document.querySelector('[data-customers]'),
+ count: document.querySelector('[data-customer-count]'),
+ fuse: document.querySelector('[data-fuse-count]'),
+ onChange: () => {
+ const customer = customers.selected();
+ selectedTag.textContent = `当前客户:${customers.selectedLabel()}`;
+ selectedTag.className = customers.selectedDbId() ? 'tag' : 'tag tag--neutral';
+ log(`已选择客户:${customers.selectedLabel()}`
+ + (customers.selectedDbId() ? `(后端客户号 ${customers.selectedDbId()},按该客户出方案)` : '(无后端客户号,走本地演示引擎)'),
+ customer && assessmentFuseHits(customer).length ? 'danger' : '');
+ renderMetrics();
+ },
});
+
const actions = createActionsModule({
output: document.querySelector('[data-action-output]'),
alert: document.querySelector('[data-action-alert]'),
+ steps,
+ amountInput: document.querySelector('[data-amount]'),
+ horizonSelect: document.querySelector('[data-horizon]'),
+ log,
+ customers,
+ online,
});
+ const published = createPublishedModule({
+ list: document.querySelector('[data-recommendations]'),
+ onClick: (count) => { publishedCount = count; renderMetrics(); },
+ });
+
+ const assistant = createAssistantModule({
+ log,
+ output: document.querySelector('[data-action-output]'),
+ form: document.querySelector('[data-chat-form]'),
+ input: document.querySelector('[data-chat-input]'),
+ actions,
+ customers,
+ online,
+ });
+
+ document.querySelector('[data-refresh]').addEventListener('click', () => published.load());
actions.bind();
- document.querySelector('[data-refresh]').addEventListener('click', published.load);
- published.load();
+ assistant.bind();
+ document.querySelector('[data-boot-hint]')?.remove();
+
+ customers.render();
+ renderMetrics();
+
+ if (online()) {
+ // 已发布交付物只在登录后才有意义;顺手用它的成败判断后端是否在线 ——
+ // 比单独打一次 /health 更省一次请求,失败时也仍然有降级文案。
+ published.load().then(() => {
+ apiClient.get('HEALTH')
+ .then(() => { backendOnline = true; setStatus('后端在线 · FastAPI', null); renderMetrics(); })
+ .catch(() => { backendOnline = false; setStatus('后端未连接 · 本地引擎', 'medium'); renderMetrics(); });
+ });
+ } else {
+ backendOnline = false;
+ renderMetrics();
+ actions.execute('recommend');
+ }
+}
+
+if (DEMO_MODE) {
+ start(null);
+} else if (requireAdvisor()) {
+ start(getAuthContext());
}
diff --git a/app/static/portal/employee-advisor/dashboard/index.html b/app/static/portal/employee-advisor/dashboard/index.html
index b765a0d..0bac14d 100644
--- a/app/static/portal/employee-advisor/dashboard/index.html
+++ b/app/static/portal/employee-advisor/dashboard/index.html
@@ -1,741 +1,129 @@
-
-
-
-
-
-投顾助手 · AI 工作台 v1.1
-
+
+
+
+
+
+ 投顾工作台 · 南方财富
+
+
+
-
-
- 投顾助手 · AI 工作台
- v1.1 · 对接 FastAPI 后端(127.0.0.1:8000)
- 后端检测中…
- AI 仅作投资分析辅助 · 不代客交易 · 输出均为草稿态,须持证顾问审核签发
-
+
+
+
+
+
组合、适当性与合规留痕
+
投顾工作台
+
按客户画像与已确认投资目标生成投资方案,风评失效自动熔断,全流程结果均由服务端鉴权并留存审计。
+
组合分析资产配置合规熔断
+
+
+ 投顾人员
+ 数据范围加载中
+
+
-
-
-
-
投顾身份
-
-
检测平台会话…
-
生成推荐 / 资产配置 / 持仓诊断 均按
所选客户调用真实后端(换客户即换结果)。
画像评分 / 调仓建议 后端暂无对应接口,走本地演示引擎。
使用本地演示数据
+
+
+
+ 本页需要平台会话才能连接真实后端。若长时间没有数据:请从
+
平台地址打开(预览或本地文件地址加载不到平台模块),
+ 也可以用
本地演示数据先浏览离线效果。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
先在左侧选择客户,再点一项操作,或直接用自然语言提问。
+
+
+
+
+
+
+
-
-
我的客户
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
推荐流水线
-
-
① 鉴权
② 画像
③ 筛选
-
④ 方案
⑤ 合规
⑥ 审核留痕
-
-
-
-
-
-
-
-
+
+
diff --git a/app/static/portal/employee-advisor/dashboard/published-module.js b/app/static/portal/employee-advisor/dashboard/published-module.js
index e412536..ec91a07 100644
--- a/app/static/portal/employee-advisor/dashboard/published-module.js
+++ b/app/static/portal/employee-advisor/dashboard/published-module.js
@@ -1,28 +1,35 @@
-import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
-import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
-import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
-import { CONTENT_TYPE_LABELS } from './advisor-config.js';
+// 已发布交付物(只读列表)。
+//
+// 数据口径见 `app/static/portal/README.md`:`/api/v1/advisor/recommendations/published`
+// 返回**本人 + 名下归属客户**、且已通过审核发布的交付物(方案书 + 推荐方案两类)。
+// 发布动作要求管理员,投顾侧只读 —— 所以这里只有列表,没有「发布」入口。
-export function createPublishedModule({ list, metrics }) {
+import { apiClient } from '/static/portal/common/api-client.js?v=20260914-3';
+import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
+import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260913';
+import { CONTENT_TYPE_LABELS } from './advisor-config.js?v=20260914-advisor9';
+
+export function createPublishedModule({ list, onClick }) {
async function load() {
- renderLoading(list, 3);
+ renderLoading(list, 2);
try {
const response = await apiClient.get('ADVISOR_PUBLISHED');
const rows = Array.isArray(response.data) ? response.data : [];
- metrics.innerHTML = [
- ['已发布方案', rows.length, '服务端返回'],
- ['协作客户', '按归属', '数据范围内'],
- ['审核状态', '留痕', '发布前需复核'],
- ].map(([label, value, meta]) =>
- `
${escapeHtml(label)}
${escapeHtml(value)}
${escapeHtml(meta)}
`
- ).join('');
+ if (typeof onClick === 'function') onClick(rows.length);
if (!rows.length) {
- renderEmpty(list, '暂未发布方案', '当前账号暂无已审核发布的客户方案。');
+ 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('');
+ list.innerHTML = rows.map((row) => {
+ const title = CONTENT_TYPE_LABELS[row.content_type] || '交付物';
+ const plan = typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}, null, 2);
+ return `
${escapeHtml(title)}
`
+ + `客户 ${escapeHtml(row.customer_id || '--')} · 发布于 ${escapeHtml(formatDateTime(row.published_at))}
`
+ + `${escapeHtml(plan)}
`;
+ }).join('');
} catch (error) {
apiClient.reportError(error);
+ if (typeof onClick === 'function') onClick(null);
renderError(list, error, load);
}
}
diff --git a/app/static/portal/employee-console/workspace/index.html b/app/static/portal/employee-console/workspace/index.html
index da89421..81d35d4 100644
--- a/app/static/portal/employee-console/workspace/index.html
+++ b/app/static/portal/employee-console/workspace/index.html
@@ -50,6 +50,6 @@
浏览器按**完整 URL** 去重,两条不同 query 会被当成两个模块、**各执行一次**,
于是入口里的 `mountShell()` 跑两遍,页面上出现**两份顶部导航与页脚**。
改版本号时是**替换**这一行,不是新增一行。 -->
-
+
diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js
index 9bf8440..fc24033 100644
--- a/app/static/portal/employee-console/workspace/workspace.js
+++ b/app/static/portal/employee-console/workspace/workspace.js
@@ -202,7 +202,7 @@ if (requireAdmin()) {
const response = await apiClient.get('ADMIN_ADVISOR_PENDING');
state.advisor = Array.isArray(response.data) ? response.data : [];
if (!state.advisor.length) {
- renderEmpty(targets.advisor, '暂无待审内容', '投顾生成推荐草案或录入客户目标后,会出现在这里等待复核。');
+ renderEmpty(targets.advisor, '暂无待审内容', '投顾生成推荐方案或录入客户目标后,会出现在这里等待复核。');
return;
}
const rows = state.advisor.map((item) => ({
diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py
index 3e11ac5..17d64fc 100644
--- a/tests/unit/api/test_portal_frontend.py
+++ b/tests/unit/api/test_portal_frontend.py
@@ -176,7 +176,7 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None:
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
):
assert f"{endpoint_id}:" in source
- for label in ("组合分析", "资产配置", "生成推荐草案", "录入客户目标"):
+ for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"):
assert label in dashboard
diff --git a/tools/check_portal_modules.py b/tools/check_portal_modules.py
index d1b660e..f4fcf4a 100644
--- a/tools/check_portal_modules.py
+++ b/tools/check_portal_modules.py
@@ -1,4 +1,4 @@
-"""验证投顾工作台四个前端模块:语法正确、import 能解析、用到的符号有来源。
+"""验证投顾工作台的前端模块:语法正确、import 能解析、用到的符号有来源。
## 为什么要这个检查
@@ -8,14 +8,20 @@
只会在浏览器里以 `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,减少误报。
+## 第 4 条的取词范围(`code_only()`)
+
+只保留**真代码**:注释、`'…'` / `"…"` 字符串、以及模板字符串里的**文字部分**都被清空,
+唯独 `${…}` 里的表达式保留下来(那里是真代码,也正是原 bug 容易藏身的地方)。
+
+早期版本只剔除引号字符串,结果注释里的 `HHI`、文案里的 `AUM`、模板串里的 `QDII`
+全被当成"未声明的变量" —— 误报比真问题还多,检查会被当成摆设跳过。
"""
from __future__ import annotations
@@ -35,6 +41,9 @@ MODULES = [
"dashboard.js",
"actions-module.js",
"published-module.js",
+ "assistant-module.js",
+ "customer-module.js",
+ "advisor-engine.js",
"advisor-config.js",
]
@@ -57,16 +66,76 @@ EXPORT_RE = re.compile(
)
#: 全大写常量:至少两个字符、全大写/数字/下划线,且不是"前面带点"的属性访问。
CONST_USE_RE = re.compile(r"(? str:
+ """只保留真代码:清空注释、引号字符串与模板字符串的文字部分(`${…}` 里的表达式保留)。
+
+ 逐字符扫描而不是一把正则:模板字符串里可以嵌套 `${…}`(甚至再嵌模板),
+ 正则匹配不了嵌套,只能老老实实走一遍。
+ """
+ out: list[str] = []
+ index = 0
+ length = len(source)
+ while index < length:
+ char = source[index]
+ following = source[index + 1] if index + 1 < length else ""
+ if char == "/" and following == "*":
+ end = source.find("*/", index + 2)
+ index = length if end == -1 else end + 2
+ out.append(" ")
+ continue
+ if char == "/" and following == "/":
+ end = source.find("\n", index)
+ index = length if end == -1 else end
+ out.append(" ")
+ continue
+ if char in "'\"":
+ cursor = index + 1
+ while cursor < length:
+ if source[cursor] == "\\":
+ cursor += 2
+ continue
+ if source[cursor] == char:
+ break
+ cursor += 1
+ out.append(" ")
+ index = min(cursor + 1, length)
+ continue
+ if char == "`":
+ index += 1
+ while index < length:
+ if source[index] == "\\":
+ index += 2
+ continue
+ if source[index] == "`":
+ index += 1
+ break
+ if source[index] == "$" and index + 1 < length and source[index + 1] == "{":
+ depth = 1
+ index += 2
+ start = index
+ while index < length and depth:
+ if source[index] == "{":
+ depth += 1
+ elif source[index] == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ index += 1
+ out.append(" " + source[start:index] + " ")
+ index += 1
+ continue
+ index += 1
+ out.append(" ")
+ continue
+ out.append(char)
+ index += 1
+ return "".join(out)
+
+
def exported_names(path: Path) -> set[str]:
source = path.read_text(encoding="utf-8")
names: set[str] = set()
@@ -155,8 +224,8 @@ def main() -> int:
# ---- 用到的全大写常量必须有来源 ----
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))
+ # 先清掉注释 / 字符串 / 模板文字,再排除对象字面量的 key。
+ stripped = OBJECT_KEY_RE.sub(" ", code_only(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:
diff --git a/启动后端.bat b/启动后端.bat
new file mode 100644
index 0000000..2fdf6e5
--- /dev/null
+++ b/启动后端.bat
@@ -0,0 +1,66 @@
+@echo off
+chcp 936 >nul
+setlocal
+title ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ(API)
+
+rem ============================================================
+rem ˫»÷±¾ÎļþÆô¶¯ºó¶Ë API£¨FastAPI + uvicorn£¬127.0.0.1:8000£©¡£
+rem ֻÆð½ӿÚÓëǰ¶ËҳÃ棻Agent Worker£¨¿ͷþ¶Ի°/֪ʶÏòÁ¿ͬ²½/
+rem ·ç¿ØɨÃ裩²»º¬ÔÚÄڣ¬ÐèҪʱÇëÁíÍâÆô¶¯¡£
+rem PROJ ×Զ¯ȡ±¾ÎļþËùÔÚĿ¼£¨ÏîĿ¸ù£©£¬²ֿ⸴ÖƵ½ÄͼÄÜÅܡ£
+rem ¿Éѡ²ÎÊý£ºÆô¶¯ºó¶Ë.bat 8100 ָ¶¨¶˿ڣ¨ĬÈÏ 8000£©
+rem ============================================================
+
+set "PROJ=%~dp0"
+set "HOST=127.0.0.1"
+set "PORT=%~1"
+if "%PORT%"=="" set "PORT=8000"
+set "PY=%PROJ%.venv\Scripts\python.exe"
+
+echo ============================================================
+echo ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ
+echo ============================================================
+echo.
+
+if not exist "%PY%" (
+ echo [´íÎó] ÕҲ»µ½Ðéľ³½âÊÍÆ÷£º
+ echo %PY%
+ echo.
+ echo ÇëÏÈÔÚÏîĿ¸ùִÐÐһ´λ·¾³°²װ£º
+ echo py -3.13 -m venv .venv
+ echo .venv\Scripts\python.exe -m pip install -e .
+ echo.
+ pause
+ exit /b 1
+)
+
+if not exist "%PROJ%app\main.py" (
+ echo [´íÎó] δÕҵ½ app\main.py£¬±¾ .bat ±ØÐë·ÅÔÚÏîĿ¸ùĿ¼¡£
+ echo µ±ǰ PROJ = %PROJ%
+ echo.
+ pause
+ exit /b 1
+)
+
+cd /d "%PROJ%"
+
+echo ÏîĿĿ¼ : %PROJ%
+echo ½âÊÍÆ÷ : %PY%
+echo ¼àÌýµØַ : http://%HOST%:%PORT%
+echo ǰ¶ËÈë¿Ú : http://%HOST%:%PORT%/portal/
+echo ½¡¿µ¼ì²é : http://%HOST%:%PORT%/health
+echo.
+echo Ìáʾ£ºÇëȷÈÏ MySQL(127.0.0.1:3306) ÒÑÆô¶¯¡£
+echo °´ Ctrl+C ¿Éֹͣ·þÎñ¡£
+echo ============================================================
+echo.
+
+"%PY%" -m uvicorn app.main:app --host %HOST% --port %PORT% --log-level info
+
+set "RC=%ERRORLEVEL%"
+echo.
+echo ============================================================
+echo ·þÎñÒÑÍ˳ö£¨Í˳öÂë %RC%£©¡£
+echo ============================================================
+pause
+endlocal