Files
group_fqcd_jr/app/static/portal/common/layout/app-shell.js
T
lzf_0626 e6d74059f2 修复员工工作台顶部导航重复(入口 JS 被引两次)
## 现象

打开 `employee-console/workspace`(平台治理),页面上出现**两份一模一样的顶部栏**:
南方财富 / 模拟基金服务 / 平台治理 / 风控中心 / admin_t 管理员 —— 连同页脚一起各两份。

## 根因:同一个入口 JS 被引了两次,且 `?v=` 不同

`app/static/portal/employee-console/workspace/index.html` 里曾同时存在:

    <script type="module" src=".../workspace.js?v=20260913-3"></script>
    <script type="module" src=".../workspace.js?v=20260914"></script>

浏览器按**完整 URL** 去重,两条不同 query 被当成**两个模块**、**各执行一次**。
入口里的 `mountShell()` 因此跑了两次,而它当时是
`document.body.insertAdjacentHTML('afterbegin', ...)` —— **无条件插入**,
于是 header 与 footer 各插两份。

来源是合并事故(`git blame` 定位):

| 行 | 提交 | 作者 |
|---|---|---|
| 旧 | `e31420df` | 卿云秋月(把版本号改成 `20260913-3`)|
| 新 | `f5d1b246` | 张胜宇(把版本号改成 `20260914`)|

两人各自把**同一行**的版本号换成新的,合并时两边都被保留,成了两行。
那个提交的信息是 "merge ... and retain risk review updates" ——
"retain" 在这里保留错了地方。

## 修法(两侧都堵)

1. **HTML 收敛成一行**(保留较新的 `?v=20260914`,与 `workspace.js` 内部
   `api-client.js?v=20260914` 一致),并就地写明"改版本号是替换这一行、不是新增一行"。
2. **`mountShell` 加幂等保护**:已有 `.site-header` 就直接 return。
   之所以不满足于只修那个 HTML —— 这个 bug 的症状很难反推到原因
   (页面看起来只是"多了一块"),而以后谁加缓存版本号时很容易再犯一次。

## 防回归(两条测试,都做过负面验证)

- `test_no_portal_page_includes_the_same_script_twice`:扫 `app/static/portal` 下
  **19 个页面**,把 `<script src>` 去掉 query 后比对,同一入口出现多次即失败。
  负面验证:把重复行临时放回去,测试**精确报出**
  `employee-console\workspace\index.html: ['/static/portal/employee-console/workspace/workspace.js']`,
  恢复后通过。
- `test_mount_shell_is_idempotent`:断言 `app-shell.js` 里有那句幂等判断。

全站扫描确认**只有这一处**,不是批量问题。

## 实测

- `GET /portal/employee-console/workspace/` -> 200,页面里 `workspace.js` **只出现一次**
  (`?v=20260914`);静态 HTML 中 `site-header` 出现 **0 次**(确认由 JS 注入,
  所以 JS 执行一次就只插一份)
- `pytest tests/unit/api/test_portal_frontend.py` -> **43 passed**(41 + 新增 2 条)
- `ruff check` -> All checks passed

## 一点说明

这次是"改同一个版本号"的合并冲突处理失误,属于**流程问题**而非个人疏忽:
两边都想把缓存版本号推新,冲突解决时很容易两边都留下。
测试补上之后,这类错误会在 `pytest tests/unit` 里当场暴露。
2026-09-14 12:06:38 +08:00

184 lines
9.6 KiB
JavaScript

import {
clearAuthSession,
getAuthContext,
startAuthSync,
switchAccount,
} from '/static/portal/common/auth.js?v=20260913';
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/'],
['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-dashboard', '运营工作台', '/portal/employee-operations/dashboard/'],
['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);
}
}