背景

业务系统已有一套 Nuxt 静态手册,通过新页签打开,支持文字、截图、Excel 模板下载和搜索,不需要在线编辑。

主系统采用 Vue 和 Vite,后端使用 FastAPI。手册用 Markdown 维护内容、自定义组件控制样式,构建后放进后端静态目录,与主系统同域部署。

后来增加了阅读范围限制:内部人员看完整手册,供应商只看登录说明和供应商数据导入说明。我们沿用现有的 Nuxt 4.5.2 和 Nuxt Content 3.14.0 工程,通过两次构建生成不同内容,再由后端控制访问。

为什么沿用 Nuxt 文档工程

Markdown 需要转换为 HTML 才能排版。可以在主前端完成转换,也可以由独立文档工程提前生成页面;后端若返回 Markdown,前端仍需负责转换和展示。

方案

主要优势

主要不足

适用场景

主前端渲染本地 Markdown

少量文章接入简单,复用登录、路由和组件,无独立构建工程

目录、锚点和搜索需自行实现或接库;文章随主前端发布;正文若已打入包,隐藏菜单不能隔离内容

几篇简单说明,希望减少工程配置

后端接口返回 Markdown,主前端渲染

可逐篇鉴权;正文独立存储时,改内容不用重新构建前端

需维护内容接口、附件权限和搜索;前端仍需阅读界面,运行时依赖接口可用

内容频繁更新,或账号阅读权限经常变化

Nuxt Content 生成静态手册

复用文章解析和查询能力,提前生成正文;手册界面独立维护,部署后无须 Nuxt 服务

多一套依赖;改文章需重新构建;双版本重复输出资源,更多阅读范围需要更多构建配置

需要目录和搜索,文章随版本发布,阅读范围固定

例如临时让一个账号多看一篇文章,接口方案可以逐次判断权限,当前双版本方案则按预先构建的文章集合划分。改一处正文时,静态方案需要重新构建;接口方案若独立存储正文,可以更新内容并同步处理缓存。

本次已有 Nuxt 手册,切换方案需要调整文章读取、搜索和发布流程。内部与供应商的范围固定,文章也随版本发布,因此选择保留已有阅读功能,增加版本配置和一次构建,接受重复资源的代价。这是现有工程上的取舍;从零开发时,应按表中的需求重新选择。

一套源码怎样选出两套文章

页面组件共用,供应商专用文章单独存放:

manual/
├── app/                     页面、布局和搜索组件
├── content/
│   ├── login-and-account.md  两版共用
│   ├── oqc/                  内部文章
│   ├── iqc/                  内部文章
│   └── supplier/iqc/import.md
├── public/manual-assets/    截图和下载模板
├── variants.mjs             版本配置
├── content.config.ts        文章来源与字段定义
└── nuxt.config.ts           站点前缀和构建配置

两次构建使用同一个命令,通过参数区分版本:

nuxt generate --envName=internal
nuxt generate --envName=supplier

项目读取 Nuxt 的 --envName参数选择版本,不需要 .env.supplier 文件:

export function getManualAudience() {
  return process.argv.includes('--envName=supplier') ? 'supplier' : 'internal'
}

variants.mjs中的供应商配置指定访问前缀、集合名称和文章清单:

supplier: {
  baseURL: '/manual/supplier/',
  collection: 'manual_supplier',
  files: ['login-and-account.md', 'supplier/iqc/import.md']
}

nuxt.config.ts用 manualVariants[audience]取得配置,将 variant.baseURL赋给 app.baseURL,将 variant.collection放进 runtimeConfig.public.manualCollection供页面读取。Nuxt 使用这些值构建站点,不参与业务身份判断。

集合是一组可查询的文章。content.config.ts按名称定义两个集合:

collections: {
  manual_internal: collectionFor('internal'),
  manual_supplier: collectionFor('supplier')
}

