Files
group_fqcd_jr/app/static/portal/common/layout/app-shell.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

203 lines
10 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.
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/'],
];
//: 需要在右下角挂客服浮窗的页面形态。
//:
//: **白名单而不是黑名单**:员工四类工作台(risk / advisor / operator / admin)
//: 走的是各自的业务 Agent,挂上客服浮窗只会让"当前账号能不能用这个入口"
//: 变成一道需要解释的问题。新增页面形态时默认**不挂**,要挂就显式加进来。
const CUSTOMER_SERVICE_MODES = Object.freeze(['public', 'customer']);
const WIDGET_STYLESHEET = '/static/portal/common/customer-service-widget/widget.css?v=20260919';
/**
* 挂载客服浮窗(幂等)。
*
* 样式用 `<link>` 注入而不是写进每个页面的 HTML:浮窗挂在 9 个页面上,
* 写 9 份就意味着 9 处 `?v=` 版本号要一起改,漏一个就是"某个页面样式陈旧"。
*/
function mountCustomerServiceFor(mode) {
if (!CUSTOMER_SERVICE_MODES.includes(mode)) return;
if (!document.querySelector('[data-cs-widget-styles]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = WIDGET_STYLESHEET;
link.dataset.csWidgetStyles = '';
document.head.appendChild(link);
}
mountCustomerServiceWidget(mode);
}
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'] });
mountCustomerServiceFor(mode);
}