// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。 // // 为什么单独抽一个模块:这两处展示的是同一份 `advisor_recommendation_plan`, // 各写一套必然漂移(改了一边忘了另一边)。本模块保持**纯渲染 + 一次注解式取数**: // · `renderPlanProducts(products)` —— 同步产出骨架(只用方案自带字段); // · `hydratePlanView(root, apiClient, products)` —— 取行情/净值后填图表与指标。 // // ⚠️ 不 import `api-client.js`:投顾页与客户页引入的 `apiClient` 版本不同, // 在共享模块里再 import 一份会多出一个实例(`ENDPOINTS` 注册表也就有了两份)。 // 所以 apiClient 由调用方当参数传进来(依赖注入)。 // // 数据来源(都已在 `api-client.js` 注册): // · P001 `GET /api/v1/products` → 最新净值/当日涨跌/基金经理/费率 // · P002 `GET /api/v1/products/{code}/nav-history` → 近 N 个交易日净值序列(画折线) // // 颜色口径:**涨红跌绿**(A 股习惯)。样式里用 `--plan-up` / `--plan-down` 两个变量 // 集中定义(`common/advisor-plan.css`),要与产品列表页的“绿涨红跌”一致时对调即可。 import { escapeHtml } from '/static/portal/common/formatters.js'; //: 从净值序列里取最近多少个交易日画走势。 const SPARK_DAYS = 180; //: 卡片上展示的区间涨跌(交易日数)。 const INTERVALS = Object.freeze([ ['近 20 日', 20], ['近 60 日', 60], ]); //: 环形图配色(最多 6 色循环)。用固定色值而不是主题变量:环形切片需要彼此可区分。 const SLICE_COLORS = Object.freeze([ '#2d6f67', '#c99a2e', '#4a7fb5', '#a6573f', '#7a6bab', '#5f8f52', ]); /** * ⚠️ 这里必须显式挡 `null` / `undefined` / `''`。 * 直接 `Number(null)` 会得到 **0** —— 于是"没有数据"被渲染成 `0.00%`(曾真出过这个 bug: * 近 20/60 日与管理费全显示 0.00%,看起来像"真的是 0")。`Number('')` 同理。 */ function num(value) { if (value === null || value === undefined || value === '') return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } /** 涨跌幅文本;拿不到就 `--`,绝不渲染成 `+0.00%`(会把"不知道"说成"平盘")。 */ function pctText(value) { const parsed = num(value); if (parsed === null) return '--'; return `${parsed > 0 ? '+' : ''}${parsed.toFixed(2)}%`; } /** 涨跌配色类;`null` 用中性色。 */ function pctClass(value) { const parsed = num(value); if (parsed === null) return 'advisor-plan__value--flat'; if (parsed > 0) return 'advisor-plan__value--up'; if (parsed < 0) return 'advisor-plan__value--down'; return 'advisor-plan__value--flat'; } function navValues(points) { return points.map((point) => num(point.nav)).filter((value) => value !== null); } function intervalChange(values, tradingDays) { if (values.length < 2) return null; const baseIndex = Math.max(0, values.length - 1 - tradingDays); const base = values[baseIndex]; if (!base) return null; return ((values[values.length - 1] - base) / base) * 100; } function metric(label, value, className = '') { return `
${escapeHtml(label)}
` + `
${escapeHtml(value)}
`; } // ---- 折线图(带坐标轴/网格;内联 SVG,不引图表库) ---- function shortDate(iso) { return String(iso || '').slice(5); } /** * 通用折线图。`series` 是 `[{date, value}]`;`formatter` 决定 y 轴刻度文本。 * 缺数据(<2 点)返回空串,由调用方给"暂无数据"文案。 */ function lineChartSvg(series, { formatter, ariaLabel }) { if (series.length < 2) return ''; const width = 640; const height = 220; const left = 58; const right = 16; const top = 14; const bottom = 30; const values = series.map((point) => point.value); let min = Math.min(...values); let max = Math.max(...values); if (min === max) { min -= 0.01; max += 0.01; } const padding = (max - min) * 0.08; min -= padding; max += padding; const xAt = (index) => left + (index * (width - left - right)) / (series.length - 1); const yAt = (value) => top + (1 - (value - min) / (max - min)) * (height - top - bottom); const rows = 4; const grid = Array.from({ length: rows + 1 }, (_, index) => { const value = min + ((max - min) * index) / rows; const y = yAt(value).toFixed(1); return `` + `` + `${escapeHtml(formatter(value))}`; }).join(''); const labelIndexes = [0, Math.floor((series.length - 1) / 2), series.length - 1]; const xLabels = labelIndexes.map((index, position) => { const anchor = position === 0 ? 'start' : (position === 2 ? 'end' : 'middle'); return `` + `${escapeHtml(shortDate(series[index].date))}`; }).join(''); const coords = series .map((point, index) => `${xAt(index).toFixed(1)},${yAt(point.value).toFixed(1)}`) .join(' '); const rising = values[values.length - 1] >= values[0]; return `` + `${grid}` + `${xLabels}` + ``; } /** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */ function normalizedPct(points) { const cleaned = points .map((point) => ({ date: String(point.nav_date), value: num(point.nav) })) .filter((point) => point.value !== null); if (cleaned.length < 2) return []; const base = cleaned[0].value; if (!base) return []; return cleaned.map((point) => ({ date: point.date, value: (point.value / base - 1) * 100 })); } /** 等权组合:各基金按共同交易日对齐后取均值。 */ function equalWeightSeries(navsByCode) { const series = Object.values(navsByCode) .map((points) => normalizedPct(points)) .filter((pts) => pts.length >= 2); if (series.length < 2) return []; let common = new Set(series[0].map((point) => point.date)); series.slice(1).forEach((pts) => { const dates = new Set(pts.map((point) => point.date)); common = new Set([...common].filter((date) => dates.has(date))); }); const dates = [...common].sort(); if (dates.length < 2) return []; return dates.map((date) => ({ date, value: series.reduce((sum, pts) => { const hit = pts.find((point) => point.date === date); return sum + (hit ? hit.value : 0); }, 0) / series.length, })); } function maxDrawdownPct(series) { let peak = -Infinity; let worst = 0; series.forEach((point) => { peak = Math.max(peak, point.value); worst = Math.min(worst, point.value - peak); }); return worst; } // ---- 环形图(资产配置比例) ---- function donutSvg(entries) { const total = entries.reduce((sum, entry) => sum + entry.weight, 0) || 1; const radius = 54; const circumference = 2 * Math.PI * radius; let offset = 0; const slices = entries.map((entry, index) => { const length = (entry.weight / total) * circumference; const slice = ``; offset += length; return slice; }).join(''); return `` + slices + '等权组合' + `${entries.length} 只` + ''; } // ---- 方案骨架 ---- function riskOf(product) { const evidence = product.recommendation_evidence_card || {}; return ((evidence.suitability || {}).risk_level) || product.risk_level || '--'; } function cardHtml(product) { const code = String(product.product_code || ''); return '
' + '
' + `

