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) => ``).join('') + + '' + + rows.map((cells) => `${cells.map((cell) => ``).join('')}`).join('') + + '
${escapeHtml(head)}
${cell}
'; +} + +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 `
${escapeHtml(title)}${sourceTag}${badge || ''}
`; +} + +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 = `
${escapeHtml(title)}${escapeHtml(status || 'ready')}
${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 = '
' + + '' + + `

${escapeHtml(ACTION_DESCRIPTIONS.recommend)};${willUseReal('recommend') ? '按所选客户调用真实后端' : '未登录,将用本地演示引擎'}。

` + + '
'; + 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 = `
客户目标${escapeHtml(GOAL_STATUS_LABELS[status] || status || '--')}
${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
方案书${escapeHtml(BOOK_STATUS_LABELS[book.review_status] || book.review_status || '--')}
${canConfirm ? '' : ''}

${canConfirm ? '确认后目标才可用于资产配置与推荐;方案书需由管理员审核发布。' : '方案书的审核与发布由管理员完成,投顾侧到此为止。'}

`; - output.querySelector('[data-confirm-goal]')?.addEventListener('click', () => confirmGoal(goalNo)); - output.querySelector('[data-view-book]')?.addEventListener('click', () => viewGoalBook(goalNo)); + 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 = `
投资目标方案书${escapeHtml(BOOK_STATUS_LABELS[data.review_status] || data.review_status || '--')}
${rows.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(String(value ?? '--'))}
`).join('')}
${(data.content?.disclosures || []).map((item) => `

${escapeHtml(item)}

`).join('')}`; + 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 = `
统一对话入口 · ${escapeHtml(title)}` + + `意图路由` + + `${escapeHtml(tag)}
` + + `

${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