Files
group_xinghuo_jinrong/docs/course/jinrong-module-frontend/index.html
T

437 lines
24 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>前端 web 深潜 · 交互导览</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400;1,9..40,500&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<style>
:root {
--color-accent: #132B3A;
--color-accent-hover: #0f2230;
--color-accent-light: #E8EEF1;
--color-accent-muted: #5a6b75;
}
</style>
<script src="main.js" defer></script>
</head>
<body>
<nav class="nav" id="nav">
<div class="progress-bar" id="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
<div class="nav-inner">
<span class="nav-title">前端 web 深潜</span>
<div class="nav-dots" id="nav-dots" role="tablist">
<button class="nav-dot" data-target="module-1" data-tooltip="四角色路由" role="tab" aria-label="模块 1: 四角色路由"></button>
<button class="nav-dot" data-target="module-2" data-tooltip="Chat 鉴权" role="tab" aria-label="模块 2: Chat 鉴权"></button>
<button class="nav-dot" data-target="module-3" data-tooltip="本地调试" role="tab" aria-label="模块 3: 本地调试"></button>
<button class="nav-dot" data-target="module-4" data-tooltip="未接线" role="tab" aria-label="模块 4: 未接线"></button>
</div>
</div>
</nav>
<main id="main">
<section class="module" id="module-1">
<div class="module-inner">
<p class="eyebrow animate-in">模块 1 · 路由与登录</p>
<h1 class="module-title animate-in">四角色路由、HashRouter<br>与 Demo 账号</h1>
<p class="module-lead animate-in">
前端在 <code>web/</code>,用 React +
<span class="term" data-definition="HashRouter = URL 里 # 后面的路径由浏览器本地切换,不请求服务器新页面,适合静态部署。">HashRouter</span>
做四角色工作台。登录后按角色跳进不同「家」——改路由时先对 <code>App.tsx</code> 和 <code>demoAccounts.ts</code>。
</p>
<div class="screen animate-in">
<h2>四角色默认入口</h2>
<div class="badge-list">
<div class="badge-item"><span class="badge-code">CUST-9527</span><span class="badge-desc">客户 → /app/customer/home</span></div>
<div class="badge-item"><span class="badge-code">STAFF-10086</span><span class="badge-desc">理财师 → /app/advisor/home</span></div>
<div class="badge-item"><span class="badge-code">STAFF-20001</span><span class="badge-desc">分析员 → /app/analyst/home</span></div>
<div class="badge-item"><span class="badge-code">STAFF-30001</span><span class="badge-desc">风控 → /app/risk/home</span></div>
</div>
<p>登录页一键 Demo:<code>LoginPage</code> 调 <code>POST /api/auth/login</code> 拿 JWT,<code>saveAuth</code> 写入 localStorage,再 <code>navigate(defaultRoute)</code>。</p>
</div>
<div class="screen animate-in">
<h2>路由树(<code>App.tsx</code>)</h2>
<div class="pattern-cards">
<div class="pattern-card">
<h3>customer/*</h3>
<p>home、profile、holdings、trades、chat。持仓/流水走平台 API,对话走 ChatPanel。</p>
</div>
<div class="pattern-card">
<h3>advisor/*</h3>
<p>home、customers、chat。顾问助手 SSE,可指定 customer_id 查名下客户。</p>
</div>
<div class="pattern-card">
<h3>analyst/*</h3>
<p>home、analytics/query(问数)、analytics/assets、analytics/chat。问数走 /api/analyst/*。</p>
</div>
<div class="pattern-card">
<h3>risk/*</h3>
<p>home、alerts、simulate、chat。台账 REST + 风控对话,JWT 须带 risk 角色。</p>
</div>
</div>
<div class="callout callout-accent">
<strong>HashRouter 为什么?</strong> 构建产物是纯静态文件,<code>#/app/customer/home</code> 不依赖服务端路由重写,双击 <code>dist/index.html</code> 也能跑(API 仍要 proxy 或同域)。
</div>
</div>
<div class="screen animate-in">
<div class="translation-block">
<div class="translation-code">
<span class="translation-label">main.tsx + App.tsx</span>
<pre><code><span class="code-line"><span class="code-comment">// main.tsx</span></span>
<span class="code-line">&lt;HashRouter&gt;</span>
<span class="code-line"> &lt;AppRoutes /&gt;</span>
<span class="code-line">&lt;/HashRouter&gt;</span>
<span class="code-line"></span>
<span class="code-line"><span class="code-comment">// demoAccounts.ts</span></span>
<span class="code-line">defaultRoute: <span class="code-string">'#/app/customer/home'</span></span>
<span class="code-line"></span>
<span class="code-line"><span class="code-comment">// App.tsx · RequireAuth</span></span>
<span class="code-line"><span class="code-keyword">if</span> (!auth) <span class="code-keyword">return</span> &lt;Navigate to=<span class="code-string">"/login"</span> /&gt;</span></code></pre>
</div>
<div class="translation-english">
<span class="translation-label">白话</span>
<div class="translation-lines">
<p class="tl">整站包在 HashRouter 里,路径变化不刷新整页。</p>
<p class="tl">每个 Demo 账号绑好「登录后跳哪」——带 # 前缀,和 Hash 路由一致。</p>
<p class="tl">/app 下子路由都要过 RequireAuth:没 token 踢回登录页。</p>
</div>
</div>
</div>
<div class="quiz-container" id="quiz-frontend-m1">
<div class="quiz-question-block"
data-correct="option-b"
data-explanation-right="STAFF-20001 登录后 defaultRoute 是 #/app/analyst/home,见 demoAccounts.ts。"
data-explanation-wrong="四角色各有独立 home,分析员不会进 customer 或 risk 路径。">
<h3 class="quiz-question">分析员 Demo 登录成功,默认打开哪条路径?</h3>
<div class="quiz-options">
<button class="quiz-option" data-value="option-a" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>/app/customer/home</span>
</button>
<button class="quiz-option" data-value="option-b" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>/app/analyst/home</span>
</button>
<button class="quiz-option" data-value="option-c" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>/app/risk/alerts</span>
</button>
</div>
<div class="quiz-feedback"></div>
</div>
<button class="quiz-check-btn" onclick="checkQuiz('quiz-frontend-m1')">检查答案</button>
<button class="quiz-reset-btn" onclick="resetQuiz('quiz-frontend-m1')">重做</button>
</div>
</div>
</div>
</section>
<section class="module" id="module-2">
<div class="module-inner">
<p class="eyebrow animate-in">模块 2 · 对话与鉴权</p>
<h1 class="module-title animate-in">ChatPanel / useChatPanel:<br>三条 API 线的头不一样</h1>
<p class="module-lead animate-in">
对话 UI 统一用 <code>ChatPanel</code> + <code>useChatPanel</code>,但后端入口分三条:
<code>/api/chat</code>(四 Agent)、<code>/api/analyst/*</code>(问数平台)、风控台账 REST。
指挥 AI 接新页面时,<strong>先对表再写 headers</strong>。
</p>
<div class="screen animate-in">
<h2>鉴权差异对照</h2>
<table style="width:100%; border-collapse:collapse; margin: 1.5rem 0; font-size: 0.95rem;">
<thead><tr><th>前端模块</th><th>API</th><th>Authorization</th><th>X-Agent-Type</th></tr></thead>
<tbody>
<tr><td><code>api/chat.ts</code></td><td>/api/chat, /stream, sessions</td><td>Bearer</td><td><strong>必须</strong>(与 prop agentType 一致)</td></tr>
<tr><td><code>api/analyst.ts</code></td><td>/api/analyst/chat 等</td><td>Bearer(apiFetch)</td><td><strong>不要</strong></td></tr>
<tr><td><code>api/risk.ts</code></td><td>/api/risk/*</td><td>Bearer</td><td><strong>必须 risk</strong></td></tr>
<tr><td>持仓/产品页</td><td>/api/customers/* 等</td><td>Bearer</td><td>不要</td></tr>
</tbody>
</table>
<div class="callout callout-warning">
<strong>真实 bug:</strong> <code>risk.ts</code> 曾漏 <code>X-Agent-Type: risk</code>,JWT 正确也 401。接新风控页先 grep 这个头。
</div>
</div>
<div class="screen animate-in">
<h2>数据流:理财师打开顾问助手</h2>
<div class="flow-animation" data-steps='[
{"highlight":"flow-actor-1","label":"ChatPanel(agentType=advisor, mode=stream)"},
{"highlight":"flow-actor-2","label":"useChatPanel → listChatSessions(token, advisor)","packet":true,"from":"1","to":"2"},
{"highlight":"flow-actor-3","label":"chat.ts:Bearer + X-Agent-Type: advisor","packet":true,"from":"2","to":"3"},
{"highlight":"flow-actor-4","label":"对话 HTTP 入口(chat.py):get_auth_context + 准入矩阵","packet":true,"from":"3","to":"4"},
{"highlight":"flow-actor-1","label":"SSE 流式 delta → UI 逐字显示","packet":true,"from":"4","to":"1"}
]'>
<div class="flow-actors">
<div class="flow-actor" id="flow-actor-1"><span class="flow-actor-icon">💬</span><span>ChatPanel</span></div>
<div class="flow-actor" id="flow-actor-2"><span class="flow-actor-icon">🪝</span><span>useChatPanel</span></div>
<div class="flow-actor" id="flow-actor-3"><span class="flow-actor-icon">📡</span><span>api/chat.ts</span></div>
<div class="flow-actor" id="flow-actor-4"><span class="flow-actor-icon">🚪</span><span>对话 HTTP 入口(chat.py)</span></div>
</div>
<p class="flow-step-label">对比:问数走 analyst.ts,无 X-Agent-Type</p>
<div class="flow-controls">
<button class="btn flow-next-btn">下一步</button>
<button class="btn flow-reset-btn">重来</button>
</div>
</div>
</div>
<div class="screen animate-in">
<div class="translation-block">
<div class="translation-code">
<span class="translation-label">chat.ts vs analyst.ts</span>
<pre><code><span class="code-line"><span class="code-comment">// chat.ts — 对话线</span></span>
<span class="code-line"><span class="code-keyword">function</span> <span class="code-function">chatHeaders</span>(token, agentType) {</span>
<span class="code-line"> <span class="code-keyword">return</span> {</span>
<span class="code-line"> Authorization: <span class="code-string">`Bearer ${token}`</span>,</span>
<span class="code-line"> <span class="code-string">'X-Agent-Type'</span>: agentType,</span>
<span class="code-line"> }</span>
<span class="code-line">}</span>
<span class="code-line"></span>
<span class="code-line"><span class="code-comment">// analyst.ts — 问数平台线</span></span>
<span class="code-line">apiFetch(<span class="code-string">'/api/analyst/chat'</span>, { method: <span class="code-string">'POST'</span>, token, body })</span>
<span class="code-line"><span class="code-comment">// apiFetch 只加 Authorization,不加 X-Agent-Type</span></span></code></pre>
</div>
<div class="translation-english">
<span class="translation-label">白话</span>
<div class="translation-lines">
<p class="tl">Chat 相关请求:token 证明身份,agentType 证明走哪条 Agent 线。</p>
<p class="tl">useChatPanel 把 agentType 从页面 prop 一路传到每次 list/send/close。</p>
<p class="tl">问数用 apiFetch 通用封装,后端走 get_platform_auth_context,不需要 Agent 头。</p>
<p class="tl">customer 线 mode 常用 sync(后端 LangGraph 整图跑完再推);advisor/risk 用 stream SSE。</p>
</div>
</div>
</div>
<div class="quiz-container" id="quiz-frontend-m2">
<div class="quiz-question-block"
data-correct="option-c"
data-explanation-right="风控对话页 ChatPanel agentType=risk,chat.ts 自动带 X-Agent-Type: risk。"
data-explanation-wrong="风控 Chat 走 /api/chat,不是 /api/risk;但 REST 台账才用 risk.ts。">
<h3 class="quiz-question">RiskChatPage 发消息,请求头应长什么样?</h3>
<div class="quiz-options">
<button class="quiz-option" data-value="option-a" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>只要 Bearer,和问数一样</span>
</button>
<button class="quiz-option" data-value="option-b" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>Bearer + X-Agent-Type: analyst</span>
</button>
<button class="quiz-option" data-value="option-c" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>Bearer + X-Agent-Type: risk</span>
</button>
</div>
<div class="quiz-feedback"></div>
</div>
<button class="quiz-check-btn" onclick="checkQuiz('quiz-frontend-m2')">检查答案</button>
<button class="quiz-reset-btn" onclick="resetQuiz('quiz-frontend-m2')">重做</button>
</div>
</div>
</div>
</section>
<section class="module" id="module-3">
<div class="module-inner">
<p class="eyebrow animate-in">模块 3 · 本地调试</p>
<h1 class="module-title animate-in">npm run dev、Vite proxy<br>与 401 排障</h1>
<p class="module-lead animate-in">
前端 dev 服务器跑在 <code>5173</code>,API 在 <code>8000</code>。
<span class="term" data-definition="Vite proxy = 开发时把 /api 请求转发到后端,浏览器以为 API 和页面同域,避免跨域 CORS 麻烦。">Vite proxy</span>
帮你转发——没起后端或头带错,Network 面板一眼 401。
</p>
<div class="screen animate-in">
<h2>启动顺序</h2>
<div class="numbered-steps">
<div class="step-card">
<span class="step-num">1</span>
<div><strong>后端</strong> · 应用入口(<code>main.py</code>):<code>uvicorn app.main:app --reload --port 8000</code>(Core 模拟库 + Redis 6380 按需)</div>
</div>
<div class="step-card">
<span class="step-num">2</span>
<div><strong>前端</strong>:<code>cd web && npm run dev</code> → 打开 <code>http://localhost:5173</code></div>
</div>
<div class="step-card">
<span class="step-num">3</span>
<div><strong>登录</strong>:点 Demo 账号 → 看 localStorage 里 token → 再点各角色菜单</div>
</div>
</div>
</div>
<div class="screen animate-in">
<div class="translation-block">
<div class="translation-code">
<span class="translation-label">vite.config.ts</span>
<pre><code><span class="code-line">server: {</span>
<span class="code-line"> port: <span class="code-number">5173</span>,</span>
<span class="code-line"> proxy: {</span>
<span class="code-line"> <span class="code-string">'/api'</span>: {</span>
<span class="code-line"> target: <span class="code-string">'http://127.0.0.1:8000'</span>,</span>
<span class="code-line"> changeOrigin: <span class="code-keyword">true</span>,</span>
<span class="code-line"> },</span>
<span class="code-line"> },</span>
<span class="code-line">},</span></code></pre>
</div>
<div class="translation-english">
<span class="translation-label">白话</span>
<div class="translation-lines">
<p class="tl">浏览器请求 localhost:5173/api/... 时,Vite 偷偷转给 8000 端口。</p>
<p class="tl">前端代码里写相对路径 /api 即可,不用硬编码 :8000。</p>
<p class="tl">生产构建没有 proxy,要 Nginx 或同域部署反代。</p>
</div>
</div>
</div>
</div>
<div class="screen animate-in">
<h2>常见 401 速查</h2>
<div class="pattern-cards">
<div class="pattern-card">
<h3>AUTH_401_MISSING_BEARER</h3>
<p>没登录或 token 过期 → 重新点 Demo 登录;改过后端角色也要重登拿新 JWT。</p>
</div>
<div class="pattern-card">
<h3>AUTH_401_MISSING_AGENT_TYPE</h3>
<p>走了 /api/chat 但没带 X-Agent-Type → 查 ChatPanel 的 agentType 和 chat.ts headers。</p>
</div>
<div class="pattern-card">
<h3>AUTH_403_AGENT_MISMATCH</h3>
<p>JWT 角色和 X-Agent-Type 不配,如客户 token 却带 advisor → 换账号或改 prop。</p>
</div>
<div class="pattern-card">
<h3>连不上 API</h3>
<p>8000 没起或 proxy 失效 → curl http://127.0.0.1:8000/health 先确认后端活着。</p>
</div>
</div>
<div class="quiz-container" id="quiz-frontend-m3">
<div class="quiz-question-block"
data-correct="option-b"
data-explanation-right="持仓页走平台 API,只需 Bearer;缺的是 X-Agent-Type 才会在 chat 线 401。"
data-explanation-wrong="平台读接口不要 X-Agent-Type;401 missing bearer 才是没 token。">
<h3 class="quiz-question">客户持仓页 401,响应 AUTH_401_MISSING_AGENT_TYPE,最可能原因是?</h3>
<div class="quiz-options">
<button class="quiz-option" data-value="option-a" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>平台 API 忘了带 X-Agent-Type(正常不该带)</span>
</button>
<button class="quiz-option" data-value="option-b" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>请求误打到 /api/chat 或混用了 chat 封装</span>
</button>
<button class="quiz-option" data-value="option-c" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>vite proxy 端口写错</span>
</button>
</div>
<div class="quiz-feedback"></div>
</div>
<div class="quiz-question-block"
data-correct="option-a"
data-explanation-right="对。/api → 127.0.0.1:8000,前端 dev 标准配置。"
data-explanation-wrong="proxy target 是 8000 不是 5173;5173 是 Vite 自己。">
<h3 class="quiz-question">npm run dev 时,/api/chat 实际打到哪?</h3>
<div class="quiz-options">
<button class="quiz-option" data-value="option-a" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>http://127.0.0.1:8000/api/chat</span>
</button>
<button class="quiz-option" data-value="option-b" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>http://127.0.0.1:5173/api/chat(Vite 自己处理)</span>
</button>
</div>
<div class="quiz-feedback"></div>
</div>
<button class="quiz-check-btn" onclick="checkQuiz('quiz-frontend-m3')">检查答案</button>
<button class="quiz-reset-btn" onclick="resetQuiz('quiz-frontend-m3')">重做</button>
</div>
</div>
</div>
</section>
<section class="module" id="module-4">
<div class="module-inner">
<p class="eyebrow animate-in">模块 4 · 未接线</p>
<h1 class="module-title animate-in">菜单有 ≠ API 已接<br>Chat 还有 B 方案三端点</h1>
<p class="module-lead animate-in">
前端 P0 主链路已真接(含风控三页 · 问数标签 · Dashboard hooks),但仍有<strong>分析对话占位、看板无钻取</strong>等缝。
开发态若 Dashboard「先闪错再正常」,多半是 StrictMode 双请求——已用 <code>useAsyncSequence</code> 丢弃过期响应。
</p>
<div class="screen animate-in">
<h2>仍开放 / 已修清单(2026-09-10)</h2>
<div class="pattern-cards">
<div class="pattern-card">
<h3>风控 UI</h3>
<p>台账筛选 · 适当性校验 · AML 扫描页已接 REST;高级运营能力仍简版。</p>
</div>
<div class="pattern-card">
<h3>问数</h3>
<p><code>AnalystChatShell</code> 占位 · 真问数在 <code>AnalystQueryPage</code>(模板/缓存标签 + dashboard/assets)。</p>
</div>
<div class="pattern-card">
<h3>竞态修复</h3>
<p><code>useAsyncSequence</code> 用于 Dashboard hooks、ChatPanel、风控/客户列表页等。</p>
</div>
<div class="pattern-card">
<h3>游客</h3>
<p><code>VisitorChatWidget</code> 首页试聊 · 与登录 ChatPanel 不同 API。</p>
</div>
</div>
</div>
<div class="screen animate-in">
<h2>Chat 方案 B:三端点 + SSE 注意点</h2>
<div class="translation-block">
<div class="translation-code">
<span class="translation-label">会话 API</span>
<pre><code><span class="code-line">GET /api/chat/sessions</span>
<span class="code-line">GET /api/chat/sessions/{id}/messages</span>
<span class="code-line">POST /api/chat/sessions/{id}/close</span>
<span class="code-line">POST /api/chat/stream → customer SSE</span></code></pre>
</div>
<div class="translation-english">
<span class="translation-label">白话</span>
<div class="translation-lines">
<p class="tl">列表/历史/关闭与发消息共用同一套鉴权(Bearer + X-Agent-Type)。</p>
<p class="tl">SSE 无心跳:断连可能留下半空回合——排障时查 session 表 + 是否 uvicorn 重启。</p>
<p class="tl">HashRouter:URL 带 <code>#</code>,生产 Nginx 要配 SPA fallback。</p>
</div>
</div>
</div>
</div>
<div class="screen animate-in">
<h2>改 URL 进别人工作台</h2>
<p><code>RequireAuth</code> 只验「有没有 token」,<strong>不</strong>验「这个角色能不能进 /app/risk」。菜单隐藏 ≠ 安全——后端 <code>AGENT_ACCESS_MATRIX</code> 和平台 RBAC 才拒 403。</p>
<div class="quiz-container" id="quiz-frontend-m4">
<div class="quiz-question-block"
data-correct="option-b"
data-explanation-right="对。前端藏菜单是 UX;越权要靠后端模块鉴权(deps.py)矩阵和 SessionGuard 403。"
data-explanation-wrong="仅改前端路由守卫不够,必须对后端入口断言。">
<h3 class="quiz-question">客户账号手动打开 /app/risk/alerts,只靠前端能拦住吗?</h3>
<div class="quiz-options">
<button class="quiz-option" data-value="option-a" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>能,RequireAuth 够了</span>
</button>
<button class="quiz-option" data-value="option-b" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>不能,必须后端 risk 角色 + X-Agent-Type: risk</span>
</button>
<button class="quiz-option" data-value="option-c" onclick="selectOption(this)">
<div class="quiz-option-radio"></div><span>能,Hash 路由自动 404</span>
</button>
</div>
<div class="quiz-feedback"></div>
</div>
<button class="quiz-check-btn" onclick="checkQuiz('quiz-frontend-m4')">检查答案</button>
<button class="quiz-reset-btn" onclick="resetQuiz('quiz-frontend-m4')">重做</button>
</div>
</div>
</div>
</section>
</main>
</body>
</html>