Files
group_fqcd_jr/app/static/portal/common/layout/app-shell.js
T
张胜宇 e5b4d02b0d merge: 集成投顾组 3 个提交(解除与「投顾模块清除」的冲突)+ 客服 Agent 重构收口
## 为什么要合并
远端 `origin/qyqy_develop` 领先 3 个提交(`5607751` / `2fe7d0c` / `74b7d00`:投顾需求与架构文档、
客户主动申报投顾方案 + 受理自动出草稿、方案交付落点与推荐依据 LLM 增强),而本地 `5d0becb`
按 `D4.4` / `D4.5`(CS-PURGE-2026-012/013)把投顾模块整体清除了。**两个目标不可兼得**:
远端新代码反向 import 已被清除的模块(`app.model.investment_goal`、
`app.service.product_recommendation_service`、`app.service.advisor_rollout_service`),
强行推进只会让两边都跑不起来。

**裁定:投顾组的新功能 > 本地的投顾清除。** 依据是 `D4.4` §0-②③ 自己写下的风险
——按名字清投顾会同时拆掉产品数据底座与 MVP 硬阻断,并失去"改 6 个底座文件时的对照组"。
本次合并因此**恢复投顾模块**;就代码面而言,`D4.4` / `D4.5` 的清除结果被本次合并取代
(留痕见 `开发文档\D1.6` §4.37)。

## 冲突怎么解的(12 处)
- **8 处 modify/delete 取远端**:`recommendations.py` / `product_recommendation_service.py` /
  `employee-advisor/dashboard/{actions-module.js,dashboard.css,dashboard.js,index.html}` /
  `tools/{check_portal_modules.py,grant_advisor_role.py}` —— 即"我删、远端改",保留投顾文件。
- **3 处内容冲突取远端**:`app/main.py`(投顾 import 与 `include_router`)、
  `common/api-client.js`(投顾端点表)、`tests/unit/api/test_portal_frontend.py`(4 条投顾前端契约)。
- **1 处取远端 + 保留我方**:`app/main.py` 解除冲突的同时,保留本轮的
  `/customer-service-test` 挂载移除(该联调页与用例已随重构作废)。

## 因"取消清除"而必须回滚的语义改动(否则恢复出来的投顾代码跑不动)
- `app/service/agent/bootstrap.py`:恢复 `AdvisorAgent` 与 5 个投顾工具注册
  (`query_investment_goal` / `analyze_portfolio` / `generate_asset_allocation` /
  `recommend_products` / `compare_products`);客服 Agent 注释按本轮口径保留。
- `app/core/config.py`:恢复 `advisor_rollout_enabled` / `advisor_rollout_customer_ids`。
- `app/static/portal/common/layout/app-shell.js`:恢复投顾工作台导航与 `advisor` 角色名。
- `tools/seed_test_rbac.py`:恢复"admin 取全量元组"的授权模型(保留远端新增的
  9070-9074 权限码与客户侧 9071/9072 绑定)。
- `tools/portal_api_check.py`:恢复投顾实测用例(AD003/AD005/AD011/A047 与 `advisor_t` 登录),
  并**新增判定**:被渲染的集合为空(0 条)时判 `SKIP` 而不是 `FAIL`
  —— "没有行"与"字段没带"是两回事,混报会把排查方向带偏。
- `app/static/portal/common/api-client.js`:以远端为基准,重新叠加本轮的
  **访客令牌 `Authorization` 优先**修复(浮窗访客身份稳定性)。

## 数据库夹具同步(代码恢复 ⇒ 夹具也要恢复)
- `tools/grant_advisor_role.py`:新建 `advisor` 角色并授权(实测 34 项权限)。
- `tools/create_test_user.py --id 9020 --username advisor_t --role advisor`:重建演示账号。

## 集成期发现并修掉的过期断言
- `tests/unit/test_advisor_migration_contract.py`:alembic 末端钉死值仍是
  `20260914_baseline_auto_increment`,而远端新增了 `20260916_advisor_service_request`
  ⇒ 这条断言**在远端分支上本身就是红的**。本次把它更新到新末端并补了注释。

## 验证(本机实测)
| 门禁 | 结果 |
|---|---|
| `pytest -q`(全量,含集成) | **1909 passed / 3 skipped / 0 failed** |
| `ruff check app tools tests` | 20(远端分支 22,本地仅客服线基线 19) |
| `mypy app` | 2(= 既有基线) |
| `tools/portal_api_check.py` | 40 项:通过 35 / 失败 0 / 跳过 5 |
| `tools/e2e_smoke_test.py --read-only` | 31/31 |
| `_eval_harness/http_probe.py` | 11/11 succeeded |
| `_consistency.py` | GATE PASS |
| `_fe_boundary_http.py`(前端入参边界真机) | 全部符合预期 |

## 未做(如实登记)
- **投顾演示数据未灌**:`AD011` / `A047` 需要 `advisor_product_suitability_reference`
  这类带 `source_url` + `document_sha256` 的证据行,而披露文件不在仓库里;
  `tools/seed_advisor_demo.py` 明确"不编证据"(fail closed),故这两条按空集 SKIP。
- **客服线文档目录仍未入库**:`客服agent/`、`开发文档/`(权威副本在本机)与
  `_chunks_report.txt`(本地构建产物)依旧排除在提交之外。
2026-09-20 14:52:35 +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);
}