Files
group_fqcd_jr/app/static/portal/common/advisor-plan-view.js
T
张胜宇 9675df8453 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)
- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 21:26:30 +08:00

398 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。
//
// 为什么单独抽一个模块:这两处展示的是同一份 `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 `<div class="advisor-plan__metric"><dt>${escapeHtml(label)}</dt>`
+ `<dd class="${className}">${escapeHtml(value)}</dd></div>`;
}
// ---- 折线图(带坐标轴/网格;内联 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 `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}"/>`
+ `<text x="${left - 8}" y="${(Number(y) + 3.5).toFixed(1)}" text-anchor="end">`
+ `${escapeHtml(formatter(value))}</text>`;
}).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 `<text x="${xAt(index).toFixed(1)}" y="${height - 9}" text-anchor="${anchor}">`
+ `${escapeHtml(shortDate(series[index].date))}</text>`;
}).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 `<svg class="advisor-plan__chart-svg" viewBox="0 0 ${width} ${height}" role="img"`
+ ` aria-label="${escapeHtml(ariaLabel)}">`
+ `<g class="advisor-plan__chart-grid">${grid}</g>`
+ `<g class="advisor-plan__axis">${xLabels}</g>`
+ `<polyline class="advisor-plan__line advisor-plan__line--${rising ? 'up' : 'down'}"`
+ ` points="${coords}"/></svg>`;
}
/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */
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 = `<circle class="advisor-plan__slice" cx="70" cy="70" r="${radius}"`
+ ` stroke="${SLICE_COLORS[index % SLICE_COLORS.length]}"`
+ ` stroke-dasharray="${length.toFixed(2)} ${(circumference - length).toFixed(2)}"`
+ ` stroke-dashoffset="${(-offset).toFixed(2)}" transform="rotate(-90 70 70)"/>`;
offset += length;
return slice;
}).join('');
return `<svg class="advisor-plan__donut" viewBox="0 0 140 140" role="img" aria-label="组合配置比例">`
+ slices
+ '<text class="advisor-plan__donut-title" x="70" y="66" text-anchor="middle">等权组合</text>'
+ `<text class="advisor-plan__donut-sub" x="70" y="84" text-anchor="middle">${entries.length} 只</text>`
+ '</svg>';
}
// ---- 方案骨架 ----
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 '<article class="advisor-plan__card" data-plan-card="' + escapeHtml(code) + '">'
+ '<header class="advisor-plan__head">'
+ `<div><h4 class="advisor-plan__name">${escapeHtml(product.product_name || '--')}</h4>`
+ `<p class="advisor-plan__sub">${escapeHtml(code)}`
+ ` · ${escapeHtml(product.product_category || '--')}</p></div>`
+ '<div class="advisor-plan__badges">'
+ `<span class="advisor-plan__risk">${escapeHtml(riskOf(product))}</span>`
+ `<span class="advisor-plan__score">评分 ${escapeHtml(String(product.score ?? '--'))}</span>`
+ '</div></header>'
+ `<div class="advisor-plan__chart" data-plan-chart="${escapeHtml(code)}">`
+ '<p class="advisor-plan__chart-pending">走势加载中…</p></div>'
+ `<dl class="advisor-plan__metrics" data-plan-metrics="${escapeHtml(code)}">`
+ metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--'))
+ '</dl>'
+ `<ul class="advisor-plan__highlights" data-plan-highlights="${escapeHtml(code)}"></ul>`
// 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。
+ '<p class="advisor-plan__reason"><strong>推荐依据'
+ (product.reason_source === 'llm'
? '<span class="advisor-plan__tag">AI 生成</span>'
: '<span class="advisor-plan__tag advisor-plan__tag--rule">规则生成</span>')
+ `</strong>${escapeHtml(product.reason || '--')}</p>`
+ '</article>';
}
/** 同步骨架:只用方案自带字段,行情/净值随后由 `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 `<li><i style="background:${SLICE_COLORS[index % SLICE_COLORS.length]}"></i>`
+ `<span class="advisor-plan__legend-name">${escapeHtml(entry.label)}</span>`
+ `<span class="advisor-plan__legend-code">${escapeHtml(entry.code)}</span>`
+ `<b>${share}%</b></li>`;
}).join('');
return `<div class="advisor-plan" data-plan-view data-codes="${escapeHtml(codes)}">`
+ '<section class="advisor-plan__block">'
+ '<h4>组合业绩(等权合成)</h4>'
+ '<div class="advisor-plan__stats" data-plan-combo-stats></div>'
+ '<div class="advisor-plan__chart" data-plan-combo>'
+ '<p class="advisor-plan__chart-pending">组合走势加载中…</p></div>'
+ '</section>'
+ '<section class="advisor-plan__block">'
+ '<h4>资产配置比例(等权)</h4>'
+ '<div class="advisor-plan__config">'
+ `<div class="advisor-plan__donut-wrap">${donutSvg(entries)}</div>`
+ `<ul class="advisor-plan__legend">${legend}</ul>`
+ '</div></section>'
+ `<div class="advisor-plan__grid">${products.map(cardHtml).join('')}</div>`
+ '</div>';
}
// ---- 注解式补水 ----
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} 个交易日净值走势`,
})
: '<p class="advisor-plan__chart-empty">暂无净值数据'
+ '(可运行 tools/sync_nav_history.py 同步)</p>';
}
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) => `<li>${escapeHtml(text)}</li>`).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 = '<p class="advisor-plan__chart-empty">成分基金净值不足,'
+ '暂无法合成组合走势。</p>';
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] || []);
});
}