系列文章终篇。本篇讲前端架构设计——为什么不用框架、单文件 SPA 如何组织、暗色主题设计系统以及部署上线后遇到的实际问题和解决方案。一、为什么不用 Vue/React做独立开发项目技术选型要考虑够用就好原则。考量Vue/React纯 HTML/CSS/JS部署复杂度需要 npm build产物要配 CDN 或静态服务器一个文件直接StaticFiles挂载包体积React 约 130KB加上路由、状态管理 300KBPDF.js 250KB其余 20KB更新速度修改需要重新 build改文件直接生效Railway 热重载学习成本对于快速迭代是负担所见即所得缺点构建步骤、版本管理没有组件化代码量大了难维护结论这个项目是 SaaS MVP7 个页面1 个人开发。单文件方案够用维护成本低部署零配置。当用户量增长到需要重构时再迁移到框架。二、整体 HTML 结构app.html ├── head │ ├── CSS 变量系统:root │ ├── 全局重置 │ ├── 各模块 CSS约 500 行 │ └── PDF.js CDN │ ├── body │ ├── #topbar固定顶部导航 │ ├── #auth-modal登录/注册弹窗 │ ├── #cite-modal引用格式弹窗 │ │ │ └── .app-bodygrid: 210px | 1fr │ ├── .nav-sidebar左侧导航 │ └── .content-area │ ├── #page-library文档库 │ ├── #page-qa知识库问答 │ ├── #page-write论文撰写 │ ├── #page-review文献综述 │ ├── #page-reduce降率工具 │ ├── #page-pdfchatPDF 精读 │ ├── #page-history历史记录 │ └── #page-account我的账号 │ └── script约 1500 行 JS ├── 认证 / authFetch ├── 页面导航 goPage() ├── 文档库逻辑 ├── 问答逻辑 ├── 精读逻辑PDF.js 截图 └── 其他功能模块三、CSS 设计系统3.1 CSS 变量整个 UI 风格统一在一组 CSS 变量里修改一处全局生效:root { --bg: #09090f; /* 页面背景极深蓝黑 */ --card: #111118; /* 卡片背景 */ --gold: #c9a84c; /* 强调色哑光金 */ --fg: #e8e4d8; /* 主文字暖白 */ --muted: #7c7a72; /* 次要文字 */ --border: rgba(255,255,255,0.08); --border-gold: rgba(201,168,76,0.25); --nav-bg: #0b0b13; --panel: #0d0d15; --nav-w: 210px; /* 侧边栏宽度 */ --nav-h: 54px; /* 顶栏高度 */ }为什么用金色作为强调色学术工具需要专业感金色比蓝色更有古典学术的联想配合 Playfair Display 衬线字体整体风格偏向高端学术工具而非普通 SaaS。3.2 布局结构/* 主体顶栏下方左右两栏 */ .app-body { display: grid; grid-template-columns: var(--nav-w) 1fr; height: calc(100vh - var(--nav-h)); margin-top: var(--nav-h); overflow: hidden; } /* 内容区各 page 叠加active 的才显示 */ .page { display: none; flex: 1; overflow: hidden; flex-direction: column; } .page.active { display: flex; }3.3 PDF 精读三栏布局.pdfchat-layout { display: grid; grid-template-columns: 220px 1fr 380px; /* 目录 | PDF | 对话 */ height: 100%; overflow: hidden; transition: grid-template-columns .22s; } /* 收起 PDF 时动画过渡 */ .pdfchat-layout.viewer-hidden { grid-template-columns: 220px 0 380px; } /* 响应式宽度不足时隐藏目录 */ media(max-width:1100px) { .pdfchat-layout { grid-template-columns: 0 1fr 360px; } .pdfchat-sidebar { display: none; } }3.4 工具类按钮.abtn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 13px; border-radius: 5px; font-size: .78rem; font-weight: 600; cursor: pointer; border: none; transition: .15s; } .abtn-gold { background: var(--gold); color: var(--bg); } .abtn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); } .abtn-danger{ background: rgba(220,38,38,.1); color: #f87171; border: 1px solid rgba(220,38,38,.15); }四、页面导航系统不用路由库自己实现三行代码的页面切换const NAV_PAGES [library,qa,write,review,reduce,history,account,pdfchat]; function goPage(name) { // 所有 page 隐藏 NAV_PAGES.forEach(p { document.getElementById(page-${p})?.classList.remove(active); document.getElementById(nav-${p})?.classList.remove(active); }); // 目标 page 显示 document.getElementById(page-${name})?.classList.add(active); document.getElementById(nav-${name})?.classList.add(active); // 问答页显示固定输入栏 document.getElementById(qaBar)?.classList.toggle(show, name qa); // 切换时刷新数据 if (name qa) loadDocList(); if (name library) loadLibrary(); if (name history) loadHistory(); if (name account) loadAccount(); }pdfchat页有专门的入口函数因为需要传入文档 IDasync function goToPdfChat(docId) { _currentPdfDocId docId; _visionB64 null; pdfchatClear(); goPage(pdfchat); // 并行加载PDF 渲染 章节目录 await Promise.all([ _loadPdfViewer(docId), _loadPdfSections(docId), ]); }五、认证系统JWT localStorage// 所有 API 请求统一用 authFetch自动附加 JWT async function authFetch(url, options {}) { const token localStorage.getItem(auth_token); const headers { Content-Type: application/json, ...(token ? {Authorization: Bearer ${token}} : {}), ...options.headers, }; const resp await fetch(url, {...options, headers}); if (resp.status 401) { localStorage.removeItem(auth_token); showAuthModal(); throw new Error(未登录); } return resp; }初始化时校验 token(async () { const token localStorage.getItem(auth_token); if (!token) { showAuthModal(); return; } const resp await fetch(/api/auth/me, { headers: {Authorization: Bearer ${token}} }); if (!resp.ok) { localStorage.removeItem(auth_token); showAuthModal(); return; } const user await resp.json(); _currentUser user; updateUserUI(user); loadLibrary(); })();六、SSE 流式渲染所有 AI 生成内容都是流式推送前端需要逐 token 追加到消息气泡async function streamToElement(url, body, targetEl) { const resp await authFetch(url, { method: POST, body: JSON.stringify(body), }); if (!resp.ok) { const err await resp.json(); targetEl.textContent ❌ ${err.detail}; return; } const reader resp.body.getReader(); const decoder new TextDecoder(); let buf ; while (true) { const {done, value} await reader.read(); if (done) break; buf decoder.decode(value, {stream: true}); const lines buf.split(\n\n); buf lines.pop(); for (const line of lines) { if (!line.startsWith(data: )) continue; const raw line.slice(6); if (raw [DONE]) return; try { const msg JSON.parse(raw); if (msg.type text) { targetEl.textContent msg.text; // 自动滚动到底部 targetEl.closest(.chat-area, .pdfchat-area) ?.scrollTo(0, 99999); } } catch {} } } }七、主要功能模块实现要点7.1 文献综述单次 SSE 调用async function startReview() { const topic document.getElementById(reviewTopic).value.trim(); const docIds getCheckedReviewDocs(); const resultEl document.getElementById(reviewResult); resultEl.textContent ; await streamToElement(/api/review/stream, {topic, doc_ids: docIds}, resultEl); }7.2 论文撰写多 section 并行async function generateAllSections() { const topic document.getElementById(writeTopic).value.trim(); const docIds getSelectedDocIds(); // 所有章节并行生成 const sections document.querySelectorAll(.ws-item); await Promise.all([...sections].map(sec generateSection(sec, topic, docIds))); } async function generateSection(secEl, topic, docIds) { const sectionName secEl.querySelector(.ws-title).textContent; const contentEl secEl.querySelector(.ws-content); contentEl.textContent ; secEl.querySelector(.ws-badge).textContent 生成中; await streamToElement(/api/write/stream, { section: sectionName, topic, doc_ids: docIds }, contentEl); secEl.querySelector(.ws-badge).textContent 已完成; }7.3 引用格式导出AI 提取元数据async function openCiteModal(docId, docName) { document.getElementById(cite-modal).classList.add(show); document.getElementById(citeDocName).textContent docName; // 调用后端提取元数据 const resp await authFetch(/api/documents/${docId}/cite); const meta await resp.json(); _currentCiteMeta meta; renderCiteOutput(gb7714); // 默认 GB/T 7714 格式 } function renderCiteOutput(fmt) { const m _currentCiteMeta; const authors m.authors?.join(, ) || —; const formats { gb7714: ${authors}. ${m.title}[J]. ${m.journal}, ${m.year}, ${m.volume}(${m.issue}): ${m.pages}., apa: ${authors} (${m.year}). ${m.title}. em${m.journal}/em, em${m.volume}/em(${m.issue}), ${m.pages}., bibtex: article{key,\n author {${authors}},\n title {${m.title}},\n journal {${m.journal}},\n year {${m.year}},\n volume {${m.volume}},\n number {${m.issue}},\n pages {${m.pages}}\n}, // ... 其他格式 }; document.getElementById(citeOutput).value formats[fmt] || ; }八、部署上线后遇到的实际问题问题 1Railway 临时文件系统Railway 免费套餐的文件系统在每次服务重启含每次部署后会清空。上传的 PDF 文件存在uploads/目录下重启后消失。现象PDF 精读页加载时返回 410 Gone。解法- 短期提示用户服务重启后文件会清除请重新上传- 长期升级到 Railway 付费套餐并挂载 Persistent Volume或改用对象存储AWS S3 / 阿里云 OSS问题 2Supabase pgvector 扩展未启用首次部署后向量检索全部失败日志显示type vector does not exist解法在 Supabase 控制台 → SQL Editor 执行CREATE EXTENSION IF NOT EXISTS vector;这是一次性操作此后不需要重复执行。问题 3Groq 免费版 TPD 用完见第三篇的多模型路由方案这里不重复。问题 4DeepSeek 模型名弃用deepseek-chat于 2026年7月前后被官方标记为弃用切换到deepseek-v4-flash。如果不切换调用时会收到弃用警告最终变成错误。教训不要把模型名写在业务代码里统一放在config.py的路由表切换时只改一处。九、安全注意事项9.1 API Key 绝对不能写在代码里# ❌ 绝对不能这样 GROQ_API_KEY gsk_xxxxxxxxxxxxx # ✅ 从环境变量读取 GROQ_API_KEY os.getenv(GROQ_API_KEY, )Railway 的 Environment Variables 面板设置密钥Git 仓库里只有取值逻辑不含实际密钥。9.2 数据库密码不能出现在代码仓库即使是连接字符串里编码过的密码%24代替$也不应该 commit 到 Git。9.3 JWT Secret 要足够随机SECRET_KEY os.getenv(SECRET_KEY, docmind-dev-secret-change-in-prod)开发默认值只用于本地测试生产环境必须在 Railway 设置随机强密钥。十、系列总结5 篇文章覆盖了 DocMind 从 0 到上线的完整构建过程篇主题核心收获一架构选型不用 LangChain自写 RAGChroma Jina AI Railway二PDF 解析与 RAG字体识别章节双语章节名子块父块多文献均衡分配三多模型路由MODEL_ROUTES 路由表429 自动降级SSE 流式响应四PDF.js 截图iframe 不可行Canvas 跨页合成DPR 适配Gemini 视觉五前端 部署单文件 SPACSS 变量系统Railway 坑点安全注意事项给独立开发者的建议先跑起来再优化MVP 阶段不需要微服务一个 FastAPI 服务够了免费额度够用很久Jina AI 100万 token/月免费Groq 有每日额度Gemini 有免费 quota合理规划可以在 0 成本下跑很长时间把所有密钥放环境变量从第一天就这样做不要等到要开源时才重构Railway 免费套餐的限制要了解清楚临时文件系统、500小时/月运行时间按需升级单文件不是不好对于 1 人团队零构建步骤的维护成本优势是真实的项目地址如有开源GitHub 链接欢迎关注系列后续内容下一阶段计划- [ ] Markdown 渲染引入 marked.js- [ ] PDF 精读全屏模式- [ ] 移动端适配- [ ] 持久化对象存储替换 Railway 临时文件系统