Files
group_fqcd_jr/app/static/portal/common/layout/app-shell.js
T
Windows 74b7d00dff feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 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;补「两处共用同一渲染」回归测试
2026-09-16 18:17:47 +08:00

184 lines
9.6 KiB
JavaScript

import {
clearAuthSession,
getAuthContext,
startAuthSync,
switchAccount,
} from '/static/portal/common/auth.js?v=20260916';
import { escapeHtml } from '/static/portal/common/formatters.js';
import { mountCustomerServiceWidget } from '/static/portal/common/customer-service-widget/widget.js';
const PUBLIC_LINKS = [
['home', '首页', '/portal/guest/home/'],
['products', '基金产品', '/portal/guest/products/'],
['ranking', '基金排行', '/portal/guest/products/?view=ranking'],
];
const CUSTOMER_LINKS = [
['dashboard', '资产总览', '/portal/customer/dashboard/'],
['advisor-plans', '我的投顾方案', '/portal/customer/advisor-plans/'],
['holdings', '我的持仓', '/portal/customer/holdings/'],
['profit-loss', '收益明细', '/portal/customer/profit-loss/'],
['orders', '交易记录', '/portal/customer/orders/'],
['transactions', '成交明细', '/portal/customer/transactions/'],
['risk-questionnaire', '风险测评', '/portal/customer/risk-questionnaire/'],
];
const RISK_LINKS = [
['risk-dashboard', '风险工作台', '/portal/employee-risk/dashboard/'],
];
const ADVISOR_LINKS = [
['advisor-dashboard', '投顾工作台', '/portal/employee-advisor/dashboard/'],
['public-products', '公开产品', '/portal/guest/products/'],
];
const OPERATOR_LINKS = [
['operator-offsite', '场外申赎', '/portal/employee-operations/offsite/'],
['operator-promotion', '推介材料', '/portal/employee-operations/promotion/'],
['operator-nl2sql', 'NL2SQL', '/portal/employee-operations/nl2sql/'],
['public-products', '公开产品', '/portal/guest/products/'],
];
const ADMIN_LINKS = [
['admin-workspace', '平台治理', '/portal/employee-console/workspace/'],
['risk-dashboard', '风控中心', '/portal/employee-risk/dashboard/'],
];
const ROLE_LABELS = Object.freeze({
customer: '客户账户',
risk_operator: '风控人员',
operator: '运营人员',
advisor: '投顾人员',
admin: '管理员',
super_admin: '超级管理员',
});
function accountActions(context) {
const username = escapeHtml(context.username || '已登录用户');
const role = context.roles.map((item) => ROLE_LABELS[item]).find(Boolean) || '已认证账户';
return `<div class="account-menu" data-account-menu><button class="account-menu__trigger" type="button" aria-haspopup="menu" aria-expanded="false" data-account-toggle><span class="account-menu__identity"><strong>${username}</strong><span>${escapeHtml(role)}</span></span><span class="account-menu__indicator" aria-hidden="true">⌄</span></button><div class="account-menu__popover" role="menu" data-account-popover><button class="account-menu__action" type="button" role="menuitem" data-switch-account>切换账号</button><button class="account-menu__action account-menu__action--danger" type="button" role="menuitem" data-logout>退出登录</button></div></div>`;
}
function headerMarkup(links, active, context, mode) {
const nav = links.map(([key, label, href]) => `<a class="site-header__link${key === active ? ' site-header__link--active' : ''}" href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</a>`).join('');
const actions = context
? accountActions(context)
: '<a class="button button--quiet" href="/portal/employee-console/login/">员工入口</a><a class="button button--primary" href="/portal/customer/login/">登录</a>';
const menuButton = mode === 'customer' ? '' : '<button class="site-header__menu-button" type="button" aria-label="展开导航" aria-expanded="false" data-menu>菜单</button>';
return `<a class="skip-link" href="#main-content">跳到主要内容</a><header class="site-header"><div class="site-header__inner"><a class="site-header__brand" href="/portal/guest/home/"><img class="site-header__mark" src="/static/portal/common/layout/brandmark.svg" alt=""><span class="site-header__brand-copy"><strong class="site-header__brand-name">南方财富</strong><span class="site-header__brand-subtitle">模拟基金服务</span></span></a>${menuButton}<nav class="site-header__nav" aria-label="主导航" data-nav>${nav}</nav><div class="site-header__actions">${actions}</div></div></header>`;
}
function footerMarkup() {
return '<footer class="site-footer"><div class="site-footer__inner"><span>基金投资需谨慎。本系统仅提供场内基金模拟交易,不构成投资建议。</span><span>数据截至页面标注时间</span></div></footer><div class="network-status" role="status" data-network-status></div><div class="trace-watermark" data-trace-watermark></div>';
}
export function mountShell({ active, mode = 'public' }) {
// ⚠️ 幂等保护:同一个页面被挂两次 shell,就会看到**两份一模一样的顶部导航与页脚**。
//
// 已知的触发方式只有一种:HTML 里引了两条入口 <script type="module">,且 `?v=` 不同。
// 浏览器按**完整 URL** 去重,两条会被当成两个模块、**各执行一次** ——
// 2026-09-14 的 `employee-console/workspace/index.html` 正是如此
// (合并时把两个分支各自改的版本号都保留下来,成了两行)。
//
// 那次已经把那两行收敛成一行;这里再挡一道,理由是这个错误**从症状上很难联想到原因**
// (页面看起来只是"多了一块"),而且以后别人加缓存版本号时很容易再犯一次。
if (document.querySelector('.site-header')) return;
startAuthSync();
document.body.classList.add(`portal-${mode}`);
const context = getAuthContext();
const links = mode === 'customer'
? CUSTOMER_LINKS
: mode === 'risk'
? RISK_LINKS
: mode === 'advisor'
? ADVISOR_LINKS
: mode === 'operator'
? OPERATOR_LINKS
: mode === 'admin'
? ADMIN_LINKS
: PUBLIC_LINKS;
document.body.insertAdjacentHTML('afterbegin', headerMarkup(links, active, context, mode));
document.body.insertAdjacentHTML('beforeend', footerMarkup());
const menu = document.querySelector('[data-menu]');
const nav = document.querySelector('[data-nav]');
menu?.addEventListener('click', () => {
const open = nav.classList.toggle('site-header__nav--open');
menu.setAttribute('aria-expanded', String(open));
});
const accountMenu = document.querySelector('[data-account-menu]');
const accountToggle = document.querySelector('[data-account-toggle]');
const accountPopover = document.querySelector('[data-account-popover]');
const closeAccountMenu = () => {
accountMenu?.classList.remove('account-menu--open');
accountToggle?.setAttribute('aria-expanded', 'false');
};
accountToggle?.addEventListener('click', () => {
const open = accountMenu.classList.toggle('account-menu--open');
accountToggle.setAttribute('aria-expanded', String(open));
if (open) accountPopover?.querySelector('[role="menuitem"]')?.focus();
});
document.addEventListener('click', (event) => {
if (accountMenu && !accountMenu.contains(event.target)) closeAccountMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeAccountMenu();
accountToggle?.focus();
}
});
document.querySelector('[data-switch-account]')?.addEventListener('click', () => {
switchAccount(mode === 'customer' ? 'customer' : 'staff');
});
document.querySelector('[data-logout]')?.addEventListener('click', () => {
clearAuthSession();
window.location.assign('/portal/guest/home/?reason=signed-out');
});
const networkStatus = document.querySelector('[data-network-status]');
let networkStatusTimer = 0;
const hideNetworkStatus = () => {
window.clearTimeout(networkStatusTimer);
networkStatus.classList.remove('network-status--visible');
};
const showNetworkStatus = (message, duration = 3600) => {
window.clearTimeout(networkStatusTimer);
networkStatus.textContent = message;
networkStatus.classList.add('network-status--visible');
if (duration > 0) networkStatusTimer = window.setTimeout(hideNetworkStatus, duration);
};
window.addEventListener('offline', () => {
showNetworkStatus('网络已断开', 0);
});
window.addEventListener('online', () => {
showNetworkStatus('网络已恢复,请点击刷新', 3000);
});
window.addEventListener('portal:error', (event) => {
const detail = event.detail || {};
showNetworkStatus(detail.message || '请求未完成');
});
window.addEventListener('portal:auth-expired', () => {
networkStatus.textContent = '登录已过期,请重新登录';
networkStatus.classList.add('network-status--visible');
});
if (new URLSearchParams(window.location.search).get('reason') === 'signed-out') {
networkStatus.textContent = '您已安全退出登录';
networkStatus.classList.add('network-status--visible');
window.setTimeout(() => networkStatus.classList.remove('network-status--visible'), 3200);
window.history.replaceState({}, '', window.location.pathname);
}
const observer = new MutationObserver(() => {
const traceId = document.documentElement.dataset.traceId || '';
const watermark = document.querySelector('[data-trace-watermark]');
if (watermark) watermark.textContent = traceId ? `追踪标识 ${traceId}` : '';
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-trace-id'] });
const isCustomerContext = context?.roles?.includes('customer');
const canUseCustomerService = mode === 'customer' || mode === 'public' && (!context || isCustomerContext);
if (canUseCustomerService) {
const style = document.createElement('link');
style.rel = 'stylesheet'; style.href = '/static/portal/common/customer-service-widget/widget.css?v=20260913';
document.head.appendChild(style);
mountCustomerServiceWidget(isCustomerContext ? 'customer' : mode);
}
}