项目函数 collectionFor负责筛选:内部版排除 supplier/**,供应商版只读取白名单文章。非当前版本的集合不收录文章,保留定义以支持共用页面的类型引用。文章来源配置

目录、正文和搜索都使用配置中的集合名称,通过 queryCollection(collection)等方法查询。供应商版只收录这两篇文章,内部正文不会进入该版集合。

source.prefix控制集合内的文章路径,app.baseURL控制站点前缀。例如文章路径 /iqc/import加上站点前缀后,得到 /manual/supplier/iqc/import。

附件需要另外处理:source只筛选 Markdown,public中的图片和 Excel 默认另行复制。供应商构建通过 ignoredAssets排除文章未引用的附件;这只控制构建产物,当前开发服务仍直接读取公共目录。解析前的钩子会把文章中 /manual/开头的资源链接换成当前版本的 baseURL,不修改源文件。

从 Markdown 到静态产物

nuxt generate执行生产构建并开启预渲染:在构建电脑上运行页面,将结果保存为 HTML。命令说明

flowchart TD
    A[共用页面和 Markdown 源码] --> B[命令参数选择版本]
    B --> C[Content 按集合筛选并解析文章]
    C --> D[准备内容数据库及导出数据]
    D --> E[Vite 编译浏览器代码和服务端渲染代码]
    E --> F[Nitro 临时渲染器逐个请求文章路由]
    F --> G[执行页面查询并渲染正文]
    G --> H{当前版本}
    H -->|内部版| I[.output/internal/public]
    H -->|供应商版| J[.output/supplier/public]

以导入文章为例,Content 将 Markdown 解析成标题、正文结构和路径。预渲染时,文章页面执行 queryCollection(collection).path(documentPath).first()取得记录,ContentRenderer生成正文,Nitro 将整页响应写入文件。这里的 Nitro 是 Nuxt 使用的服务端引擎。

服务端渲染代码用于构建;浏览器加载 JavaScript 后接管搜索和导航等交互。

产物中,index.html保存页面,_payload.json保存页面查询数据,_nuxt包含脚本、样式和 WASM 文件,__nuxt_content包含内容数据库导出数据。浏览器通过 WASM(可在浏览器运行的编译代码)运行 SQLite,加载导出数据后即可本地查询。静态部署说明

搜索使用 useSearchCollection(collection, { immediate: false }),首次搜索时按章节建立全文索引,随后在浏览器内查询。构建时准备的是内容数据,没有额外的搜索 JSON 或 FastAPI 搜索接口。

新页签怎样带上登录身份

主系统按公司类型选择 /manual/internal/或 /manual/supplier/。用户可以手动输入地址,因此后端仍须校验身份。

API 请求由前端请求工具附带 LocalStorage 中的 Bearer Token。普通链接打开新页签不会经过这套请求逻辑,也不会自动携带 LocalStorage 中的令牌。

同域部署下,登录接口额外将同一个 JWT(后端签发的登录令牌)写入 Cookie,浏览器访问手册时便会自动携带:

response.set_cookie(
    settings.MANUAL_COOKIE_NAME,
    token,
    max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
    httponly=True,
)

HttpOnly禁止 JavaScript 读取 Cookie,但浏览器仍会发送它。Cookie 保留时间与令牌配置一致,后端仍会验证令牌有效性;API 继续使用 Bearer 认证。

sequenceDiagram
    participant B as 浏览器
    participant A as FastAPI
    participant S as 手册静态文件
    B->>A: 提交账号密码
    A-->>B: 返回 API Token 并设置手册 Cookie
    Note over B: 主系统按公司类型选择手册链接
    B->>A: 新页签请求手册并自动携带 Cookie
    A->>A: 验证令牌及账号状态
    alt 未登录或凭证无效
        A-->>B: 页面请求跳转登录并记录返回地址
    else 供应商请求内部手册
        A-->>B: 返回 403
    else 允许访问
        A->>S: 读取对应页面或资源
        S-->>A: 静态文件
        A-->>B: 返回文件
    end

后端先挂载两版手册,再挂载主系统的根静态目录:

app.mount('/manual/internal', ProtectedManualFiles(settings.MANUAL_STATIC_DIR))
app.mount('/manual/supplier', ProtectedManualFiles(
    settings.MANUAL_SUPPLIER_STATIC_DIR, allow_suppliers=True
))

ProtectedManualFiles验证 Cookie 和账号状态,通过后交给 StaticFiles返回文件。allow_suppliers=True允许供应商和内部用户访问;默认值拒绝供应商。

未登录的页面请求跳转登录页,资源请求返回 401。登录后用 window.location.replace回到手册,避免让主系统的 Vue Router 处理另一个应用的路由。

当前支持 HTTP,未启用 Cookie 的 Secure标记;公网部署应使用 HTTPS 并启用它。退出主系统尚未同步清除手册 Cookie,凭证不会因此立即失效。private, no-store约束 HTTP 缓存,不能收回已加载的内容。

代理或其他静态挂载不能绕过这两个入口公开同一份文件。已授权用户仍可保存自己取得的内容。

产物如何进入后端

当前 build-all执行:

vite build && npm run manual:build && node scripts/package-manual.mjs

先构建主系统,再生成两版手册,最后将两份 public的内容复制到 dist/manual。主系统构建会清理输出目录,顺序不能颠倒。

dist/
├── index.html
├── assets/
└── manual/
    ├── internal/index.html
    └── supplier/index.html

前端 Dockerfile 将 dist保存到产物镜像,后端 Dockerfile 再复制到 src/static,发布后无须运行 Nuxt 服务。单独执行 manual:build只生成 .output产物,不会复制到本机后端。

手动复制时要取 public内部的文件。曾经多复制一层,首页变成 src/static/manual/internal/public/index.html,后端在 src/static/manual/internal/index.html找不到文件,返回 404。nitro.json等构建信息不必部署。

文章修改后需重新构建发布。两版共用开发端口和构建中间目录,开发时一次启动一版,构建时串行执行。

效果预览

内部版目录和文章页面:

供应商版目录和导入说明:

手册搜索结果: