系列文章终篇。本篇讲前端架构设计——为什么不用框架、单文件 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 坑点;安全注意事项

给独立开发者的建议

  1. 先跑起来,再优化:MVP 阶段不需要微服务,一个 FastAPI 服务够了
  2. 免费额度够用很久:Jina AI 100万 token/月免费,Groq 有每日额度,Gemini 有免费 quota,合理规划可以在 0 成本下跑很长时间
  3. 把所有密钥放环境变量:从第一天就这样做,不要等到要开源时才重构
  4. Railway 免费套餐的限制要了解清楚:临时文件系统、500小时/月运行时间,按需升级
  5. 单文件不是不好:对于 1 人团队,零构建步骤的维护成本优势是真实的

项目地址(如有开源):GitHub 链接

欢迎关注系列后续内容,下一阶段计划:
- [ ] Markdown 渲染(引入 marked.js)
- [ ] PDF 精读全屏模式
- [ ] 移动端适配
- [ ] 持久化对象存储(替换 Railway 临时文件系统)

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