从零构建 AI 学术论文助手(五):单文件 SPA 前端 + 全栈部署总结
系列文章终篇。本篇讲前端架构设计——为什么不用框架、单文件 SPA 如何组织、暗色主题设计系统,以及部署上线后遇到的实际问题和解决方案。
一、为什么不用 Vue/React
做独立开发项目,技术选型要考虑"够用就好"原则。
| 考量 | Vue/React | 纯 HTML/CSS/JS |
|---|---|---|
| 部署复杂度 | 需要 npm build,产物要配 CDN 或静态服务器 | 一个文件,直接 StaticFiles 挂载 |
| 包体积 | React 约 130KB,加上路由、状态管理 300KB+ | PDF.js 250KB,其余 < 20KB |
| 更新速度 | 修改需要重新 build | 改文件直接生效,Railway 热重载 |
| 学习成本 | 对于快速迭代是负担 | 所见即所得 |
| 缺点 | 构建步骤、版本管理 | 没有组件化,代码量大了难维护 |
结论:这个项目是 SaaS MVP,7 个页面,1 个人开发。单文件方案够用,维护成本低,部署零配置。当用户量增长到需要重构时,再迁移到框架。
二、整体 HTML 结构
app.html
├── <head>
│ ├── CSS 变量系统(:root)
│ ├── 全局重置
│ ├── 各模块 CSS(约 500 行)
│ └── PDF.js CDN
│
├── <body>
│ ├── #topbar(固定顶部导航)
│ ├── #auth-modal(登录/注册弹窗)
│ ├── #cite-modal(引用格式弹窗)
│ │
│ └── .app-body(grid: 210px | 1fr)
│ ├── .nav-sidebar(左侧导航)
│ └── .content-area
│ ├── #page-library(文档库)
│ ├── #page-qa(知识库问答)
│ ├── #page-write(论文撰写)
│ ├── #page-review(文献综述)
│ ├── #page-reduce(降率工具)
│ ├── #page-pdfchat(PDF 精读)
│ ├── #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 页有专门的入口函数,因为需要传入文档 ID:
async 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] || '';
}
八、部署上线后遇到的实际问题
问题 1:Railway 临时文件系统
Railway 免费套餐的文件系统在每次服务重启(含每次部署)后会清空。上传的 PDF 文件存在 uploads/ 目录下,重启后消失。
现象:PDF 精读页加载时返回 410 Gone。
解法:
- 短期:提示用户"服务重启后文件会清除,请重新上传"
- 长期:升级到 Railway 付费套餐并挂载 Persistent Volume,或改用对象存储(AWS S3 / 阿里云 OSS)
问题 2:Supabase pgvector 扩展未启用
首次部署后向量检索全部失败,日志显示:
type "vector" does not exist
解法:在 Supabase 控制台 → SQL Editor 执行:
CREATE EXTENSION IF NOT EXISTS vector;
这是一次性操作,此后不需要重复执行。
问题 3:Groq 免费版 TPD 用完
见第三篇的多模型路由方案,这里不重复。
问题 4:DeepSeek 模型名弃用
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,自写 RAG;Chroma + Jina AI + Railway |
| 二 | PDF 解析与 RAG | 字体识别章节;双语章节名;子块+父块;多文献均衡分配 |
| 三 | 多模型路由 | MODEL_ROUTES 路由表;429 自动降级;SSE 流式响应 |
| 四 | PDF.js + 截图 | iframe 不可行;Canvas 跨页合成;DPR 适配;Gemini 视觉 |
| 五 | 前端 + 部署 | 单文件 SPA;CSS 变量系统;Railway 坑点;安全注意事项 |
给独立开发者的建议
- 先跑起来,再优化:MVP 阶段不需要微服务,一个 FastAPI 服务够了
- 免费额度够用很久:Jina AI 100万 token/月免费,Groq 有每日额度,Gemini 有免费 quota,合理规划可以在 0 成本下跑很长时间
- 把所有密钥放环境变量:从第一天就这样做,不要等到要开源时才重构
- Railway 免费套餐的限制要了解清楚:临时文件系统、500小时/月运行时间,按需升级
- 单文件不是不好:对于 1 人团队,零构建步骤的维护成本优势是真实的
项目地址(如有开源):GitHub 链接
欢迎关注系列后续内容,下一阶段计划:
- [ ] Markdown 渲染(引入 marked.js)
- [ ] PDF 精读全屏模式
- [ ] 移动端适配
- [ ] 持久化对象存储(替换 Railway 临时文件系统)
更多推荐



所有评论(0)