交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口
可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义
推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)
数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)
测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
398 lines
17 KiB
JavaScript
398 lines
17 KiB
JavaScript
// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。
|
||
//
|
||
// 为什么单独抽一个模块:这两处展示的是同一份 `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] || []);
|
||
});
|
||
}
|