// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。
//
// 为什么单独抽一个模块:这两处展示的是同一份 `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 ``;
}
/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */
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 `';
}
// ---- 方案骨架 ----
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 ''
+ ''
+ `'
+ ``
+ 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)}
`
+ `
`
+ '
'
+ `
${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] || []);
});
}