${escapeHtml(product.product_name || '--')}

` + `

${escapeHtml(code)}` + ` · ${escapeHtml(product.product_category || '--')}

` + '
' + `${escapeHtml(riskOf(product))}` + `评分 ${escapeHtml(String(product.score ?? '--'))}` + '
' + `
` + '

走势加载中…

' + `
` + metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--')) + '
' + `
    ` // 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。 + '

    推荐依据' + (product.reason_source === 'llm' ? 'AI 生成' : '规则生成') + `${escapeHtml(product.reason || '--')}

    ` + '
    '; } /** 同步骨架:只用方案自带字段,行情/净值随后由 `hydratePlanView` 补。 */ export function renderPlanProducts(products) { if (!products.length) return ''; const codes = products.map((product) => String(product.product_code || '')).join(','); const entries = products.map((product) => ({ label: product.product_name || product.product_code || '--', code: String(product.product_code || ''), weight: 1, })); const legend = entries.map((entry, index) => { const share = Math.round((entry.weight / entries.length) * 100); return `
  • ` + `${escapeHtml(entry.label)}` + `${escapeHtml(entry.code)}` + `${share}%
  • `; }).join(''); return `
    ` + '
    ' + '

    组合业绩(等权合成)

    ' + '
    ' + '
    ' + '

    组合走势加载中…

    ' + '
    ' + '
    ' + '

    资产配置比例(等权)

    ' + '
    ' + `
    ${donutSvg(entries)}
    ` + `
      ${legend}
    ` + '
    ' + `
    ${products.map(cardHtml).join('')}
    ` + '
    '; } // ---- 注解式补水 ---- async function fetchNav(apiClient, code) { try { const response = await apiClient.get('P002', { pathParams: { productCode: code }, query: { days: SPARK_DAYS }, }); const points = response?.data?.points; return Array.isArray(points) ? points : []; } catch { // 单只基金取不到净值不该拖垮整块:留空,卡片显示“暂无净值数据”。 return []; } } function fillCardProducts(products) { return new Map(products.map((product) => [String(product.product_code || ''), product])); } function enrichCard(block, code, product, facts, points) { const chartNode = block.querySelector(`[data-plan-chart="${code}"]`); const metricsNode = block.querySelector(`[data-plan-metrics="${code}"]`); const highlightsNode = block.querySelector(`[data-plan-highlights="${code}"]`); const values = navValues(points); if (chartNode) { const series = points .map((point) => ({ date: String(point.nav_date), value: num(point.nav) })) .filter((point) => point.value !== null); chartNode.innerHTML = series.length >= 2 ? lineChartSvg(series, { formatter: (value) => value.toFixed(3), ariaLabel: `${product?.product_name || code} 近 ${series.length} 个交易日净值走势`, }) : '

    暂无净值数据' + '(可运行 tools/sync_nav_history.py 同步)

    '; } if (metricsNode) { const latestNav = values.length ? values[values.length - 1] : num(facts?.current_nav); const rows = [ metric('最新净值', latestNav === null ? '--' : latestNav.toFixed(4)), metric('当日涨跌', pctText(facts?.change_pct), pctClass(facts?.change_pct)), ]; INTERVALS.forEach(([label, days]) => { const change = intervalChange(values, days); rows.push(metric(label, pctText(change), pctClass(change))); }); rows.push(metric('基金经理', facts?.fund_manager || '--')); const fee = num(facts?.management_fee_rate); // `management_fee_rate` 在库里可能是 NULL 或 0(未维护)——显示成「0.00%/年」 // 会被读成"这只基金不要管理费",与事实相反,所以按"没数据"处理。 rows.push(metric('管理费', fee === null || fee <= 0 ? '--' : `${(fee * 100).toFixed(2)}%/年`)); metricsNode.innerHTML = rows.join(''); } if (highlightsNode) { const items = []; const c60 = intervalChange(values, 60); const c20 = intervalChange(values, 20); if (c60 !== null) items.push(`近 60 个交易日区间收益 ${pctText(c60)}`); if (c20 !== null) items.push(`近 20 日区间收益 ${pctText(c20)}`); if (values.length >= 2) { items.push(`区间最大回撤 ${pctText(Math.min(0, maxDrawdownPct( values.map((value) => ({ date: '', value })), )))}`); } items.push(`风险等级 ${riskOf(product)},已按你的风险测评与投资期限做匹配校验`); if (facts?.fund_manager) items.push(`基金经理 ${facts.fund_manager}`); highlightsNode.innerHTML = items.map((text) => `
  • ${escapeHtml(text)}
  • `).join(''); } } function fillCombo(block, navsByCode) { const chartNode = block.querySelector('[data-plan-combo]'); const statsNode = block.querySelector('[data-plan-combo-stats]'); const series = equalWeightSeries(navsByCode); if (!chartNode) return; if (series.length < 2) { chartNode.innerHTML = '

    成分基金净值不足,' + '暂无法合成组合走势。

    '; return; } const total = series[series.length - 1].value; const drawdown = maxDrawdownPct(series); chartNode.innerHTML = lineChartSvg(series, { formatter: (value) => `${value.toFixed(1)}%`, ariaLabel: `等权组合近 ${series.length} 个交易日累计收益走势`, }); if (statsNode) { statsNode.innerHTML = metric('区间收益', pctText(total), pctClass(total)) + metric('最大回撤', pctText(drawdown), pctClass(drawdown)) + metric('成分基金', `${Object.keys(navsByCode).length} 只`) + metric('区间', `${series[0].date} ~ ${series[series.length - 1].date}`); } } /** * 注解式补水:取 P001(一次,含全部产品)+ P002(每只一次), * 填组合走势、卡片折线图、指标与看点。任何一步失败都只降级为「暂无数据」,不抛错。 */ export async function hydratePlanView(root, apiClient, products = []) { const block = root?.querySelector?.('[data-plan-view]'); if (!block || typeof apiClient?.get !== 'function') return; const codes = String(block.dataset.codes || '').split(',').filter(Boolean); if (!codes.length) return; const byCode = fillCardProducts(products); const productsByCode = new Map(); try { const response = await apiClient.get('P001'); (response?.data?.products || []).forEach((item) => { productsByCode.set(String(item.product_code), item); }); } catch { // 产品清单取不到:仍能画净值走势,只是基金经理/费率显示 `--`。 } const navs = await Promise.all(codes.map((code) => fetchNav(apiClient, code))); const navsByCode = {}; codes.forEach((code, index) => { navsByCode[code] = navs[index] || []; }); fillCombo(block, navsByCode); codes.forEach((code, index) => { enrichCard(block, code, byCode.get(code), productsByCode.get(code), navs[index] || []); }); }