为 MS2Int 模型构建一个 Web 界面,用户可通过浏览器输入肽段序列及参数,在线预测碎片离子强度,并以交互式谱图展示结果。
| 层 | 技术 | 理由 |
|---|---|---|
| Backend | FastAPI (Python) | 与 predict.py / PyTorch 同语言,可直接复用模型代码 |
| Frontend | React + Vite + TailwindCSS v4 | 现代 SPA,开发体验好,构建快 |
| 谱图渲染 | spectrum_utils (后端) | 专业质谱可视化库,支持 ProForma 注释、b/y ion 着色、mirror plot |
| 通信 | REST API (JSON + PNG) | 单条同步返回 JSON+图片;批量异步返回 job_id |
| 异步任务 | asyncio + 后台线程 | 批量推理异步执行,避免阻塞 API |
| 部署 | 单机 GPU 服务器 | 模型推理需要 CUDA |
| 模式 | 入口 | 交互方式 | 输出 |
|---|---|---|---|
| Single (体验模式) | 输入一条肽段序列 + 参数 | 同步,秒级返回 | 1 张谱图 (PNG) + 可展开离子表 |
| Batch (批量模式) | 上传 CSV/TSV 文件 | 异步,返回 job_id + 预估时间 | 稍后通过 job_id 下载结果 (H5/CSV) |
┌──────────────────────────────────────────────────┐
│ React Frontend │
│ ┌─────────────┐ ┌───────────────────────────┐ │
│ │ Single Mode │ │ Batch Mode │ │
│ │ PeptideForm │ │ CsvUpload → JobStatus │ │
│ │ SpectrumImg │ │ JobHistory → Download │ │
│ │ IonTable │ │ │ │
│ └──────┬──────┘ └──────────┬────────────────┘ │
│ │ │ │
│ POST /api/predict POST /api/jobs/submit │
│ (同步) GET /api/jobs/{id} │
│ │ │ │
├─────────┴────────────────────┴───────────────────┤
│ FastAPI Backend │
│ ┌────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ Predictor │ │ Spectrum │ │ Job Queue │ │
│ │ (GPU Model)│ │ Renderer │ │ (async) │ │
│ │ │ │ (spec_utils)│ │ │ │
│ └────────────┘ └─────────────┘ └────────────┘ │
└──────────────────────────────────────────────────┘
webserver/
├── PLAN.md # 本文件
├── backend/
│ ├── app.py # FastAPI 主入口 + 路由
│ ├── predictor.py # 模型加载 & 推理封装
│ ├── spectrum_render.py # spectrum_utils 谱图渲染 → PNG
│ ├── ion_labels.py # 离子标签 + 理论 m/z 计算
│ ├── job_manager.py # 异步任务队列 (batch 模式)
│ ├── schemas.py # Pydantic 请求/响应模型
│ ├── config.py # 配置 (模型路径、GPU、job 存储)
│ ├── jobs/ # 任务结果存储目录 (运行时生成)
│ └── requirements.txt # Python 依赖
└── frontend/
├── package.json
├── vite.config.ts
├── index.html
├── src/
│ ├── App.tsx # 根组件 + Tab 路由
│ ├── main.tsx # 入口
│ ├── api/
│ │ └── client.ts # API 调用封装
│ ├── components/
│ │ ├── Header.tsx # 页面头部
│ │ ├── SingleMode.tsx # 单条预测页面
│ │ ├── PeptideForm.tsx # 肽段输入表单
│ │ ├── SpectrumImage.tsx # 谱图图片展示 (<img>)
│ │ ├── IonTable.tsx # 可展开离子表
│ │ ├── BatchMode.tsx # 批量上传页面
│ │ ├── CsvUpload.tsx # CSV 拖拽上传
│ │ ├── JobStatus.tsx # 任务状态 + 进度
│ │ └── JobHistory.tsx # 历史任务列表
│ ├── types/
│ │ └── index.ts # TypeScript 类型
│ └── styles/
│ └── index.css # Tailwind v4 入口 + @theme
└── tsconfig.json
- 服务启动时加载模型到 GPU(
@app.lifespan) - 封装
predict_single(peptide: dict) -> PredictionResult和predict_batch(df: DataFrame) -> ndarray - 复用
MS2Int/下的preprocess.py、utils.py、model.py逻辑 - 输入验证:Length ≤ 30、Charge 1–6、collision_energy ∈ {10,20,23,25,26,27,28,29,30,35,40,42}、Fragmentation ∈ {HCD, CID}
import spectrum_utils.spectrum as sus
import spectrum_utils.plot as sup
import matplotlib.pyplot as plt
def render_spectrum_png(
sequence: str, # ProForma 格式, e.g. "ALLS[Phospho]LATHK"
charge: int,
predicted_intensities: np.ndarray, # 模型输出的 29×31 矩阵
theoretical_mz: np.ndarray, # 理论 m/z 29×31 矩阵
) -> bytes:
"""
用 spectrum_utils 生成标注谱图 PNG。
1. 从 predicted_intensities + theoretical_mz 构建 MsmsSpectrum
2. 用 annotate_proforma() 标注 b/y ions
3. 调用 sup.spectrum() 渲染
4. 返回 PNG bytes
"""- 利用
spectrum_utils.spectrum.MsmsSpectrum构建谱图对象 annotate_proforma(sequence, fragment_tol_mass, fragment_tol_mode, ion_types="by")spectrum_utils.plot.spectrum()生成 matplotlib Figure → 导出 PNG bytes- 颜色自动: b 离子蓝、y 离子红 (spectrum_utils 默认配色)
class JobManager:
jobs: dict[str, JobInfo] # job_id → JobInfo
def submit(self, csv_content: bytes, filename: str) -> JobInfo:
"""创建任务, 返回 job_id + 预估时间, 后台线程执行推理"""
def get_status(self, job_id: str) -> JobInfo:
"""查询任务状态: pending/running/completed/failed"""
def get_result(self, job_id: str) -> Path:
"""返回结果文件路径 (H5 或 CSV)"""- 预估时间:
N_samples * 0.005s(经验值, ~200 samples/sec on GPU) - 结果存储:
backend/jobs/{job_id}/目录下 - 任务保留: 24h 后自动清理
Single 模式 (同步):
| Method | Path | 描述 |
|---|---|---|
POST |
/api/predict |
单条肽段预测, 返回 JSON (ions + spectrum_png base64) |
GET |
/api/health |
健康检查 (模型是否就绪) |
GET |
/api/supported-modifications |
返回支持的修饰列表 |
Batch 模式 (异步):
| Method | Path | 描述 |
|---|---|---|
POST |
/api/jobs/submit |
上传 CSV/TSV, 返回 job_id + 预估时间 |
GET |
/api/jobs/{job_id} |
查询任务状态 + 进度 |
GET |
/api/jobs/{job_id}/download |
下载结果文件 (H5 或 CSV) |
GET |
/api/jobs |
列出所有任务 (带分页) |
Single 请求:
{
"sequence": "PEPTIDEK",
"charge": 2,
"collision_energy": 30,
"fragmentation": "HCD"
}Single 响应:
{
"sequence": "PEPTIDEK",
"charge": 2,
"collision_energy": 30,
"fragmentation": "HCD",
"length": 8,
"spectrum_png": "<base64 encoded PNG>",
"ions": [
{"label": "b1", "mz": 98.06, "intensity": 0.05, "type": "b"},
{"label": "y1", "mz": 147.11, "intensity": 0.85, "type": "y"}
]
}Batch Submit 响应:
{
"job_id": "a1b2c3d4",
"filename": "input.csv",
"total_samples": 1500,
"estimated_seconds": 7.5,
"status": "pending",
"created_at": "2025-01-15T10:30:00Z"
}Batch Status 响应:
{
"job_id": "a1b2c3d4",
"status": "running",
"progress": 0.6,
"processed": 900,
"total": 1500,
"elapsed_seconds": 4.2,
"estimated_remaining_seconds": 2.8
}- 输出矩阵 29×31 → 展开为带标签的离子列表
- b1–b29, b1²⁺–b29²⁺, y1–y29, y1²⁺–y29²⁺, 内部离子 m(start:end)
- 计算理论 m/z (复用
spectrum_utils.proforma解析 + 氨基酸残基质量表) - 过滤 intensity > 0 的离子返回前端
- 8-bit 复古风: 像素字体、直角边框、NES/SNES 风格配色
- 数据优先: 谱图和数据仍占主视觉空间,像素风是装饰层
- 游戏感交互: 按钮有按压效果 (box-shadow 位移)、hover 闪烁、8-bit 音效可选
- 无障碍: 像素字体仅用于标题/装饰,正文使用等宽像素字体保证可读性
Google Fonts:
- 标题/Logo: "Press Start 2P" (经典 8-bit 字体)
- 正文/数据: "VT323" (像素等宽,可读性好)
- 代码/序列: "Silkscreen" (紧凑像素风)
@import "tailwindcss";
@theme {
/* 像素风字体 */
--font-pixel-title: "Press Start 2P", monospace;
--font-pixel-body: "VT323", monospace;
--font-pixel-code: "Silkscreen", monospace;
/* NES 风格配色 */
--color-primary: #3040d0; /* NES 蓝 */
--color-primary-foreground: #fcfcfc; /* NES 白 */
--color-primary-hover: #5060e0;
--color-background: #f0f0e8; /* 复古米白 (像老式显示器) */
--color-foreground: #1a1a2e; /* 深蓝黑 */
--color-card: #fcfcfc;
--color-card-foreground: #1a1a2e;
--color-border: #888888; /* 像素灰边框 */
--color-border-light: #c0c0c0;
--color-border-dark: #404040;
--color-muted: #e8e8e0;
--color-muted-foreground: #606060;
--color-destructive: #d03030; /* NES 红 */
--color-success: #30a030; /* NES 绿 */
--color-warning: #e8a020; /* NES 黄 */
/* 谱图专用色 — 8-bit 风格 */
--color-ion-b: #3070f0; /* b 离子 — 像素蓝 */
--color-ion-y: #e03030; /* y 离子 — 像素红 */
--color-ion-internal: #909090; /* 内部离子 — 像素灰 */
/* 无圆角 — 像素风核心特征 */
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
}
@custom-variant dark (&:where(.dark, .dark *));
.dark {
--color-background: #0a0a1a; /* 深空蓝黑 */
--color-foreground: #e0e0d0;
--color-card: #14142a;
--color-card-foreground: #e0e0d0;
--color-border: #505070;
--color-border-light: #606080;
--color-border-dark: #303050;
--color-muted: #1a1a30;
--color-muted-foreground: #8080a0;
}/* 3D 凸起边框 — 用于 Card、Input */
@utility pixel-border-raised {
border: 3px solid var(--color-border);
box-shadow:
inset -3px -3px 0 0 var(--color-border-dark),
inset 3px 3px 0 0 var(--color-border-light);
}
/* 3D 凹陷边框 — 用于 Input focus、按下态 */
@utility pixel-border-inset {
border: 3px solid var(--color-border);
box-shadow:
inset 3px 3px 0 0 var(--color-border-dark),
inset -3px -3px 0 0 var(--color-border-light);
}
/* 像素阴影 — 用于按钮 */
@utility pixel-shadow {
box-shadow: 4px 4px 0 0 var(--color-border-dark);
}
/* 按钮按压效果 */
@utility pixel-shadow-pressed {
box-shadow: 2px 2px 0 0 var(--color-border-dark);
transform: translate(2px, 2px);
}后端 (requirements.txt):
| 库 | 用途 |
|---|---|
fastapi + uvicorn |
Web 框架 + ASGI 服务器 |
spectrum_utils[iplot] |
谱图渲染 (matplotlib backend) |
torch + mamba_ssm |
模型推理 |
h5py + pandas + numpy |
数据处理 |
pydantic v2 |
请求/响应校验 |
python-multipart |
文件上传 |
前端 (package.json):
| 库 | 用途 |
|---|---|
tailwindcss v4 |
CSS-first 样式 |
class-variance-authority (CVA) |
组件变体管理 |
clsx + tailwind-merge |
className 合并 |
react-dropzone |
文件拖拽上传 |
| Google Fonts | Press Start 2P / VT323 / Silkscreen |
╔══════════════════════════════════════════════════════╗
║ ★ MS2Int ★ [☀/☾] [GitHub] ║
║ ═══ Spectrum Prediction Tool ═══ ║
╠══════════════════════════════════════════════════════╣
║ ║
║ ┌─[▸ SINGLE]─┬─[ BATCH ]─┐ ║
║ │ ║
║ │ ╔═══ INPUT ══════════════════════════════════╗ ║
║ │ ║ SEQUENCE: ║ ║
║ │ ║ ┌──────────────────────────────────────┐ ║ ║
║ │ ║ │ PEPTIDEK_ │ ║ ║
║ │ ║ └──────────────────────────────────────┘ ║ ║
║ │ ║ ║ ║
║ │ ║ CHG:[2▾] CE:[30▾] FRAG:[■HCD][□CID] ║ ║
║ │ ║ ║ ║
║ │ ║ ╔══════════════════╗ ║ ║
║ │ ║ ║ ▶ PREDICT !! ║ ║ ║
║ │ ║ ╚══════════════════╝ ║ ║
║ │ ╚════════════════════════════════════════════╝ ║
║ │ ║
║ │ ╔═══ SPECTRUM ═══════════════════════════════╗ ║
║ │ ║ ║ ║
║ │ ║ ┌────────────────────────────────────┐ ║ ║
║ │ ║ │ │ ║ ║
║ │ ║ │ <img> spectrum_utils 渲染的 │ ║ ║
║ │ ║ │ 带标注的质谱图 (PNG) │ ║ ║
║ │ ║ │ b ions = 蓝, y ions = 红 │ ║ ║
║ │ ║ │ │ ║ ║
║ │ ║ └────────────────────────────────────┘ ║ ║
║ │ ║ ║ ║
║ │ ╚════════════════════════════════════════════╝ ║
║ │ ║
║ │ ╔═══ ION TABLE (▾ expand) ═══════════════════╗ ║
║ │ ║ ION │ M/Z │ INT │ TYPE ║ ║
║ │ ║ b1 │ 98.06 │ 0.05 │ ■ b ║ ║
║ │ ║ y1 │ 147.11 │ 0.85 │ ■ y ║ ║
║ │ ║ ... │ ... │ ... │ ... ║ ║
║ │ ╚════════════════════════════════════════════╝ ║
║ └───────────────────────────────────────────────────╝
╠══════════════════════════════════════════════════════╣
║ MS2Int © 2025 │ Powered by Mamba2 │ ★★★ ║
╚══════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════╗
║ ★ MS2Int ★ [☀/☾] [GitHub] ║
║ ═══ Spectrum Prediction Tool ═══ ║
╠══════════════════════════════════════════════════════╣
║ ║
║ ┌─[ SINGLE ]─┬─[▸ BATCH ]─┐ ║
║ │ ║
║ │ ╔═══ UPLOAD CSV/TSV ════════════════════════╗ ║
║ │ ║ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ║ ║
║ │ ║ │ │ ║ ║
║ │ ║ │ DROP CSV/TSV FILE HERE │ ║ ║
║ │ ║ │ or click to browse │ ║ ║
║ │ ║ │ │ ║ ║
║ │ ║ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ║ ║
║ │ ║ ║ ║
║ │ ║ FILE: input.csv (1500 rows) ║ ║
║ │ ║ PREVIEW: ║ ║
║ │ ║ │ Sequence │ Chg │ CE │ Frag │ ║ ║
║ │ ║ │ PEPTIDEK │ 2 │ 30 │ HCD │ ║ ║
║ │ ║ │ ALLS[Phospho]… │ 3 │ 27 │ HCD │ ║ ║
║ │ ║ ║ ║
║ │ ║ ╔══════════════════╗ ║ ║
║ │ ║ ║ ▶ SUBMIT JOB ║ ║ ║
║ │ ║ ╚══════════════════╝ ║ ║
║ │ ╚═══════════════════════════════════════════╝ ║
║ │ ║
║ │ ╔═══ JOB SUBMITTED ════════════════════════╗ ║
║ │ ║ JOB ID: a1b2c3d4 ║ ║
║ │ ║ SAMPLES: 1500 ║ ║
║ │ ║ EST. TIME: ~8 sec ║ ║
║ │ ║ STATUS: [████████░░] 80% RUNNING ║ ║
║ │ ╚══════════════════════════════════════════╝ ║
║ │ ║
║ │ ╔═══ JOB HISTORY ═════════════════════════╗ ║
║ │ ║ ID │ FILE │ STATUS │ ACTION ║ ║
║ │ ║ a1b2c3d4 │ input.csv │ ✓ DONE │ [↓] ║ ║
║ │ ║ e5f6g7h8 │ test.tsv │ RUNNING│ [···] ║ ║
║ │ ╚═════════════════════════════════════════╝ ║
║ └───────────────────────────────────────────────────╝
╠══════════════════════════════════════════════════════╣
║ MS2Int © 2025 │ Powered by Mamba2 │ ★★★ ║
╚══════════════════════════════════════════════════════╝
┌──────────────────┐
│ ▶ PREDICT !! │ ← 凸起态 (pixel-shadow)
└──────────────────┘
┌──────────────────┐
│ ▶ PREDICT !! │ ← 按压态 (pixel-shadow-pressed, translate 2px)
└──────────────────┘
| Variant | 样式 | 用途 |
|---|---|---|
default |
bg-primary text-primary-foreground pixel-shadow |
Predict 主按钮 |
outline |
border-3 border-border bg-card |
次要操作 |
ghost |
hover:bg-muted |
Tab 切换等 |
destructive |
bg-destructive text-white pixel-shadow |
清除/重置 |
| Size | 尺寸 |
|---|---|
sm |
h-8 px-3 text-xs font-pixel-body |
default |
h-10 px-5 text-sm font-pixel-body |
lg |
h-12 px-8 text-base font-pixel-title |
按钮交互:
- Hover: 轻微亮度提升 (
brightness-110) - Active:
pixel-shadow-pressed(阴影缩短 + 位移 2px) - Disabled:
opacity-50 cursor-not-allowed, 无阴影
- 基础:
h-10 border-3 border-border bg-card px-3 font-pixel-body text-sm pixel-border-inset - Focus:
border-primary(边框变蓝) - Error:
border-destructive+ 下方红色像素文字 - Placeholder:
text-muted-foreground(闪烁光标效果可选)
- Trigger: 与 Input 同风格 + 右侧
▾箭头 - Content:
bg-card border-3 border-border pixel-border-raised - Item hover:
bg-primary text-primary-foreground(整行高亮,类似游戏菜单) - Active item: 左侧加
▶指示符
border-3 border-border bg-card pixel-border-raised- Header:
px-4 py-3 border-b-3 border-border bg-muted font-pixel-title text-xs uppercase tracking-wider - Content:
p-4
- Active:
bg-card border-3 border-border border-b-0 font-pixel-body text-primary(底部与内容区融合) - Inactive:
bg-muted text-muted-foreground border-3 border-border - 效果: 类似老式文件夹标签
谱图不使用像素风格,而是直接使用 spectrum_utils 生成的学术级 PNG 图片。
spectrum_utils.plot.spectrum() 配置:
├── 渲染引擎: matplotlib (后端 Agg, headless)
├── 输出: PNG bytes → base64 编码传给前端
├── b 离子: 蓝色 (spectrum_utils 默认)
├── y 离子: 红色 (spectrum_utils 默认)
├── 标注: ion_types="by", 峰上标注离子名
├── 图片尺寸: figsize=(12, 6), dpi=150
├── 背景: 白色 (固定, 不跟随暗色主题)
└── 前端展示: <img src="data:image/png;base64,..." />
前端 SpectrumImage.tsx 组件:
- 直接用
<img>标签展示 base64 PNG - 像素面板包裹:
pixel-border-raised外框 - 无交互 (缩放/hover 不需要, spectrum_utils 图片已包含标注)
- 可右键另存为 PNG
- Logo: "★ MS2Int ★", 用 Press Start 2P 字体
- 分隔线:
═══双线字符 - Loading: 像素进度条
[████████░░] 80%,或 8-bit 旋转动画 - 成功提示:
✓ QUEST COMPLETE!风格 - 错误提示:
✗ ERROR! Invalid sequence红色像素框 - 空状态: "Press PREDICT to start your quest!" + 小像素分子图标
| 断点 | 宽度 | 布局 |
|---|---|---|
sm |
≥640px | 表单一列,像素字体缩小 |
md |
≥768px | 参数行横向排列 |
lg |
≥1024px | 谱图区域更大 |
xl |
≥1280px | 最大容器宽 max-w-5xl |
- Sequence: 全宽像素输入框,
font-pixel-code, placeholder=ENTER PEPTIDE SEQUENCE... - 参数行 (md 断点后横向排列):
- CHG: 像素 Select (1–6),
font-pixel-body, hover 时▶指示 - CE: 像素 Select (10, 20, 23, 25, 26, 27, 28, 29, 30, 35, 40, 42)
- FRAG: 像素切换按钮组
[■HCD] [□CID], 选中态bg-primary text-white
- CHG: 像素 Select (1–6),
- Predict 按钮:
variant=default size=lg font-pixel-title, 文字▶ PREDICT !! - Loading 状态: 像素进度条
[████░░░░░░] PREDICTING... - 错误提示:
✗ ERROR!红色像素框 (bg-destructive/10 border-3 border-destructive)
<img>标签,src="data:image/png;base64,{spectrum_png}"- 外层: 像素面板
pixel-border-raised, 标题═══ SPECTRUM ═══ - 图片宽度:
w-full max-w-3xl - 预测前: 空状态文字
"Press PREDICT to start your quest!" - 预测后: 显示 spectrum_utils 渲染的带标注质谱图 (学术风格, 非像素风)
- 默认折叠, 点击
▾ ION TABLE标题展开 - 像素表格头:
bg-muted border-3 border-border font-pixel-title text-xs uppercase - 列: ION, M/Z, INTENSITY, TYPE
- TYPE 列: 带颜色块
■(b=蓝, y=红, internal=灰) - 排序: 默认按 intensity 降序
- 仅显示 intensity > 0 的离子
- 拖拽区:
react-dropzone, 像素虚线边框border-3 border-dashed border-border, 内置文字DROP CSV/TSV FILE HERE - 文件预览: 上传后展示文件名 + 行数 + 前 3 行像素表格
- Submit 按钮:
▶ SUBMIT JOB, pixel-shadow
- 提交后立即显示
- 字段: JOB ID, SAMPLES (总条数), EST. TIME (~N sec), STATUS
- 进度条: 像素进度条
[████████░░] 80% RUNNING - 轮询: 每 2s 调用
GET /api/jobs/{job_id}更新进度 - 完成态:
✓ QUEST COMPLETE!+[↓ DOWNLOAD]按钮 - 失败态:
✗ ERROR!+ 错误信息
- 像素表格列出所有提交过的任务
- 列: ID (短), FILE, SAMPLES, STATUS, ACTION
- STATUS:
PENDING/RUNNING/✓ DONE/✗ FAILED - ACTION: 完成 →
[↓]下载按钮; 运行中 →[···]动画 - 任务列表存储在 localStorage (页面刷新不丢失)
- 左:
★ MS2Int ★(font-pixel-title, 带微弱闪烁动画可选) - 副标题:
═══ Spectrum Prediction Tool ═══(font-pixel-body, text-muted-foreground) - 右:
[☀/☾]暗色切换 +[GH]GitHub 链接, 均为像素按钮 ghost variant
- 两个标签:
[▸ SINGLE]/[▸ BATCH] - 像素标签风格, active 底部与内容区融合
- 切换时保留各自状态 (不重置表单/任务列表)
- 创建
backend/config.py— 模型路径、GPU 等配置 - 创建
backend/predictor.py— 封装模型加载 + 单条/批量推理 - 创建
backend/ion_labels.py— 离子标签 + 理论 m/z 计算 - 创建
backend/spectrum_render.py— spectrum_utils 谱图渲染 - 创建
backend/schemas.py— Pydantic 请求/响应模型 - 创建
backend/app.py— FastAPI/api/predict同步端点 - 创建
backend/requirements.txt - 验证: curl 调用
/api/predict返回 ions + spectrum_png
- 创建
backend/job_manager.py— 异步任务队列 - 实现
/api/jobs/submit— CSV 上传 + 预估时间 - 实现
/api/jobs/{job_id}— 状态查询 + 进度 - 实现
/api/jobs/{job_id}/download— 结果下载 - 实现
/api/jobs— 任务列表 - 验证: 上传 CSV → 获取 job_id → 轮询完成 → 下载
- 初始化 Vite + React + TypeScript + TailwindCSS v4
- 像素风 CSS 主题 (@theme + @utility)
- Header + Tab 导航
- PeptideForm 组件
- SpectrumImage 组件 (base64 PNG 展示)
- IonTable 组件 (可折叠)
- 连通: 表单提交 → API → 谱图 + 离子表
- CsvUpload 组件 (react-dropzone + 预览)
- JobStatus 组件 (进度轮询)
- JobHistory 组件 (localStorage 持久化)
- 下载功能
- 连通: 上传 → submit → 轮询 → 下载
- 错误处理 + 输入校验提示 (像素风 toast)
- Loading 状态 (像素进度条)
- 响应式布局适配
- 暗色模式
- README + 启动脚本 (
start.sh)
用于远程服务器上自动验证前后端功能完整性。headless 模式运行,无需 GUI。
webserver/frontend/
├── playwright.config.ts # Playwright 配置
├── tests/
│ ├── single-mode.spec.ts # Single 模式测试
│ ├── batch-mode.spec.ts # Batch 模式测试
│ ├── api-health.spec.ts # API 健康检查
│ └── pages/
│ ├── SingleModePage.ts # Page Object: Single 页面
│ └── BatchModePage.ts # Page Object: Batch 页面
playwright.config.ts 关键配置:
| 配置项 | 值 | 说明 |
|---|---|---|
testDir |
./tests |
测试文件目录 |
baseURL |
http://localhost:5173 |
前端 dev server |
projects |
chromium only |
远程服务器无需多浏览器 |
webServer[0] |
uvicorn app:app --port 8000 |
自动启动后端 |
webServer[1] |
npm run dev -- --port 5173 |
自动启动前端 |
retries |
1 | 失败重试一次 |
trace |
on-first-retry |
失败时记录 trace |
screenshot |
only-on-failure |
失败时自动截图 |
timeout |
30000 | 推理可能需要较长时间 |
| 用例 | 验证点 |
|---|---|
| GET /api/health | status=ok, model_loaded=true, device 包含 cuda |
| GET /api/supported-modifications | 返回非空数组, 包含 M[Oxidation], C[Carbamidomethyl] |
| 用例 | 操作 | 验证点 |
|---|---|---|
| 默认预测 | 保留默认值 PEPTIDEK/2+/30/HCD → 点击 PREDICT | 谱图 PNG 可见 + 离子表显示 + 行数 >0 |
| 修饰肽段 | 输入 [Acetyl]-ALLS[Phospho]LATHK, 3+, 25, HCD |
谱图标题包含序列 + 离子表有 b/y ions |
| CID 碎裂 | PEPTIDEK, 2+, 30, CID | 返回成功 + 谱图可见 |
| 所有电荷值 | 遍历 charge 1–6 | 每个电荷值都返回结果不报错 |
| 所有 CE 值 | 遍历所有 12 个 CE | 每个 CE 都返回结果不报错 |
| 空序列 | 清空输入框 → 点击 PREDICT | 按钮 disabled, 不发请求 |
| 无效序列 | 输入 123!!! |
显示 ERROR 提示 |
| 超长序列 | 输入 31 个氨基酸 | 显示长度超限错误 |
| 离子表折叠 | 预测后点击 SHOW ALL / COLLAPSE | 表格展开/折叠状态切换 |
| 离子类型过滤 | 点击 b / y / ALL 过滤按钮 | 表格行数正确变化 |
| 用例 | 操作 | 验证点 |
|---|---|---|
| CSV 上传 | 切换到 BATCH tab → 上传有效 CSV | 显示 JobStatus 卡片 + job_id |
| 进度轮询 | 上传后等待 | 进度条从 0 → 100%, 状态变为 COMPLETED |
| 结果下载 | 任务完成后点击 DOWNLOAD H5 | 响应 200 + Content-Type 包含 octet-stream |
| 任务历史 | 提交多个任务 | JobHistory 列表显示所有任务, 点击可切换查看 |
| 无效文件 | 上传 .txt 文件 | 提示仅支持 CSV/TSV |
| Tab 切换保留 | Single 预测后切到 Batch 再切回 | Single 结果仍在 |
// tests/pages/SingleModePage.ts
class SingleModePage {
// Locators
readonly sequenceInput; // pixel-input for peptide
readonly chargeSelect; // pixel-select for charge
readonly ceSelect; // pixel-select for CE
readonly fragSelect; // pixel-select for fragmentation
readonly predictButton; // pixel-btn PREDICT
readonly spectrumImage; // img in SpectrumImage card
readonly ionTable; // table in IonTable card
readonly errorCard; // ERROR card
readonly filterButtons; // type filter buttons (ALL/b/y/...)
readonly expandButton; // SHOW ALL / COLLAPSE
// Actions
async fillForm(seq, charge, ce, frag);
async predict();
async waitForResult();
async getIonCount();
async filterByType(type);
async toggleExpand();
}
// tests/pages/BatchModePage.ts
class BatchModePage {
readonly dropZone; // drag-drop upload area
readonly fileInput; // hidden file input
readonly submitButton; // SUBMIT BATCH JOB
readonly jobStatusCard; // JobStatus component
readonly progressBar; // pixel-progress-bar
readonly downloadButton; // DOWNLOAD H5 link
readonly jobHistory; // JobHistory list
readonly statusBadge; // status badge (PENDING/RUNNING/COMPLETED)
async uploadFile(filePath);
async waitForCompletion(timeout);
async getProgress();
async clickDownload();
async selectJob(jobId);
}webserver/frontend/tests/fixtures/
├── valid_3rows.csv # 3 行有效数据 (快速测试)
├── valid_20rows.csv # 20 行数据 (进度条测试)
├── invalid_columns.csv # 缺少必需列
└── empty.csv # 空文件
valid_3rows.csv 示例:
Sequence,Charge,collision_energy,Fragmentation
PEPTIDEK,2,30,HCD
ACDEFGHIK,3,25,HCD
LMNPQRSTVWY,2,35,CID# 安装 Playwright + 浏览器 (首次)
cd webserver/frontend
npm install -D @playwright/test
npx playwright install chromium
# 运行全部测试 (headless, 自动启动前后端)
npx playwright test
# 运行指定 suite
npx playwright test tests/single-mode.spec.ts
npx playwright test tests/batch-mode.spec.ts
# 带 UI 调试 (仅本地)
npx playwright test --ui
# 查看测试报告
npx playwright show-report
# 失败时查看 trace
npx playwright show-trace test-results/*/trace.zipnpm install -D @playwright/test+npx playwright install chromium- 创建
playwright.config.ts(双 webServer 配置) - 创建测试 fixtures (CSV 文件)
- 实现 Page Object:
SingleModePage.ts,BatchModePage.ts - 实现
api-health.spec.ts(最简单, 先跑通) - 实现
single-mode.spec.ts(核心功能) - 实现
batch-mode.spec.ts(异步流程) - 验证:
npx playwright test全部通过 - (可选) 添加到 CI/CD 或 cron 定时执行
- 模型参数: d_model=512, n_layer=4, Mamba2 架构
- 最大肽段长度: 30 个氨基酸
- 电荷范围: 1–6
- 碰撞能量离散值: 10, 20, 23, 25, 26, 27, 28, 29, 30, 35, 40, 42
- 碎裂方式: HCD, CID
- 输出维度: 29 × 31 (离子位置 × 离子类型)
- GPU: 推理需要 CUDA,模型常驻显存约 ~50MB
| 项 | 选择 | 理由 |
|---|---|---|
| 框架 | Playwright Test | 自动等待、跨浏览器、网络拦截、内置 runner |
| 语言 | TypeScript | 与前端统一 |
| 浏览器 | Chromium (主) + Firefox (辅) | Chromium 覆盖 Chrome/Edge,Firefox 覆盖兼容性 |
| 模式 | API Mock + 真实后端双模式 | Mock 模式用于 CI/快速迭代,真实模式用于集成验证 |
frontend/
├── playwright.config.ts # Playwright 配置
├── tests/
│ ├── fixtures/
│ │ └── test-base.ts # 自定义 fixture (mock API)
│ ├── pages/
│ │ ├── SingleModePage.ts # Single 模式 Page Object
│ │ └── BatchModePage.ts # Batch 模式 Page Object
│ ├── mocks/
│ │ └── api-handlers.ts # API mock 响应数据
│ ├── single-mode.spec.ts # Single 模式测试
│ ├── batch-mode.spec.ts # Batch 模式测试
│ ├── navigation.spec.ts # 导航 + Header 测试
│ └── visual.spec.ts # 视觉回归测试
└── test-results/ # 运行时生成 (gitignore)
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on', // 每次录制 trace (Trace Viewer 逐步回放)
video: 'on', // 每次录制 webm 视频
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 15_000,
},
});// tests/mocks/api-handlers.ts
export const MOCK_PREDICT_RESPONSE = {
sequence: "PEPTIDEK",
charge: 2,
collision_energy: 30,
fragmentation: "HCD",
length: 8,
spectrum_png: "<base64 1x1 PNG placeholder>",
ions: [
{ label: "b1", mz: 98.0600, intensity: 0.0500, type: "b" },
{ label: "y1", mz: 147.1128, intensity: 0.8500, type: "y" },
{ label: "y2", mz: 275.1714, intensity: 0.6200, type: "y" },
{ label: "b2", mz: 227.1026, intensity: 0.3100, type: "b" },
{ label: "y3", mz: 388.2554, intensity: 0.4800, type: "y" },
],
};
export const MOCK_JOB_SUBMIT_RESPONSE = {
job_id: "test-job-001",
filename: "test_input.csv",
total_samples: 100,
estimated_seconds: 0.5,
status: "pending",
created_at: "2025-01-15T10:30:00Z",
};
export const MOCK_JOB_STATUS_RUNNING = {
job_id: "test-job-001",
status: "running",
progress: 0.6,
processed: 60,
total: 100,
elapsed_seconds: 0.3,
estimated_remaining_seconds: 0.2,
error: null,
filename: "test_input.csv",
created_at: "2025-01-15T10:30:00Z",
};
export const MOCK_JOB_STATUS_COMPLETED = {
...MOCK_JOB_STATUS_RUNNING,
status: "completed",
progress: 1.0,
processed: 100,
elapsed_seconds: 0.5,
estimated_remaining_seconds: 0,
};
export const MOCK_HEALTH = {
status: "ok",
model_loaded: true,
device: "cuda:0",
};// tests/pages/SingleModePage.ts
import { Page, Locator } from '@playwright/test';
export class SingleModePage {
readonly page: Page;
readonly singleTab: Locator;
readonly sequenceInput: Locator;
readonly chargeSelect: Locator;
readonly ceSelect: Locator;
readonly fragSelect: Locator;
readonly predictButton: Locator;
readonly spectrumImage: Locator;
readonly ionTable: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.singleTab = page.locator('.pixel-tab', { hasText: 'SINGLE' });
this.sequenceInput = page.locator('.pixel-input');
this.chargeSelect = page.locator('.pixel-select').first();
this.ceSelect = page.locator('.pixel-select').nth(1);
this.fragSelect = page.locator('.pixel-select').nth(2);
this.predictButton = page.locator('.pixel-btn-primary', { hasText: /PREDICT/ });
this.spectrumImage = page.locator('img[alt*="spectrum"]');
this.ionTable = page.locator('table');
this.errorMessage = page.locator('text=ERROR').locator('..');
}
async goto() {
await this.page.goto('/');
await this.singleTab.click();
}
async fillForm(params: {
sequence?: string;
charge?: string;
ce?: string;
frag?: string;
}) {
if (params.sequence !== undefined) {
await this.sequenceInput.clear();
await this.sequenceInput.fill(params.sequence);
}
if (params.charge) await this.chargeSelect.selectOption(params.charge);
if (params.ce) await this.ceSelect.selectOption(params.ce);
if (params.frag) await this.fragSelect.selectOption(params.frag);
}
async predict() {
await this.predictButton.click();
}
}// tests/pages/BatchModePage.ts
import { Page, Locator } from '@playwright/test';
export class BatchModePage {
readonly page: Page;
readonly batchTab: Locator;
readonly dropZone: Locator;
readonly fileInput: Locator;
readonly submitButton: Locator;
readonly jobStatus: Locator;
readonly jobHistory: Locator;
readonly downloadButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.batchTab = page.locator('.pixel-tab', { hasText: 'BATCH' });
this.dropZone = page.locator('.border-dashed');
this.fileInput = page.locator('input[type="file"]');
this.submitButton = page.locator('.pixel-btn-primary', { hasText: /SUBMIT/ });
this.jobStatus = page.locator('text=JOB:').locator('..');
this.jobHistory = page.locator('text=JOB HISTORY').locator('..');
this.downloadButton = page.locator('a', { hasText: 'DOWNLOAD' });
this.errorMessage = page.locator('text=ERROR').locator('..');
}
async goto() {
await this.page.goto('/');
await this.batchTab.click();
}
async uploadFile(filePath: string) {
await this.fileInput.setInputFiles(filePath);
}
async submit() {
await this.submitButton.click();
}
}test.describe('Navigation & Header', () => {
test('页面加载 — 标题、Header、默认 Tab', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/MS2Int/);
await expect(page.locator('text=★ MS2Int ★')).toBeVisible();
await expect(page.locator('.pixel-tab-active')).toContainText('SINGLE');
});
test('Tab 切换 — Single ↔ Batch', async ({ page }) => {
await page.goto('/');
// 默认 Single
await expect(page.locator('text=PEPTIDE SEQUENCE')).toBeVisible();
// 切换到 Batch
await page.locator('.pixel-tab', { hasText: 'BATCH' }).click();
await expect(page.locator('text=UPLOAD CSV')).toBeVisible();
// 切回 Single
await page.locator('.pixel-tab', { hasText: 'SINGLE' }).click();
await expect(page.locator('text=PEPTIDE SEQUENCE')).toBeVisible();
});
test('暗色模式切换', async ({ page }) => {
await page.goto('/');
const html = page.locator('html');
await expect(html).not.toHaveClass(/dark/);
await page.locator('button[title="Toggle dark mode"]').click();
await expect(html).toHaveClass(/dark/);
await page.locator('button[title="Toggle dark mode"]').click();
await expect(html).not.toHaveClass(/dark/);
});
test('GitHub 链接存在且 target=_blank', async ({ page }) => {
await page.goto('/');
const ghLink = page.locator('a[title="GitHub"]');
await expect(ghLink).toHaveAttribute('target', '_blank');
});
});test.describe('Single Mode — Mock API', () => {
test.beforeEach(async ({ page }) => {
// Mock /api/predict
await page.route('**/api/predict', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_PREDICT_RESPONSE),
})
);
await page.goto('/');
});
test('默认表单值正确', async ({ page }) => {
const sp = new SingleModePage(page);
await expect(sp.sequenceInput).toHaveValue('PEPTIDEK');
await expect(sp.chargeSelect).toHaveValue('2');
await expect(sp.ceSelect).toHaveValue('30');
await expect(sp.fragSelect).toHaveValue('HCD');
});
test('提交 → 显示谱图 + 离子表', async ({ page }) => {
const sp = new SingleModePage(page);
await sp.predict();
await expect(sp.spectrumImage).toBeVisible();
await expect(sp.ionTable).toBeVisible();
// 验证离子表有数据行
const rows = page.locator('table tbody tr');
await expect(rows).toHaveCount(5);
});
test('离子表类型过滤', async ({ page }) => {
const sp = new SingleModePage(page);
await sp.predict();
// 点击 "B" 过滤按钮
await page.locator('button', { hasText: 'B' }).click();
const rows = page.locator('table tbody tr');
await expect(rows).toHaveCount(2); // b1 + b2
});
test('空序列时 Predict 按钮禁用', async ({ page }) => {
const sp = new SingleModePage(page);
await sp.sequenceInput.clear();
await expect(sp.predictButton).toBeDisabled();
});
test('API 错误 → 显示 ERROR 信息', async ({ page }) => {
await page.route('**/api/predict', route =>
route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Sequence too long (max 30 AA)' }),
})
);
const sp = new SingleModePage(page);
await sp.predict();
await expect(page.locator('text=ERROR')).toBeVisible();
await expect(page.locator('text=Sequence too long')).toBeVisible();
});
test('Predict 中按钮显示 Loading 态', async ({ page }) => {
// 设置延迟响应
await page.route('**/api/predict', async route => {
await new Promise(r => setTimeout(r, 1000));
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_PREDICT_RESPONSE),
});
});
const sp = new SingleModePage(page);
await sp.predict();
await expect(sp.predictButton).toContainText('PREDICTING');
await expect(sp.predictButton).toBeDisabled();
});
test('修改参数后重新预测', async ({ page }) => {
const sp = new SingleModePage(page);
await sp.fillForm({ sequence: 'ACDEFGH', charge: '3', ce: '25', frag: 'CID' });
await sp.predict();
// 验证请求参数正确 (通过拦截)
await expect(sp.spectrumImage).toBeVisible();
});
});test.describe('Batch Mode — Mock API', () => {
test.beforeEach(async ({ page }) => {
// Mock submit
await page.route('**/api/jobs/submit', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_JOB_SUBMIT_RESPONSE),
})
);
await page.goto('/');
await page.locator('.pixel-tab', { hasText: 'BATCH' }).click();
});
test('Drop zone 可见 + 文件选择', async ({ page }) => {
const bp = new BatchModePage(page);
await expect(bp.dropZone).toBeVisible();
await expect(bp.submitButton).toBeDisabled();
});
test('上传 CSV → 显示文件信息 → Submit', async ({ page }) => {
const bp = new BatchModePage(page);
// Mock job status 轮询
let pollCount = 0;
await page.route('**/api/jobs/test-job-001', route => {
pollCount++;
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
pollCount < 3 ? MOCK_JOB_STATUS_RUNNING : MOCK_JOB_STATUS_COMPLETED
),
});
});
await bp.uploadFile('tests/fixtures/test_input.csv');
await expect(page.locator('text=test_input.csv')).toBeVisible();
await bp.submit();
// JobStatus 出现
await expect(page.locator('text=JOB:')).toBeVisible();
// 等待完成
await expect(bp.downloadButton).toBeVisible({ timeout: 10_000 });
});
test('拒绝非 CSV/TSV 文件', async ({ page }) => {
// 使用 dialog 监听
page.on('dialog', async dialog => {
expect(dialog.message()).toContain('CSV/TSV');
await dialog.accept();
});
await page.locator('input[type="file"]').setInputFiles({
name: 'test.xlsx',
mimeType: 'application/vnd.ms-excel',
buffer: Buffer.from('fake'),
});
});
test('Job History — localStorage 持久化', async ({ page }) => {
const bp = new BatchModePage(page);
await page.route('**/api/jobs/test-job-001', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_JOB_STATUS_COMPLETED),
})
);
await bp.uploadFile('tests/fixtures/test_input.csv');
await bp.submit();
await expect(page.locator('text=JOB HISTORY')).toBeVisible();
// 刷新页面,history 仍在
await page.reload();
await page.locator('.pixel-tab', { hasText: 'BATCH' }).click();
await expect(page.locator('text=test_input.csv')).toBeVisible();
});
test('Job History — 删除记录', async ({ page }) => {
// 先提交一个 job 使 history 非空
const bp = new BatchModePage(page);
await page.route('**/api/jobs/test-job-001', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_JOB_STATUS_COMPLETED),
})
);
await bp.uploadFile('tests/fixtures/test_input.csv');
await bp.submit();
await expect(page.locator('text=JOB HISTORY')).toBeVisible();
// 删除
await page.locator('button[title="Remove from history"]').click();
await expect(page.locator('text=No batch jobs submitted yet')).toBeVisible();
});
test('Submit 失败 → 显示 ERROR', async ({ page }) => {
await page.route('**/api/jobs/submit', route =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Internal server error' }),
})
);
const bp = new BatchModePage(page);
await bp.uploadFile('tests/fixtures/test_input.csv');
await bp.submit();
await expect(page.locator('text=ERROR')).toBeVisible();
});
});test.describe('Visual Regression', () => {
test('Single 模式 — 空状态截图', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('single-empty.png', {
maxDiffPixelRatio: 0.05,
});
});
test('Single 模式 — 暗色主题截图', async ({ page }) => {
await page.goto('/');
await page.locator('button[title="Toggle dark mode"]').click();
await expect(page).toHaveScreenshot('single-empty-dark.png', {
maxDiffPixelRatio: 0.05,
});
});
test('Batch 模式 — 空状态截图', async ({ page }) => {
await page.goto('/');
await page.locator('.pixel-tab', { hasText: 'BATCH' }).click();
await expect(page).toHaveScreenshot('batch-empty.png', {
maxDiffPixelRatio: 0.05,
});
});
});tests/fixtures/
├── test_input.csv # 5 行有效 CSV (Sequence,Charge,collision_energy,Fragmentation)
└── test-base.ts # 自定义 fixture (预置 mock)
test_input.csv 内容:
Sequence,Charge,collision_energy,Fragmentation
PEPTIDEK,2,30,HCD
ACDEFGH,3,25,HCD
KLMNPQR,2,27,CID
STUVWXY,4,35,HCD
AAAAAA,1,20,CIDnpm install -D @playwright/test+npx playwright install chromium firefox- 创建
playwright.config.ts - 创建
tests/mocks/api-handlers.ts— Mock 数据 - 创建
tests/pages/SingleModePage.ts+BatchModePage.ts— Page Objects - 创建
tests/fixtures/test_input.csv+test-base.ts - 编写
tests/navigation.spec.ts— 4 个用例 - 编写
tests/single-mode.spec.ts— 7 个用例 - 编写
tests/batch-mode.spec.ts— 6 个用例 - 编写
tests/visual.spec.ts— 3 个视觉回归用例 - 运行
npx playwright test验证全部通过 - 添加
npm run test:e2escript 到package.json
# 全部测试
npx playwright test
# 指定浏览器
npx playwright test --project=chromium
# UI 模式 (交互式调试)
npx playwright test --ui
# 仅 single 模式测试
npx playwright test tests/single-mode.spec.ts
# 更新视觉快照
npx playwright test --update-snapshots
# 查看报告 (远程服务器需绑定 0.0.0.0)
npx playwright show-report --host 0.0.0.0 --port 9323
# 查看 Trace (逐步 DOM 快照回放)
npx playwright show-trace test-results/<test-name>/trace.zip --host 0.0.0.0 --port 9324
# 视频文件位于 test-results/<test-name>/ 目录下 (.webm 格式)| 产物 | 路径 | 查看方式 |
|---|---|---|
| HTML 报告 | playwright-report/ |
show-report --host 0.0.0.0 --port 9323 → 浏览器访问 |
| Trace 文件 | test-results/*/trace.zip |
show-trace <path> --host 0.0.0.0 --port 9324 → 逐步回放 |
| 视频录制 | test-results/*/*.webm |
直接下载/scp 到本地播放 |
| 失败截图 | test-results/*/*.png |
HTML 报告中内嵌,或直接查看文件 |
注意: 打开 5173 端口可以看到前端页面本身,但 无法实时观测 Playwright 的操作过程。 Playwright 在无头模式下操作的是内部 Chromium 实例(不是你浏览器打开的页面), 两者互不干扰。要观察测试流程,需通过 Trace Viewer 回放或视频录制。
| 组件 | 测试点 | 用例数 |
|---|---|---|
| Header | 标题、暗色切换、GitHub 链接 | 3 |
| Tab 导航 | Single ↔ Batch 切换 | 1 |
| PeptideForm | 默认值、空序列禁用、参数修改 | 3 |
| SpectrumImage | 提交后显示、base64 渲染 | 1 |
| IonTable | 数据展示、类型过滤 | 2 |
| CsvUpload | 文件选择、拒绝非法格式 | 2 |
| JobStatus | 轮询进度、完成态、下载按钮 | 1 |
| JobHistory | 持久化、删除记录 | 2 |
| 错误处理 | API 422/500 → ERROR 展示 | 2 |
| Loading 态 | Predict 中按钮禁用 + 文字变化 | 1 |
| 视觉回归 | 空状态、暗色主题截图 | 3 |
| 合计 | ~20 |