05 · 技术资料:前端、上传与解析流水线
#1. 版本与脚手架
npx create-next-app@latest baton --typescript --tailwind --app --no-src-dir --import-alias "@/*"建完把 package.json 的依赖版本对齐 08-可复用资产清单 §2(Next 16.2.10 / React 19.2.4 / Tailwind ^4 / shadcn ^4.13.1)。
npx shadcn@latest init # Tailwind config 路径问项时【留空】(v4 用 CSS @theme,不再有 tailwind.config.js)
npx shadcn@latest add button card table sheet dialog badge switch progress tabs command sonner \
input label select separator skeleton avatar tooltip scroll-area checkboxnext.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 这三个包体积大 / 有动态 require,必须让 Next 走运行时 require 而不是打包
serverExternalPackages: ['unpdf', 'mammoth', 'exceljs'],
}
export default nextConfig#2. Next.js 16 相对 15 的破坏性变更(会咬到本项目的)
| 变更 | 对 Baton 的影响 | 怎么写 |
|---|---|---|
middleware.ts → proxy.ts |
密码门用 proxy.ts,导出 export async function proxy(req) |
直接抄 english-daily 的(08 §1) |
proxy.ts 默认跑 Node runtime(不再是 Edge) |
process.env 是运行时读取,行为更符合直觉 |
无需特殊处理 |
params / searchParams / cookies() / headers() 全部异步 |
每个动态路由都要改 | const { id } = await params |
revalidateTag() 必须传第二参数 |
若用到缓存标签 | revalidateTag('files', 'max') |
cacheComponents / "use cache" |
Baton 的数据要实时(进度条、状态),不加 "use cache" 即可 |
保持默认 |
| Server Action 请求体仍 4.5MB 上限 | 上传 ⛔ 不能走 Server Action | 见 §3 |
| Node 最低 20.9+ | 本机 v22.22.2 ✅ | — |
| Turbopack 成为默认构建器 | 有自定义 webpack 配置才需注意 | 本项目无 |
next/image 的 images.qualities 默认收窄为 [75] |
本项目基本不用 next/image | — |
#3. 文件上传:Vercel Blob 客户端直传
#3.1 为什么必须直传
Vercel Function(API Route 和 Server Action 一样)请求体上限 4.5MB,超了返回 413 FUNCTION_PAYLOAD_TOO_LARGE。文档动辄十几 MB。
所以让浏览器绕过自己的函数,直接把字节传到 Blob;服务端只签发一个短时效令牌。
npm i @vercel/blob#3.2 服务端令牌路由 app/api/blob/upload/route.ts
// 客户端直传的看门人:不接收文件本体,只签令牌 + 接收完成回调
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
import { NextResponse } from 'next/server'
export const runtime = 'nodejs'
export async function POST(request: Request): Promise<NextResponse> {
const body = (await request.json()) as HandleUploadBody
try {
const json = await handleUpload({
body,
request,
onBeforeGenerateToken: async () => {
// 🔴 必须鉴权。不做这一步 = 把 Blob 库对全网开放。
// Baton 走的是密码门 Cookie,这里校验会话 Cookie 是否有效。
// (proxy.ts 已经拦了未登录请求,但这里要再确认一次,不能只靠中间件。)
return {
allowedContentTypes: [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain', 'text/markdown',
],
addRandomSuffix: true,
maximumSizeInBytes: 20 * 1024 * 1024, // 20MB,对应 AC-2.1.3
}
},
onUploadCompleted: async ({ blob }) => {
// ⚠️ 本地开发收不到这个回调(Vercel Blob 回调不到 localhost)。
// 所以【建档不要只依赖这个回调】—— 前端拿到 blob.url 后再显式调一次建档接口。
void blob
},
})
return NextResponse.json(json)
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 })
}
}#3.3 前端调用要点
import { upload } from '@vercel/blob/client'
const blob = await upload(`baton/${employeeId}/${Date.now()}-${file.name}`, file, {
access: 'public', // 私有资料理论上要 private;若 private 读取要签名 URL,今晚可先 public + 路径含随机后缀
handleUploadUrl: '/api/blob/upload',
multipart: file.size > 10 * 1024 * 1024,
onUploadProgress: ({ percentage }) => setProgress(percentage),
})
// 拿到 blob.url 后显式调建档接口(不要只靠 onUploadCompleted,本地收不到)
await fetch('/api/files', { method: 'POST', body: JSON.stringify({ url: blob.url, ... }) })#3.4 ⚠️ 创建 Blob store 的坑
若 Vercel 项目还没有 Blob store,需要 vercel blob create-store。
这条命令历史上覆写过 .env.local,导致四个孤本 sensitive 值当场丢失。
cp .env.local .env.local.bak # ⛔ 不做这步不许跑那条命令拿不到 BLOB_READ_WRITE_TOKEN → 走 04 §1 决策点 D2 的降级方案。
#4. 文档解析选型(serverless 友好)
| 格式 | 包 | 为什么 | 出处怎么保 |
|---|---|---|---|
unpdf |
unjs 出品,内置去掉 canvas 的 pdf.js,零原生依赖,官方测过 Vercel Functions。⛔ 不要用 pdf-parse(依赖原生 canvas,serverless 会崩)也不要裸用 pdfjs-dist |
extractText(pdf, { mergePages: false }) 返回按页数组,下标+1 = 页码 |
|
| docx | mammoth |
成熟稳定 | .docx 没有物理页码(分页是渲染时算的)→ 用段落序号 + 标题层级 heading_path |
| xlsx | exceljs |
⚠️ ⛔ 不要 npm i xlsx —— npm 上的 SheetJS 停更多年,最新只有 0.18.5,带已知原型污染/DoS 漏洞(官方新版只发在自家 CDN)。exceljs 正常维护、内存占用更低 |
worksheet.eachRow((row, rowNumber) => ...),Sheet名!行号 |
| txt/md | 原生 TextDecoder |
— | 按标题分节 |
npm i unpdf mammoth exceljs#4.1 PDF 解析 + 扫描件检测(对应 AC-2.2.5)
// lib/parse/pdf.ts —— 逐页提取,同时检测"疑似扫描件"
import { extractText, getDocumentProxy } from 'unpdf'
export async function parsePdf(buffer: ArrayBuffer) {
const pdf = await getDocumentProxy(new Uint8Array(buffer))
const { totalPages, text } = await extractText(pdf, { mergePages: false })
const pages = text.map((t, i) => ({ pageNumber: i + 1, text: t }))
// 扫描件(图片型 PDF)没有文字层,提取出来几乎是空的。
// 判据:超过 60% 的页面非空白字符数 < 20 → 判定扫描件,不做 OCR,直接报错态。
const empty = pages.filter(p => p.text.replace(/\s/g, '').length < 20).length
const likelyScanned = totalPages > 0 && empty / totalPages > 0.6
return { totalPages, pages, likelyScanned }
}likelyScanned === true → parse_status = 'failed',parse_error = '疑似扫描件/图片型 PDF,暂不支持 OCR,请上传可选中文字的版本',UI 用警告态展示。
⛔ 不许静默成功(那样用户以为传成功了,其实库里是空的)。
#4.2 xlsx 要点
row.values 的下标 0 是空占位,从 1 开始才是第一列 → 记得 .slice(1)。
每个 chunk 必须带上表头行,否则脱离表头的数字没有语义。
#5. 长任务架构
#5.1 先纠正一个过时说法
Vercel Hobby 的函数超时现在是 300 秒(5 分钟),不是老文章里说的 10s/60s —— Fluid Compute 已对新项目默认开启,Hobby 内存上限 2GB。
(waitUntil() / after() 里的后台代码仍然算在同一次调用的 maxDuration 预算内,不是绕过超时的手段。)
#5.2 选定方案:前端轮询驱动分步处理
即便 300 秒理论够跑完一份 50 页 PDF,也不要一把梭。理由:任何一步失败就前功尽弃、没有断点续传、进度条卡住不知道卡在哪。
架构:前端拿到 fileId 后循环调用 POST /api/files/[id]/step,每次只做一小块,返回当前状态和进度,直到 done / failed。
pending → parsing (下载 + 解析 + 切片 + 落 chunk,embedding_status 全 pending)
parsing → chunking (落库完成)
chunking → embedding
embedding→ embedding (每次取 N=40 条 pending chunk 做 embedding,取不到就转 done)
→ done
任何一步异常 → failed(保留 parse_error)断点续传是天然的:每次只取 embedding_status = 'pending' 的 chunk,已完成的不会重算。用户关掉浏览器再回来,接着轮询即可。
// app/api/files/[id]/step/route.ts 的骨架
export const runtime = 'nodejs'
export const preferredRegion = 'hnd1' // 东京,就近连 Leo-hub
export const maxDuration = 300
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params // Next 16:必须 await
// ...按当前 parse_status 分支处理一小步,返回 { status, progress }
}前端 hook 每次响应后 setTimeout(step, 300) 继续,直到 done / failed。
⚠️ 兜底:如果用户传完就关页面,任务会停在原地。今晚不做 Cron 兜底(Hobby 的 Cron 每天只能跑 1 次),改成:文件列表页对非终态文件显示「继续处理」按钮,点一下续跑。这也是一条可测的 UI 行为。
#6. embedding 批量调用
// lib/embed.ts —— 批量 + 指数退避 + 全抖动
const BASE = process.env.YUNWU_API_BASE! // https://yunwu.ai/v1
const KEY = process.env.YUNWU_API_KEY!
const MODEL = process.env.EMBEDDING_MODEL || 'text-embedding-3-small'
export async function embedBatch(texts: string[]): Promise<number[][]> {
if (!texts.length) return []
let lastErr: unknown
for (let attempt = 0; attempt <= 5; attempt++) {
try {
const res = await fetch(`${BASE}/embeddings`, {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, input: texts }),
})
if (res.status === 429 || res.status >= 500) throw new Error(`embedding ${res.status}`)
if (!res.ok) {
const t = await res.text()
throw Object.assign(new Error(`embedding ${res.status}: ${t}`), { retryable: false })
}
const data = await res.json()
return (data.data as { embedding: number[] }[]).map(d => d.embedding)
} catch (e) {
lastErr = e
if ((e as { retryable?: boolean }).retryable === false || attempt === 5) break
// 全抖动退避:random(0, min(500 * 2^n, 15000))
await new Promise(r => setTimeout(r, Math.random() * Math.min(500 * 2 ** attempt, 15000)))
}
}
throw lastErr
}- 单批 30–50 条,单条 chunk ≤ 800 token
- 并发用
p-limit控在 3–5 - 失败的 chunk 留
embedding_status='failed'+embedding_retry_count,超 5 次就放弃这条继续跑,⛔ 不要卡死整份文件(AC-2.3.2)
测试怎么写:集成测试里不打真实 embedding API,用固定 fixture 向量(这是 02-TDD规程 §3 明确允许 mock 的少数几项之一)。但解析和入库必须真跑。
#7. 界面要点(浅色干净风)
#7.1 硬性可测项(写成 E2E 断言)
body背景#f6f7f9系;卡片#ffffff;二者不同(AC-7.1.2)- 1280px 宽无横向滚动(AC-7.1.3)
- 五个页面在空数据时都有空态文案,⛔ 不许白屏(AC-7.2.6)
- 所有 API 错误走 Sonner toast,⛔ 不许静默(AC-7.3.1)
- 列表加载中显示 Skeleton(AC-7.3.2)
#7.2 视觉规则
- 单一强调色 indigo
#6366f1,其余全灰阶;状态色只用 shadcn 的 destructive - 圆角统一
0.625rem;表格行高 ≥ 44px;留白宁多勿少 - 中文字体栈必须含
"PingFang SC" - 交接页是主场,允许一点克制动效(勾选时飞入右栏、确认成功微动画)
- ⛔ 深色科技风 / 霓虹 / 玻璃拟态 / 大面积渐变
#7.3 种子数据(P1 就要有)
三个员工:王销售(华东区销售,家居建材,负责宏远建材)、李销售(接手王销售客户)、赵采购(与销售客户无关,用来验证隔离)。 虚构文档 3–5 份:报价单、合同、客户资料、供应商清单。 记忆条目 12–20 条,覆盖五个分类。 ⚠️ 全部虚构,⛔ 不要用任何真实公司/人名。
#8. 坑清单
- 413 FUNCTION_PAYLOAD_TOO_LARGE → 走 Blob 客户端直传,⛔ 不要用 Server Action 收文件。
- 本地
onUploadCompleted不触发 → Blob 回调不到 localhost。建档改为前端显式调接口。 - PDF 在 Vercel 上报 canvas 错误 → 换
unpdf。 npm i xlsx装到带漏洞的 0.18.5 → 换exceljs。- 函数体积超 250MB / 构建报错 →
serverExternalPackages里加unpdfmammothexceljs。 params同步解构报错 → Next 16 全部要await。- 密码门本地失效 →
.env.local里必须补HUB_SITE_PASSWORD/HUB_AUTH_SECRET,否则 proxy 拦一切、/api/login返 503,E2E 全跑不了。 NEXT_PUBLIC_前缀误用 → 服务端密钥一律不加这个前缀,否则被打进浏览器 bundle。- Vercel Framework Preset 不自动改 → 历史坑:Vite 迁 Next 后 preset 没改,线上静默 404。新建项目一般没这问题,但部署后必须真的打开线上 URL 确认,别只看 "Ready"。
- shadcn init 问 Tailwind config 路径 → Tailwind v4 下留空。
- Vercel 环境变量三套环境 → Production / Preview / Development 都要配,漏一套预览就崩。
- sensitive 变量写进去读不回明文 → 自己生成的密钥先在本地
.env.local留一份再写上去。 - 中文文本噪声 → 解析出来常带控制字符、多余空白、
#REF!之类。切片前过一遍normalizeText(),空 chunk 直接丢。 - Playwright 跑不起来 → 记得
npx playwright install chromium。
来源:沉淀/03-项目方案与交接/接棒-通宵施工包-20260731/05-技术资料-前端与流水线.md(整理于 2026-08-18)