本篇对应
static/三件套:index.html(155 行)、app.js(1356 行)、style.css(450 行)。本篇回答一个很多工程师想问的问题:2026 年了,不用 React、不用打包工具,能写出专业的企业前端吗? 司南的答案是:能,而且维护成本出奇地低。
一、为什么不用前端框架
02 篇定的选型,这里展开理由:
- 需求形态:一个多视图(对话/知识库/工作流/后台)的管理界面,交互密度中等,没有复杂的状态联动(不需要响应式依赖追踪、虚拟 DOM diffing);
- 部署形态:FastAPI 直接挂载静态目录,没有 Node、没有 npm audit、没有 node_modules 的 200MB。12 篇的 Dockerfile 里前端就是
COPY static ./static一行; - 学习曲线:教程读者写 HTML/CSS/JS 三件套就能全懂,不用先学两周框架;
- 代价如实说:视图复杂后代码量线性增长(本仓库 1356 行 JS,已接近"该拆"的边界);没有组件复用,靠函数 + innerHTML 模板字符串。如果页面再翻一倍,建议上框架——架构没有银弹,只有匹配规模的选择。
二、index.html:多视图 SPA 的骨架
SPA 的本质:一个 HTML 容器 + JS 控制显示哪个"页"。司南的骨架极简:
<!-- 登录/注册视图 -->
<div id="view-login" class="login-wrap">...</div>
<!-- 主界面视图 -->
<div id="view-main" style="display:none">
<header class="topbar">
<nav>
<button class="nav-btn active" data-view="chat" onclick="switchView('chat')">助手对话</button>
<button class="nav-btn" data-view="kb" onclick="switchView('kb')">知识库</button>
...
</nav>
</header>
<section id="page-chat" class="page">...</section>
<section id="page-kb" class="page" style="display:none">...</section>
...
</div>
switchView('kb') 做的事:隐藏所有 .page、显示目标页、更新导航按钮高亮、加载该页数据。四个"页"就是一个文件里的四个 section——没有路由库,没有懒加载。规模匹配即可。
还有一个值得抄的细节——防闪白:
<script>
/* 防闪白:首帧渲染前从 localStorage 恢复主题(app.js 里的 setTheme 负责交互与记忆) */
document.documentElement.dataset.theme = (function () {
try { return localStorage.getItem("aip_theme") || "light"; } catch (e) { return "light"; }
})();
</script>
<link rel="stylesheet" href="/static/style.css">
主题恢复脚本放在 <head> 里、样式表之前:页面渲染首帧时主题已就位。如果等 app.js 加载完再恢复主题,深色主题用户每次刷新都要经历一帧白闪。用户体验的差距就在这些地方。
三、style.css:CSS 变量驱动的 5 套主题
主题机制(style.css 头部注释原话):"所有颜色经 --var 引用,html[data-theme="..."] 覆写变量;切换由 app.js 的 setTheme() 完成(localStorage 记忆)。新增主题只需复制一个变量块。"
:root {
--primary: #5b8def;
--bg: #f3f5f9;
--card: #ffffff;
--text: #24292f;
/* ...十几个语义变量 */
}
html[data-theme="github-dark"] {
--primary: #4493f8;
--bg: #0d1117;
--card: #161b22;
--text: #e6edf3;
}
关键设计:变量按"语义"命名而不是按"颜色"命名——--card(卡片底色)而不是 --white,--text-dim(次要文字)而不是 --gray。深色主题覆写的是语义映射,所有组件样式零改动。这就是"5 套主题只是一个变量块"的技术含义。贡献指南相应规定:"颜色一律走 CSS 变量(新增主题参考 style.css 底部色板块)。"
配套细节:主题切换的过渡动画只针对颜色属性(transition: background-color .18s ...),不碰布局属性——避免切主题时页面"跳动"。
四、app.js 的基础设施:每个前端都该有的四件套
4.1 统一请求函数(含 401 全局处置)
async function req(method, url, body, isForm) {
const headers = {};
if (TOKEN) headers["Authorization"] = "Bearer " + TOKEN;
...
const resp = await fetch(API + url, { method, headers, body: payload });
if (resp.status === 401) {
logoutLocal(); // 全局唯一处置:清 token 回登录页
throw new Error("登录状态已失效,请重新登录");
}
if (!resp.ok) throw new Error(friendlyDetail(data, resp.status));
return data;
}
401 的处置只写一遍。散在各调用点各自处理 401,必然有的分支忘写——前端版的"判定收敛"。
4.2 错误信息人话化(pydantic 422 翻译器)
后端 pydantic 校验失败返回的 422 detail 是结构化数组,直接显示是 [object Object]。friendlyDetail 把它翻译成人话:string_too_short → "密码至少 6 个字符"、missing → "缺少必填项「用户名」"、数组只取前 3 条 + "等 N 处"。前后端错误体验的接力棒:后端给结构,前端给人话。
4.3 XSS 防线:esc() 全程护航
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
所有动态内容进 HTML 前必经 esc()——用户名、消息内容、文件名。配合 09 篇的 CSP(connect-src 'self'),即使漏了一处,注入脚本也外传不出数据。XSS 防御是纵深,不是单点。
4.4 交互锁:防重复提交
const INFLIGHT = new Set();
async function withLock(key, run) {
if (INFLIGHT.has(key)) { toast("上一条操作还在处理中,请稍候", "err"); return null; }
INFLIGHT.add(key);
try { return await run(); }
finally { INFLIGHT.delete(key); }
}
发消息中按钮 disabled(sending 标志)、表格行内按钮用 withLock("动作:目标id")——连点只生效一次。注释说得很细:"表格行内按钮没有 id,用「动作:目标id」做键"。重复提交是内部系统的头号脏数据来源,这是交互层的纪律。
五、SSE 流式渲染:打字机的全部实现
前端不用 EventSource(它只支持 GET),用 fetch + ReadableStream 手工解析(09 篇的 SSE 帧协议在这里的镜像实现):
async function readSse(resp, onEvent) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf("\n\n")) >= 0) { // SSE 帧以空行分隔
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let ev = "message", data = null;
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) ev = line.slice(6).trim();
else if (line.startsWith("data:")) data = line.slice(5).trim();
}
if (data != null) {
try { onEvent(ev, JSON.parse(data)); } catch (e) { /* 跳过坏帧 */ }
}
}
}
}
三个新手必知的点:
- 网络字节没有消息边界:一个 chunk 可能含半帧/一帧半。缓冲区按
\n\n切帧、残帧留在 buf 里——TCP 是字节流,协议自己定边界,这是网络编程的通用课; TextDecoder的{stream: true}:多字节 UTF-8 字符(一个汉字 3 字节)可能被 chunk 拦腰截断,流式解码器会保留不完整的字节序列等下一个 chunk;- 坏帧跳过而非中断:JSON.parse 失败只丢这一帧,流继续。
消息气泡的生命周期
sendMessage() 的时序(对应 09 篇的后端协议):
发消息 → 插入用户气泡 + 带光标的空 bot 气泡(.streaming)
← meta 事件:存下 refs(引用来源)与 degraded 标记
← delta 事件:增量文本直接写入 textContent(安全),滚动到底
← done 事件:整体替换为"富文本版"(esc 后的正文 + 引用列表 + 用量元信息)
← error 事件:气泡变错误样式,提示"本条未保存"
两处值得品的细节:
- 流式中 delta 写
textContent(天然免疫 XSS),完成后才 innerHTML 整体替换(esc 后的富文本版)——性能与安全的平衡:流式期间每帧转义再拼 HTML 太浪费; - 完成后
bubble.removeAttribute("id")——"只有'正在流式'的气泡才持有该 id,否则下一条消息会定位到旧气泡"(注释原话)。DOM id 复用引发的诡异 bug,一句话注释讲透。
降级可见 + 用量透明
回复气泡底部永远有一行 msg-meta:mock(已降级) / mock-model · tokens ↑12 ↓45 · 耗时 38ms · 重试2次。degraded 时追加醒目的 ⚠ 模型调用失败已自动降级为本地模拟回复:原因——06 篇"降级必须可见"的前端兑现。企业用户对"AI 到底花了多少钱"的信任,就建立在这种透明上。
六、工作流页:轮询驱动的实时节点状态
工作流执行页的"实时性"是最朴素的实现:提交后先起一个 900ms 间隔的 setInterval 轮询 GET /workflows/{id}/runs + GET /workflows/runs/{id}/detail(09 篇的端点),同时主请求等待执行完成;节点状态渲染成 chip 条:pending(呼吸脉冲动画)/ success(绿)/ failed(红,页面上方列出失败节点名单)/ skipped(半透明,failed 后未到达的节点),外加进度条与"已执行 N/M"计数。停表时机有两个:主请求返回(finally 中 stopRunWatch()),或用户切换工作流(轮询回调发现 curWf 变了就自停)——源码里 if (!runWatch) return; // 主流程已结束,别覆盖最终状态 的多处守卫,防止迟到的轮询响应覆盖最终状态。
后端保证运行必有终态(08 篇"僵尸运行兜底"),前端才有资格"拿到终态才停轮询"——前后端契约是成对出现的。
七、引用来源的可展开设计
带知识库的回复,气泡下方列出 《文件名》第N段 (相关度0.83),点击展开该块完整原文。数据来自 meta 事件的 refs(doc_id/filename/seq/score/text 全带)。员工验证"AI 说的对不对"的最短路径——可溯源是 RAG 产品可信的前提。
八、动手清单
- 写出双视图骨架(login/main + switchView),完成登录表单 → 存 TOKEN → 切主视图
- 实现
req/guard/toast/esc四件套,接通助手列表 - 实现
readSse+ 流式气泡,跑通打字机效果(Mock 的 12ms/8字符切片肉眼可见) - 加第六套主题:在 style.css 复制一个变量块、app.js 的 THEMES 数组加一行——体会"新增主题只需一个变量块"(仓库已有 5 套:清爽蓝/Codex 暖白/GitHub Dark/Dracula 紫/Nord 极地)
- 【体验实验】把
readSse里的{stream: true}去掉,传中文长消息看乱码——亲手理解 TextDecoder 的流式参数 - 【安全实验】把某处
esc()去掉,用户名改成<img src=x onerror=alert(1)>注册,观察注入效果——再装回 esc
常见坑:
- fetch 的流式读取在响应体为空(如 413/502)时 resp.body 为 null——先判 resp.ok 再取 reader
- innerHTML 模板里忘 esc → XSS(用第 6 条实验自查)
- 轮询忘 clearInterval → 切页后仍在后台刷接口
下一篇进入质量保障:107 项测试怎么组织、夹具怎么设计、"评估进 CI"的回归护栏怎么搭。