61 Commits

Author SHA1 Message Date
caoxiaozhu
746d1118df style: 登录页高度与滚动行为修正
登录页主区域改为 height 100% 避免内部双滚动条,移动端恢复 min-height 并允许整体滚动。
2026-07-14 16:19:16 +08:00
caoxiaozhu
6e1197d321 docs: 更新视觉与权限设计走查记录
补充本期用户与权限、登录重构、看板优化等改动的视觉与交互走查记录。
2026-07-14 16:11:07 +08:00
caoxiaozhu
a70cf6addd refactor: Dashboard 看板布局与图表优化
DashboardView 调整看板卡片与栅格展示,ECharts 补充图表类型,主布局 dashboard 区域由内嵌滚动改为自适应可滚动以避免内容被裁剪,回归脚本适配。
2026-07-14 16:10:59 +08:00
caoxiaozhu
8b83b8facf refactor: 登录页重构为双栏视觉布局
左侧新增品牌展示区与平台能力介绍,右侧保留登录表单并优化可访问性属性,Element Plus 链接/文本按钮统一样式与焦点表现。
2026-07-14 16:10:55 +08:00
caoxiaozhu
e09c6e81df feat: 用户与权限管理
新增 PermissionCode 权限类型与用户/权限 API,路由注册用户设置/创建/权限页与无权访问页并接入权限守卫,AppSidebar 按权限过滤菜单并新增用户设置入口,auth store 与 mock 适配器同步支持用户管理与新登录认证,回归脚本与 npm 脚本注册。
2026-07-14 16:10:50 +08:00
caoxiaozhu
2bceb686c6 feat: 数据集版本管理支持删除历史版本
DatasetVersionList 新增 next_version_number,API 与 Mock 补充删除版本接口与并发控制,DatasetVersionBar 增加删除操作并对初始版本与最后版本禁用,DatasetPreviewView 接入删除流程,回归脚本与设计走查同步。
2026-07-14 14:47:12 +08:00
caoxiaozhu
acbd6d91b3 feat: 评测创建支持基础指标配置与启动参数类型化
新增 StartEvalPayload 与 BasicEvalMetricsConfig 类型约束启动评测接口,创建向导补充 BLEU、ROUGE、余弦相似度等基础指标配置,维度表单与规则步骤同步调整,回归脚本扩充断言。
2026-07-14 09:40:17 +08:00
caoxiaozhu
762d48d090 feat: 数据处理向导新增模型配置步骤
StepId 新增 model 步骤,DataProcessCreateView 增加模型选择与生成参数编排,各选项面板与样式适配新步骤流程,回归脚本补充断言。
2026-07-13 17:12:49 +08:00
caoxiaozhu
42f2efb449 refactor: 评测创建步骤导航条结构优化
步骤连接符独立为单独元素并随完成状态高亮,修复窄屏连接线显示。
2026-07-13 17:12:49 +08:00
caoxiaozhu
8c1a0d785c chore: 模型微调更名为模型训练
侧边栏菜单标签与路由标题统一由模型微调改为模型训练。
2026-07-13 16:43:37 +08:00
caoxiaozhu
66eac77c96 feat: 数据处理向导默认数据类型改为结构化
创建页 processType 默认值与草稿恢复兜底由非结构化调整为结构化,更贴合主流使用场景。
2026-07-13 16:41:42 +08:00
caoxiaozhu
d8ba048c6d chore: 调整数据治理菜单顺序
数据集管理移至数据处理之前,与实际使用优先级对齐。
2026-07-13 16:41:42 +08:00
caoxiaozhu
c9ddc3b652 refactor: 数据处理向导生成选项改为平铺布局
移除生成选项面板的高级设置折叠抽屉,温度、最大输出长度、JSON 模式等配置直接平铺展示,简化非结构化与任务设置步骤的嵌套结构,列表新建按钮文案精简。
2026-07-13 16:39:16 +08:00
caoxiaozhu
0917206191 refactor: 优化数据处理向导配置面板布局
数据集划分编辑器调整宽屏与窄屏布局,结构化预处理选项修正 flex 占满,非结构化智能预处理由开关改为复选卡片并补充可访问性标签。
2026-07-13 16:16:50 +08:00
caoxiaozhu
aea0d51b44 test: 新增统一回归运行器
新增 run-regressions.mjs 一键执行全部回归脚本,package.json 注册 npm test 入口与 test:fine-tune-create,page-surface 脚本适配统一运行。
2026-07-13 15:31:01 +08:00
caoxiaozhu
3fabd0c0eb refactor: 数据集预览组件化
拆分 DatasetVersionBar、DatasetRecordTable、DatasetRawPreview、DatasetRecordEditorDialog 子组件,DatasetPreviewView 聚焦编排,预览类型独立到 preview/types.ts,回归脚本适配。
2026-07-13 15:30:11 +08:00
caoxiaozhu
e212de1693 refactor: 训练日志组件化与 Mock 数据增强
拆分 TrainingTaskOverview 组件与 trainingLogModel 状态模型,TrainingLogView 大幅瘦身;Mock 新增按文件路由的训练日志内容与更真实的 GPU 进程占用数据,adapter 类型收敛为 AxiosAdapter,配套新增 mock 内容回归脚本。
2026-07-13 15:29:49 +08:00
caoxiaozhu
e580ec4791 refactor: 数据处理向导拆分组合式函数与子面板
提取 useDataProcessDraft、useDataProcessGeneration 与 dataProcessCreateState 管理向导状态,新增 StructuredOptionsPanel、UnstructuredOptionsPanel、DatasetSplitEditor 子面板组件,样式抽离为独立 scss,DataProcessCreateView 与 TaskSetupStep 大幅瘦身,回归脚本适配。
2026-07-13 15:28:48 +08:00
caoxiaozhu
735a8a71f5 refactor: 调优创建提取表单模型
将默认参数、命令构建、payload 构造逻辑抽离为 fineTuneFormModel.ts,新增 FineTuneStartPayload 类型约束启动训练接口,FineTuneCreateView 瘦身为视图层,列表微调,回归脚本适配。
2026-07-13 15:28:17 +08:00
caoxiaozhu
bff07b7a31 style: 表格操作按钮统一添加图标
评测列表与模型管理列表的详情、编辑、删除按钮补充 Font Awesome 图标,提升操作可辨识度。
2026-07-13 11:51:50 +08:00
caoxiaozhu
91a7c84d7c refactor: 评测列表移除内嵌维度管理
EvalView 移除维度列表加载/删除与相关 Tab 内容,聚焦评测任务展示;维度表单与规则步骤同步微调,回归脚本适配。
2026-07-13 11:47:46 +08:00
caoxiaozhu
5c469a1778 feat: 数据处理向导新增生成控制选项
提取 GenerationOptionsPanel 组件统一管理生成模型、温度、最大长度、JSON 模式与质量过滤配置,StructuredProcessOptions 与 UnstructuredProcessOptions 继承 GenerationControlOptions,CreateView、TaskSetupStep 及各子步骤同步接入,详情/列表小幅调整,回归脚本扩充生成选项断言。
2026-07-13 11:47:33 +08:00
caoxiaozhu
b64d730616 feat: 数据处理任务详情页
新增 DataProcessDetailView,列表查看详情入口跳转到详情页,路由注册 data-process/:id,配套回归脚本与 npm 脚本注册(含数据转换与数据处理详情两个测试脚本)。
2026-07-13 11:04:55 +08:00
caoxiaozhu
0f8f41057a feat: 新增数据类型转换页面
实现 DataConvertView 替换占位页,工具页 JSON 转 JSONL 入口跳转到该页面,路由指向真实视图,配套回归脚本。
2026-07-13 11:04:08 +08:00
caoxiaozhu
03d4c9fbd8 feat: 评测创建改为分步向导
任务配置与评测规则拆分为两个独立步骤,新增步骤导航条与下一步/返回流程,逐步校验任务配置后再进入规则设置,提交时仅校验规则项。
2026-07-13 10:31:40 +08:00
caoxiaozhu
6b48b8fac3 feat: 硬件看板展示 GPU 进程
HardwareView 增加显卡进程占用列表展示,配套回归脚本与 npm test:hardware 脚本注册。
2026-07-13 10:31:29 +08:00
caoxiaozhu
0d82930181 feat: 数据集版本管理
新增数据集版本创建、切换、激活与历史追溯能力,Mock 通过 localStorage 持久化版本状态并接入版本接口路由,预览页支持版本对比与内容回滚,列表补充版本入口,回归脚本扩充断言。
2026-07-13 10:31:20 +08:00
caoxiaozhu
4dde761348 feat: 调优创建支持模型量化配置
新增训练时量化(QLoRA 4/8bit)与训练后导出量化模型开关,支持 bitsandbytes、GPTQ、AWQ、GGUF 四种方法及对应位数、分组、导出格式配置,命令预览与提交参数同步接入,常量补充三类量化选项。
2026-07-13 10:30:52 +08:00
caoxiaozhu
08a567062d feat: 扩展类型定义支撑量化、版本与监控
FineTuneTask 新增量化参数字段(QLoRA 位数、量化方法、位数、分组、GGUF 格式),新增 DatasetVersion 与 DatasetVersionList 支撑数据集版本管理,新增 GpuProcess 完善硬件监控。
2026-07-13 10:30:32 +08:00
caoxiaozhu
862a7972cf refactor: 简化评测创建向导规则来源
移除已有维度/基线两种规则来源,评测创建仅保留新建维度流程并自动生成维度名称;维度名称字段回归独立创建页,表单校验改为返回精确布尔值并启用 scroll-to-error。
2026-07-12 22:49:05 +08:00
caoxiaozhu
687831870c feat: 看板细节优化与设计走查补充
DashboardView 调整窄屏布局与卡片展示,回归脚本补充断言,新增窄屏看板设计截图并更新视觉走查记录。
2026-07-12 15:40:16 +08:00
caoxiaozhu
71ce26fff6 feat: 评测模块新增详情页与创建向导
新增 EvalTaskDetail 与逐样本结果类型,Mock 与适配器补充评测详情接口,路由注册详情页并将维度创建并入向导;EvalCreateView 改为分步向导(任务配置、规则设置、维度表单),新增 EvalDetailView 展示综合得分与样本判定,列表补充详情入口,配套两份回归脚本。
2026-07-12 15:39:59 +08:00
caoxiaozhu
c0f5f4a30a refactor: 数据处理向导拆分源数据上传步骤
将任务设置中的文件上传与外来数据源拉取抽离为独立 SourceUploadStep 组件,TaskSetupStep 聚焦任务信息与处理配置,CreateView 同步接入新步骤与草稿同步,回归脚本补充上传步骤断言。
2026-07-12 15:39:43 +08:00
caoxiaozhu
fecaba040b style: 加深侧边栏激活项背景色
sidebar-active-bg 由 #f1f5f9 调整为 #e2e8f0,提升激活菜单项的视觉对比度。
2026-07-11 14:50:22 +08:00
caoxiaozhu
10b06c749f feat: 数据集列表展示任务来源ID
DatasetItem 新增 task_id 字段,任务来源数据集 Mock 补充对应任务 ID,列表任务 Tab 新增任务ID列,并将上传 Tab 文案与表单标签统一为上传方式。
2026-07-11 14:50:07 +08:00
caoxiaozhu
c5221e1624 feat: 推理对话支持生成参数配置
设置面板新增 Temperature、Top P、Max Tokens 滑块,参数随对话请求实时下发,替换原先硬编码默认值。
2026-07-11 14:49:53 +08:00
caoxiaozhu
cc113db1aa feat: 新增服务看板页面
实现 Dashboard 看板视图并将应用默认入口由调优改为看板,路由、侧边栏默认菜单与登录跳转同步调整;侧边栏增加导航失败高亮校正,布局与 ECharts 注册柱状图适配看板,补充两份回归脚本、设计走查记录与五张设计截图。
2026-07-11 14:49:37 +08:00
caoxiaozhu
ddf47eb8fe feat: 数据处理向导配置体系扩展
新增结构化与非结构化处理选项类型,TaskSetupStep 增加预处理、切分方法、数据集划分等配置 UI,previewModel 实现语义切分与受保护区间算法,CreateView 接入配置状态与草稿持久化并替换为 AppConfirmDialog,回归脚本扩充配置与弹窗断言。
2026-07-11 14:49:10 +08:00
caoxiaozhu
9e16f6358d feat: 新增 AppConfirmDialog 通用确认弹窗组件
基于 Teleport 的 Promise 式确认弹窗,支持键盘关闭、焦点管理、可访问性属性与响应式触控区域,供路由离开等场景替代 window.confirm。
2026-07-11 14:48:54 +08:00
caoxiaozhu
771c258929 refactor: 移除已训练模型的删除入口
清理已训练模型(合并权重)的删除处理函数、表格 delete-fn 绑定与行内删除按钮,避免误删合并产物。
2026-07-11 11:01:02 +08:00
caoxiaozhu
ba465b5016 feat: 调优创建支持 SFT 自动合并权重
FineTuneTask 类型新增 auto_merge 字段,SFT 训练表单新增自动合并权重开关并在创建/启动时随参提交,回归脚本补充 auto_merge 与模型对话框断言。
2026-07-11 11:00:39 +08:00
caoxiaozhu
ee50350dff feat: 数据处理向导支持外来数据源拉取
任务设置步骤新增 external 数据类型,支持 MySQL/PostgreSQL/MongoDB/REST API 配置、测试连接与拉取数据,生成步骤与草稿持久化同步适配,回归脚本放宽分页器数量断言。
2026-07-11 11:00:15 +08:00
caoxiaozhu
a2d65a1123 feat: 任务列表增加任务ID列
数据处理列表与调优列表统一新增任务ID列,并将数据处理示例数据 ID 更新为更贴近真实的长整型。
2026-07-11 10:39:54 +08:00
caoxiaozhu
1455ee7f97 feat: 增强数据集预览功能
重构数据集预览页支持多文件切换与内容高亮,Mock 新增预览数据集与按文件 ID 路由的内容返回,并补充回归测试脚本与 npm 脚本注册。
2026-07-11 10:39:26 +08:00
caoxiaozhu
bc3eddfe84 refactor: 精简评测页面头部操作
移除三个 Tab 下冗余的使用指南与刷新按钮及相关样式,新增任务详情入口占位。
2026-07-11 10:39:04 +08:00
caoxiaozhu
a438684575 refactor: 移除数据处理状态切换 2026-07-10 23:57:07 +08:00
caoxiaozhu
57c3947615 test: 覆盖数据处理任务列表布局 2026-07-10 23:56:06 +08:00
caoxiaozhu
73b2131dd4 docs: 添加数据处理状态切换移除计划 2026-07-10 23:55:16 +08:00
caoxiaozhu
f06b25c228 docs: 设计移除数据处理状态切换 2026-07-10 23:52:37 +08:00
caoxiaozhu
45b7206090 docs: 设计数据预览头部操作移除 2026-07-10 23:42:32 +08:00
caoxiaozhu
9bb26f3b24 feat: 添加数据任务 mock 数据 2026-07-10 17:13:23 +08:00
caoxiaozhu
79843a4b3b docs: 添加数据任务 mock 实施计划 2026-07-10 17:02:46 +08:00
caoxiaozhu
f5b1873ee5 docs: 设计数据任务 mock 数据 2026-07-10 17:01:12 +08:00
caoxiaozhu
89881a4307 readme 2026-07-10 16:52:39 +08:00
caoxiaozhu
acdad260d7 docs: 添加设计文档与视觉走查记录
视觉走查记录、设计方案规格(specs)、实施计划(plans)及配套截图资产,覆盖路由过渡、页面表层级、数据处理向导、训练日志重设计等改进项。
2026-07-10 16:47:29 +08:00
caoxiaozhu
e2eabe3525 test: 添加回归测试脚本
基于 Playwright 的 UI 回归脚本,覆盖返回导航、数据处理向导、调优创建、模型管理、页面表层级、训练日志布局六个场景。
2026-07-10 16:46:23 +08:00
caoxiaozhu
6f6609dae5 feat: 实现业务视图页面
登录、模型调优、评测、推理、对比、模型管理、数据集、数据处理、工具、系统(硬件/日志/训练日志)等全部业务页面视图。
2026-07-10 16:45:55 +08:00
caoxiaozhu
c1893cf82c feat: 实现通用组件
顶部栏、侧边栏、分页表格、Markdown 渲染、模型选择对话框、模型状态标签、页面卡片、占位视图等八个公共组件,及 Font Awesome 图标资源。
2026-07-10 16:45:25 +08:00
caoxiaozhu
ca9e05aa91 feat: 实现基础设施层
axios 请求封装及七个业务模块 API,Pinia 状态管理(auth/system/models/tools),Mock 适配器与数据,以及流式对话、轮询、倒计时组合式函数。
2026-07-10 16:45:06 +08:00
caoxiaozhu
8aa67003c8 feat: 搭建应用基础框架
包含应用入口、根组件、路由配置(含登录守卫)、主布局、全局样式、ECharts 插件、类型定义、常量映射表及静态资源。
2026-07-10 16:44:49 +08:00
caoxiaozhu
855308e5fa chore: 初始化前端项目脚手架与构建配置
Vue 3 + TypeScript + Vite 工程配置,包含 package.json、tsconfig、vite.config.ts 及 .gitignore(忽略 node_modules/dist/自动生成类型声明)。
2026-07-10 16:44:23 +08:00
182 changed files with 33268 additions and 1 deletions

132
README.md
View File

@@ -1,3 +1,133 @@
# YG_FT # YG_FT
远光微调平台 远光微调平台 - 面向大语言模型的微调、评测、推理与对比一体化前端。
## 技术栈
| 类别 | 技术 | 版本 |
|------|------|------|
| 框架 | Vue 3 | ^3.5.13 |
| 语言 | TypeScript | ~5.7.2 |
| 构建工具 | Vite | ^6.0.7 |
| 路由 | Vue Router | ^4.5.0 |
| 状态管理 | Pinia | ^2.3.0 |
| UI 组件库 | Element Plus | ^2.9.1 |
| HTTP 客户端 | axios | ^1.7.9 |
| 图表 | ECharts / vue-echarts | ^6.1.0 / ^8.0.1 |
| Markdown | marked + DOMPurify | ^15.0.5 / ^3.2.3 |
| 编辑器 | md-editor-v3 | ^5.1.4 |
| 工具集 | @vueuse/core | ^11.3.0 |
| 样式 | Sass | ^1.83.0 |
**项目版本**1.0.0
## 环境要求
- **Node.js** >= 18推荐 20 LTS
- **npm** >= 9
- 后端服务运行于 `http://localhost:7861`(前端通过代理转发,见下文)
## 快速开始
### 1. 安装依赖
```bash
cd frontend
npm install
```
### 2. 启动开发服务器
```bash
npm run dev
```
开发服务器默认运行在 `http://localhost:6801`
### 3. 构建生产包
```bash
npm run build # 类型检查 + 生产构建,产物输出到 dist/
npm run preview # 本地预览构建产物
```
### 4. 类型检查
```bash
npm run type-check
```
## 测试
内置基于 Playwright 的 UI 回归脚本,首次运行前需安装浏览器:
```bash
npx playwright install chromium
```
执行已注册的回归脚本:
```bash
npm run test:data-process-wizard # 数据处理向导
npm run test:model-manage # 模型管理
npm run test:training-log-layout # 训练日志布局
npm run test:page-surface # 页面表层级
```
其余脚本可直接运行:
```bash
node scripts/regression-back-navigation.mjs # 返回导航
node scripts/regression-fine-tune-create-ui.mjs # 调优创建 UI
```
> 回归脚本默认连接 `http://localhost:6801`,需先启动开发服务器。
## 目录结构
```
YG-FT/
├── frontend/ # 前端工程Vue 3 SPA
│ ├── src/
│ │ ├── api/ # axios 封装 + 各业务模块 API
│ │ ├── components/ # 公共组件
│ │ ├── composables/ # 组合式函数
│ │ ├── constants/ # 常量与映射表
│ │ ├── layouts/ # 主布局
│ │ ├── mock/ # Mock 数据与适配器
│ │ ├── plugins/ # 第三方插件注册
│ │ ├── router/ # 路由配置 + 登录守卫
│ │ ├── stores/ # Pinia 状态
│ │ ├── styles/ # 全局样式
│ │ ├── types/ # TypeScript 类型定义
│ │ └── views/ # 业务页面
│ ├── scripts/ # UI 回归测试脚本
│ ├── public/ # 静态资源
│ └── vite.config.ts # Vite 构建与代理配置
├── docs/ # 设计文档与视觉走查记录
└── design-qa.md # 视觉走查汇总
```
## 端口与代理
| 服务 | 地址 |
|------|------|
| 前端开发服务器 | `http://localhost:6801` |
| 后端 API | `http://localhost:7861` |
前端统一使用 `/api` 相对路径发请求,由 Vite 开发代理转发到后端 `http://localhost:7861`(配置见 `frontend/vite.config.ts`)。
## 业务模块
| 模块 | 说明 |
|------|------|
| 登录 | 用户登录鉴权 |
| 模型调优 | 微调任务创建与管理 |
| 模型评测 | 评测任务与评测维度配置 |
| 模型推理 | 在线推理对话 |
| 模型对比 | 多模型对话与结果对比 |
| 模型管理 | 模型 CRUD 与权重合并 |
| 数据集 | 数据集管理与预览 |
| 数据处理 | 数据处理任务向导 |
| 工具 | 辅助工具集 |
| 系统 | 硬件监控、日志、训练日志 |

429
design-qa.md Normal file
View File

@@ -0,0 +1,429 @@
# Training Log Detail Design QA
## Evidence
- Source visual truth: `docs/superpowers/specs/assets/training-log-detail-option-2.png`
- Implementation screenshot: `docs/superpowers/specs/assets/training-log-detail-final-expanded-1440.png`
- Collapsed implementation screenshot with global surface: `docs/superpowers/specs/assets/training-log-detail-global-surface-1440-v2.png`
- Normalized full-view comparison: `docs/superpowers/specs/assets/training-log-detail-final-comparison-normalized.png`
- Focused parameter comparison: `docs/superpowers/specs/assets/training-log-detail-final-comparison-params.png`
- White-canvas reference: `docs/superpowers/specs/assets/page-white-canvas-reference.png`
- White-canvas implementation: `docs/superpowers/specs/assets/model-edit-white-page-canvas-final-1440.png`
- White-canvas normalized comparison: `docs/superpowers/specs/assets/page-white-canvas-comparison.png`
- Training-log white-canvas screenshot: `docs/superpowers/specs/assets/training-log-detail-white-page-canvas-1440.png`
- Self-surface list screenshot: `docs/superpowers/specs/assets/fine-tune-list-self-surface-final-1440.png`
- Default-canvas detail screenshot: `docs/superpowers/specs/assets/training-log-detail-default-canvas-final-1440.png`
- Reference/detail comparison: `docs/superpowers/specs/assets/page-surface-reference-detail-comparison.png`
- Create-page duplicate-surface evidence: `docs/superpowers/specs/assets/fine-tune-create-double-surface-before-1440.png`
- Create-page single-surface evidence: `docs/superpowers/specs/assets/fine-tune-create-single-surface-final-1440.png`
- Route-transition flash reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-ea775434-828f-4bca-9714-72887faa9af9.png`
- Route-transition final detail frame: `docs/superpowers/specs/assets/route-transition-detail-final.png`
- Viewport: 1440 × 1024; comparison content normalized to 1200 × 800 after removing the existing 240px sidebar and 60px header from the implementation capture.
- State: `finance-sft-001`, completed, mock data loaded, training parameters expanded.
## Full-view comparison
The implementation preserves the selected two-column hierarchy: task and dataset information occupy the wide left track, runtime facts use the narrow right rail, and training parameters continue as a full-width disclosure section. The selected mock omitted the product shell, so the comparison intentionally crops the existing sidebar and header rather than treating them as design drift.
The global product mode now uses two intentional surface modes. Form and detail routes render inside one `#ffffff` page canvas with 16px radius and 24px content padding. List routes that already own a white table/card surface render that surface directly on the `#f3f5f8` application background, avoiding a redundant white layer.
## Required fidelity surfaces
- Fonts and typography: Existing system font stack is preserved. Heading, label, value, and muted-copy hierarchy match the selected direction; output model uses body-level contrast after iteration 1, and all “未配置” values use `#64748b` on white after iteration 2.
- Spacing and layout rhythm: 24px main gap, 16px section gap, 12px surface radius, and light row separators match the selected composition. The existing application shell reduces usable content width, but normalized proportions remain aligned.
- Colors and tokens: Indigo accent, Slate text, success status, `#f3f5f8` page background, and white content surfaces are consistent with the current product.
- Image and icon fidelity: The screen contains no raster imagery. Existing Font Awesome icons are retained to match the repository's icon system; no placeholder, emoji, CSS drawing, or handcrafted SVG was introduced.
- Copy and content: Task name, status, model, date, duration, dataset metadata, storage, SFT, LoRA, and missing-value copy match the selected design and actual mock data.
## Interaction and responsive checks
- Parameter disclosure changed from `aria-expanded="false"` to `true` after activation, and the expanded content became visible.
- At 1000px viewport width, the overview changed to one column and the document had no horizontal overflow.
- At 700px viewport width, dataset metrics and parameter rows changed to one column and the document had no horizontal overflow.
- Browser console: no errors. One existing Element Plus `el-link` underline deprecation warning was emitted by the login flow and is unrelated to this page.
## Comparison history
### Iteration 1 — blocked
- [P2] The implementation added a visible “基础训练参数” heading that did not exist in the selected mock, creating extra vertical space.
- [P2] “暂未生成” was styled too faintly compared with the selected design.
Fixes:
- Removed the redundant visible base-parameter heading while retaining an accessible region label.
- Restored body-level contrast for “暂未生成”.
Post-fix evidence:
- `docs/superpowers/specs/assets/training-log-detail-final-comparison-normalized.png`
- `docs/superpowers/specs/assets/training-log-detail-final-comparison-params.png`
### Iteration 2 — blocked
- [P2] “未配置” values used `#94a3b8` on white, below WCAG AA contrast for 14px text.
Fix:
- Updated muted values to `#64748b`; the regression check now calculates the contrast ratio and requires at least 4.5:1.
Post-fix evidence:
- Browser computed color: `rgb(100, 116, 139)`.
- Browser console: no errors.
### Iteration 3 — passed
No actionable P0/P1/P2 differences remain. The retained P3 difference is that the generated mock does not include the real product sidebar/header; this is an intentional constraint because the existing shell is shared by every page.
### Iteration 4 — clarified global page canvas, passed
- [P1] The earlier interpretation left the route content directly on the gray layout background and only made individual cards white. The clarified reference requires a single white page canvas behind every route.
Fixes:
- Split the shell and page tokens into `--app-shell-bg: #f3f5f8` and `--app-page-bg: #ffffff`.
- Added one global `.page-canvas` around every route in `MainLayout.vue`.
- Added 16px outer gutter, 16px canvas radius, 24px canvas padding, and a subtle canvas shadow.
- Flattened a route-root `PageCard` to prevent a duplicate large card layer.
Post-fix evidence:
- `docs/superpowers/specs/assets/page-white-canvas-comparison.png`
- Browser computed canvas: white background, 16px radius, 24px padding; outer shell: `rgb(243, 245, 248)`.
- Both the model-edit page and training-log page render inside the same global white canvas without horizontal overflow.
### Iteration 5 — corrected list-page surface ownership, passed
- [P1] Applying the white page canvas to every route created a redundant layer on list pages because `DataTablePage`, model evaluation, and model management already provide their own white root card.
Fixes:
- Added explicit `pageSurface: 'self'` metadata to each self-surfaced list route: model tuning, model evaluation, model inference, model management, data processing, and dataset management.
- Added `.page-canvas.is-self-surface` to remove the outer canvas padding, radius, background, and shadow only for those routes.
- Preserved the default white canvas for training-log, create, edit, preview, chat, and result routes.
Post-fix evidence:
- `docs/superpowers/specs/assets/fine-tune-list-self-surface-final-1440.png`
- `docs/superpowers/specs/assets/training-log-detail-default-canvas-final-1440.png`
- `docs/superpowers/specs/assets/page-surface-reference-detail-comparison.png`
- Browser computed list state: transparent outer canvas, 0px padding/radius, no shadow; white 12px-radius list card on `rgb(243, 245, 248)` shell.
- Browser computed detail state: white outer canvas, 24px padding, 16px radius, subtle shadow.
- Both states have no horizontal overflow and no console errors at 1440 × 900.
### Iteration 6 — flattened wrapped root PageCard, passed
- [P1] The training-task creation route wraps its root `PageCard` in `.fine-tune-create`. The earlier selector only matched a `PageCard` directly under `.page-canvas`, so this page retained a second white background, 12px radius, and card shadow.
Fixes:
- Added an explicit `.page-card-host` marker to the training-task creation route root; the layout flattens only a directly rendered root `PageCard` or a `PageCard` inside that explicit host.
- Root `PageCard` now uses a transparent background, 0px radius, no shadow, and no bottom margin while preserving its header/body layout.
- Kept the selector excluded from `.is-self-surface`, so list cards retain their own white background, 12px radius, and shadow.
- Rejected a generic one-level descendant selector because it would also match the training-log parameter card.
Post-fix evidence:
- `docs/superpowers/specs/assets/fine-tune-create-double-surface-before-1440.png`
- `docs/superpowers/specs/assets/fine-tune-create-single-surface-final-1440.png`
- Browser computed create-page root card: transparent background, 0px radius, no shadow; outer canvas remains white with 24px padding.
- Browser computed list-page card remains white with 12px radius and subtle shadow on a transparent outer canvas.
- Browser computed training-log parameter card remains white with 12px radius and subtle shadow, confirming that internal business cards are not flattened.
- Both pages have no horizontal overflow; create-page console has no errors.
### Iteration 7 — removed page-level opacity transition, passed
- [P1] When navigating from a self-surface list to a default-canvas secondary page, `route.meta.pageSurface` changed immediately while the old list remained for the 150ms `out-in` leave animation. The result was a semi-transparent old list rendered inside the new white canvas.
Fixes:
- Removed the page-level Vue `transition` wrapper from `MainLayout.vue`.
- Removed the `.fade-enter-*` and `.fade-leave-*` opacity rules.
- Preserved local component animations such as dialogs, disclosures, and the selected-row batch bar.
Post-fix evidence:
- Source flash frame: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-ea775434-828f-4bca-9714-72887faa9af9.png`.
- Final detail frame: `docs/superpowers/specs/assets/route-transition-detail-final.png`.
- Immediate state after list → create: old list absent, route-root opacity `1`, zero `.fade-*` transition elements, correct default canvas.
- Immediate state after create → list: old create page absent, route-root opacity `1`, zero `.fade-*` transition elements, correct self-surface canvas.
- Immediate state after list → training log: old list absent, route-root opacity `1`, zero `.fade-*` transition elements, correct default canvas.
- All three paths had no horizontal overflow; browser console had no errors.
## Build evidence gap
The `type-check` script now uses project-reference mode (`vue-tsc -b --noEmit`) so it no longer reports a false pass. `npm run type-check` and `npm run build` remain blocked by pre-existing TypeScript errors in `src/mock/adapter.ts`, `FineTuneCreateView.vue`, and `FineTuneListView.vue`; no remaining error points to `TrainingLogView.vue` or the page-surface files. `npx vite build` succeeds, proving the updated UI bundles for production.
Design-QA final result: passed
final result: passed
---
# Service Dashboard Design QA
## Evidence
- Source visual truth: `docs/superpowers/specs/assets/service-dashboard-approved-1440.png`
- Browser-rendered implementation: `docs/superpowers/specs/assets/service-dashboard-implementation-1440.png`
- Viewport: 1440 × 1024
- State: authenticated `admin` user on `/dashboard`; service dashboard navigation active; 7-day chart visible; four training tasks visible.
## Full-view comparison
The implementation preserves the approved composition: the product shell stays intact, the service dashboard is the active navigation item, the platform-health summary spans the top, the grouped training chart occupies the wide middle track, service health occupies the narrow track, and the training-task table spans the bottom. The three bar series, dual axes, dates, values, service counts, task names, statuses, progress, accuracy, and actions match the selected mock.
A separate focused crop was not required because both source and implementation evidence are full-resolution desktop captures at a readable scale; the chart labels, axis units, service rows, and every task-table column are legible in the full-view comparison.
## Required fidelity surfaces
- Fonts and typography: the existing Inter/system/PingFang stack is retained. Heading, section title, metric, table header, and muted-copy weights and sizes match the selected direction.
- Spacing and layout rhythm: 24px page padding, 14px section gaps, 10px panel radii, light separators, and the wide-chart/narrow-status grid preserve the selected hierarchy. The implementation uses the repository's 240px sidebar and 60px header exactly.
- Colors and visual tokens: white page canvas, `#f3f5f8` shell, indigo `#4f46e5`, green `#10b981`, amber `#f59e0b`, red `#ef4444`, and slate text are aligned with the source and current product tokens.
- Image and icon fidelity: the page contains no decorative raster imagery. The supplied product logo is preserved and existing Font Awesome icons are used consistently; no emoji, handcrafted SVG, placeholder image, or CSS illustration was introduced.
- Copy and content: dashboard title, health summary, chart legend and units, service states, task names, task status, model names, progress, accuracy, timestamps, and action labels match the approved design.
## Interaction and runtime checks
- Login with the existing `admin` credentials navigated to `/dashboard`, confirming the requested default entry behavior.
- ECharts rendered one canvas; hovering 07/10 exposed the tooltip values: training count 18, GPU count 7, and average accuracy 91%.
- “查看全部任务” navigated to `/fine-tune` and browser back restored `/dashboard`.
- The first “查看详情” action navigated to `/training-log/103942` and browser back restored `/dashboard`.
- Browser console errors: none.
- `npm run test:default-dashboard`: passed.
- `npm run test:dashboard`: passed.
- `npx vite build`: passed.
## Comparison history
### Iteration 1 — passed
No actionable P0/P1/P2 differences remain. The only intentional product constraint is that the sidebar active background follows the repository's current neutral active token instead of the slightly bluer tint produced by ImageGen; location, contrast, label, and active-state clarity remain equivalent.
## Validation gap
`npm run type-check` remains blocked by pre-existing TypeScript errors in the mock adapter, dataset mock typing, data-process list, evaluation tabs, and fine-tune views. No reported error points to `DashboardView.vue`, the ECharts registration, router defaults, login redirect, or dashboard regression scripts. The direct Vite production build succeeds.
Design-QA final result: passed
final result: passed
## Compact dashboard revision
- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-43f63327-063f-47da-9e96-31b53cebc49d.png`
- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-compact-1440.png`
- Viewport: 1440 × 1024
- State: authenticated dashboard, compact layout, redundant title/action row removed.
### Iteration 2 — passed
The annotated header row containing the duplicate “服务看板” title, subtitle, and “查看告警” action was removed entirely. Section gaps, overview height, health icon, metric type, chart height, service rows, task heading, and task rows were reduced by roughly 10%15%. The result preserves chart labels, dual-axis readability, service-state text, task progress, accuracy, and all task actions while bringing the primary content closer to the top of the page.
- ECharts tooltip remains functional after the height reduction and reports all three 07/10 series values.
- The revised page contains no browser console errors.
- The full-resolution comparison makes the removed annotation target and the compact replacement legible; no focused crop is necessary.
- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build` pass.
Design-QA final result: passed
final result: passed
## One-screen dashboard revision
- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-3c166ffc-7243-4e57-94fc-48949599c4f1.png`
- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-one-screen-1440x768.png`
- Viewport: 1440 × 768
- State: authenticated dashboard with the desktop low-height compact rules active.
### Iteration 3 — passed
The platform-status block was reduced again, including its container padding, inner gap, health icon, status copy, metric labels, and metric values. The chart, service rows, task rows, and page-canvas padding now use a dedicated `max-height: 900px` desktop mode. The dashboard page canvas is constrained to the available application viewport so the outer content area does not introduce a vertical scrollbar.
Browser measurements at 1440 × 768:
- document overflow: false
- layout-content overflow: false
- page-canvas overflow: false
- dashboard overflow: false
- task section bottom: 653px within the 768px viewport
- ECharts tooltip: passed with all three series present
- browser console errors: none
- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed
Design-QA final result: passed
final result: passed
## Flexible middle-region revision
- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-daf2bbae-baea-418d-9962-7d0e1a1c219b.png`
- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-flex-middle-1440x900.png`
- Viewport: 1440 × 900
- State: authenticated dashboard with flexible middle-region growth.
### Iteration 4 — passed
The previous fixed-height middle row caused unused white space beneath the task table on taller screens. The dashboard now reserves compact intrinsic height for the platform summary and task table while allowing the chart/service row to consume all remaining viewport height. The ECharts canvas grows with that row, and the service-state rows distribute across the matching height.
Browser measurements:
- at 1440 × 768, chart height: 283px; no document, layout, canvas, or dashboard overflow
- at 1440 × 900, chart height: 415px; no document, layout, canvas, or dashboard overflow
- task table bottom at 1440 × 900: 868px within the 900px viewport
- ECharts tooltip: passed with all three series present
- browser console errors: none
- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed
Design-QA final result: passed
final result: passed
## Narrow service-status revision
- User request: make the right-hand service-status panel slightly narrower.
- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-narrow-service-1440x900.png`
- Viewports: 1440 × 900 and 1440 × 768
- State: authenticated dashboard with the service-status column reduced to approximately 30% of the middle row.
### Iteration 5 — passed
The middle grid now allocates `1.9fr` to the training chart and `0.82fr` to service status, with a 300px minimum width for the service panel. This gives the chart more horizontal space while keeping all service names, status badges, and instance counts fully visible.
Browser measurements:
- at 1440 × 900, chart width: 799px; service width: 345px; service share: 30.1%
- at 1440 × 768, chart width: 799px; service width: 345px
- clipped service cells: none at both tested viewports
- horizontal and vertical document overflow: none at both tested viewports
- browser console errors: none
- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed
Design-QA final result: passed
final result: passed
## Taller training-task revision
- User request: increase the training-task region slightly and shorten the middle chart/service region.
- Source visual truth: `docs/superpowers/specs/assets/service-dashboard-narrow-service-1440x900.png` plus the current user annotation.
- Intended viewports: 1440 × 900 and 1440 × 768.
- State: authenticated dashboard with larger task heading and table rows.
### Iteration 6 — blocked
The task section now uses a taller heading and table rows in both standard and low-height desktop modes. Because the middle row is the only flexible region, the additional task height is taken directly from the chart/service row while preserving the one-screen layout contract in code.
Verification evidence:
- `npm run test:dashboard`: passed
- `npm run test:default-dashboard`: passed
- `npx vite build`: passed
- browser-rendered comparison: blocked because the in-app browser rejected the local preview URL under its URL security policy
- implementation screenshot: unavailable for this iteration
Design-QA final result: blocked
final result: blocked
---
# Dataset Version Actions Design QA
## Evidence
- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5e2e-3bee-77f0-b285-89ef139db56c/exec-48a163b6-d4c9-4646-9199-135957c6e72e.png`
- Historical-version implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-historical-version-final.png`
- Delete-confirm implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-delete-confirm.png`
- Full-view comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-full.png`
- Focused version-control comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-version-controls-final.png`
- Viewport: 1316 × 768 browser window; source crop normalized for the focused comparison.
- State: authenticated dataset detail, V3 current, V2 selected as a read-only historical version.
## Full-view comparison
The existing product shell, dataset summary, version selector, read-only alert, file toolbar, and sample table remain unchanged. The former standalone primary action has been replaced by one compact rounded-square overflow button at the far right of the version-control row, matching the selected hierarchy.
## Focused comparison and required fidelity surfaces
- Fonts and typography: existing system/PingFang stack, 13px labels, 12px metadata, and Element Plus menu text are preserved.
- Spacing and layout rhythm: the 40px overflow trigger aligns with the version selector and leaves the central status copy flexible; the 168px menu provides 40px action rows.
- Colors and visual tokens: the existing indigo primary token is used for the activate icon; the delete item and confirmation action use the danger token.
- Image and icon fidelity: no new raster assets are needed. Existing Font Awesome ellipsis, check-circle, and trash icons match the repository's icon system.
- Copy and content: the menu contains exactly “设为当前版本” and “删除版本”, separated visually; the confirmation names V2 and explains that current V3 is unaffected.
## Interaction checks
- Created V2 and V3 through the real edit-and-save flow, then switched from current V3 to historical V2.
- Historical records became read-only and the overflow trigger appeared; current V3 showed no history-operation trigger.
- Opening the trigger exposed exactly two accessible menu items: “设为当前版本” and “删除版本”.
- Choosing “删除版本” opened the danger confirmation dialog; cancelling returned focus without deleting data.
- Actual deletion behavior, protected-version rejection, optimistic-lock handling, and non-reused version numbers are covered by `test:dataset-preview`.
## Comparison history
### Iteration 1 — blocked
- [P2] The overflow trigger was circular while the selected mock used a small rounded square.
- [P2] The menu did not explicitly lock its target width or primary-action icon color.
Fixes:
- Replaced the circular trigger with a 40px square and 10px radius.
- Set the menu minimum width to 168px, action height to 40px, and the activate icon to the product primary color.
### Iteration 2 — passed
No actionable P0/P1/P2 differences remain. The desktop capture API does not retain the transient popup layer in screenshots, so the open-menu labels were additionally verified through the accessibility tree; exact popup shadow rendering remains a non-blocking P3 capture gap.
final result: passed
---
# Login Page Responsive Design QA
## Evidence
- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5f3f-f7e1-7e41-9605-ea307d9f09e6/exec-d376c603-0c47-45ad-86fd-3e99c1a95ec7.png`
- Implementation route: `http://localhost:6801/login`
- Implementation screenshot: unavailable because the in-app browser runtime could not initialize in this session.
- Intended desktop viewport: 1536 × 1024.
- Intended laptop viewports: 1366 × 768 and 1280 × 720.
- State: unauthenticated login page, default username and password populated.
## Static and automated evidence
- Added a 1440px width breakpoint that shifts the split from 58/42 to 55/45 and caps the form at 440px.
- Added a short-screen breakpoint for heights up to 820px that reduces title, form, input, footer, and panel spacing without hiding the left visual.
- Kept the single-column fallback at 900px and below.
- `regression-login-layout.mjs`: passed.
- `regression-default-dashboard.mjs`: passed.
- `vue-tsc -b --noEmit`: passed.
- Vite dev transform for `LoginView.vue`: HTTP 200.
- Production build: blocked by the pre-existing missing route component `UserPermissionView.vue`, outside the login-page change.
## Required fidelity surfaces
- Fonts and typography: code uses the existing product font stack with laptop-specific display-size reductions; visual comparison remains unavailable.
- Spacing and layout rhythm: dedicated width and height media queries are present; rendered measurements remain unavailable.
- Colors and visual tokens: existing indigo tokens and the selected dark-purple visual asset are preserved.
- Image quality and asset fidelity: the generated `login-hero-flow.png` is used directly; the official `logo.png` is reused for the brand lockup.
- Copy and content: platform title, supporting copy, form labels, actions, and footer match the selected design.
## Findings
- [P2] Browser-rendered laptop comparison unavailable
Location: login page at 1366 × 768 and 1280 × 720.
Evidence: the in-app browser runtime failed during initialization, so no implementation screenshot or side-by-side comparison could be captured.
Impact: static checks cannot prove that all visible spacing and crop details match the selected design at laptop sizes.
Fix: capture both laptop viewports in a working in-app browser session, compare them with the source, and resolve any remaining P0/P1/P2 differences.
## Comparison history
### Iteration 1 — blocked
- User reported that the initial implementation was optimized for large displays and did not compose well on laptop screens.
- Added explicit laptop-width and short-screen layout rules and passed targeted regression/type checks.
- Post-fix visual evidence remains unavailable because browser capture is blocked.
final result: blocked

View File

@@ -0,0 +1,458 @@
# Data Process Create Wizard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`/data-process/create` 实现为固定四步向导,并在第二步提供“右侧选择切片、左侧自动定位并高亮原文”的可编辑对照体验。
**Architecture:** `DataProcessCreateView.vue` 只负责向导状态、步骤切换和跨步骤数据;每个步骤拆成独立 Vue 组件。源文定位和切片生成由纯 TypeScript 模块负责,第二步组件只消费偏移范围并同步滚动、高亮和编辑状态。现有 Vue 3、Element Plus、SCSS 和 Font Awesome 继续使用,不引入新依赖。
**Tech Stack:** Vue 3.5、TypeScript 5.7、Vite 6、Element Plus 2.9、SCSS、Node.js 回归脚本、`vue-tsc`
## Global Constraints
- 顶部固定四步:`创建任务``数据预览``开始生成``结果编辑与保存`
- 结构化和非结构化类型不得改变步骤数量。
- 第二步桌面端左侧约 58% 为只读源文件,右侧约 42% 为切片或记录列表及编辑器。
- 点击右侧条目时,左侧必须定位并高亮 `sourceStart``sourceEnd` 的原始范围。
- 编辑切片不得改写源文件;来源映射始终指向初始原文。
- 每一步只能有一个主操作,不得同时出现“下一步”和“开始生成”等竞争动作。
- 页面继续使用现有全局白色页面画布,不新增整页嵌套白卡。
- 不增加第三方依赖。
---
### Task 1: 建立四步向导回归测试
**Files:**
- Create: `frontend/scripts/regression-data-process-wizard.mjs`
- Modify: `frontend/package.json`
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: Vue SFC 源码、`@vue/compiler-sfc``@vue/compiler-dom`
- Produces: `npm run test:data-process-wizard`,验证固定步骤、组件边界、对照定位标记和底部唯一主操作。
- [ ] **Step 1: 写入当前实现必然失败的结构回归检查**
```js
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewSource = await readFile(
path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue'),
'utf8',
)
const previewSource = await readFile(
path.resolve(scriptDir, '../src/views/data-process/create/PreviewCompareStep.vue'),
'utf8',
)
assert.match(viewSource, /const WIZARD_STEPS = \[/)
for (const title of ['创建任务', '数据预览', '开始生成', '结果编辑与保存']) {
assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`)
}
assert.doesNotMatch(viewSource, /all\.filter|steps\s*=\s*computed/)
assert.match(previewSource, /class="source-viewer"/)
assert.match(previewSource, /class="preview-workspace"/)
assert.match(previewSource, /scrollIntoView/)
assert.match(previewSource, /sourceStart/)
assert.match(previewSource, /sourceEnd/)
const { descriptor } = parseSfc(viewSource)
assert.ok(descriptor.template?.content.includes('TaskSetupStep'))
assert.ok(descriptor.template?.content.includes('PreviewCompareStep'))
assert.ok(descriptor.template?.content.includes('GenerationStep'))
assert.ok(descriptor.template?.content.includes('ResultEditorStep'))
console.log('数据处理四步向导回归检查通过')
```
- [ ] **Step 2: 在 `package.json` 注册命令**
```json
{
"scripts": {
"test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs"
}
}
```
- [ ] **Step 3: 运行测试并确认失败原因正确**
Run: `cd frontend && npm run test:data-process-wizard`
Expected: FAIL首先因 `PreviewCompareStep.vue` 不存在或固定步骤断言不成立而失败。
### Task 2: 建立向导类型、草稿状态和来源映射模型
**Files:**
- Create: `frontend/src/views/data-process/create/types.ts`
- Create: `frontend/src/views/data-process/create/previewModel.ts`
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: 上传文件解析出的字符串。
- Produces: `ProcessType``StepId``PreviewItem``ResultItem``DataProcessDraft``buildPreviewItems(sourceText, processType)``sourceLines(sourceText)`
- [ ] **Step 1: 在回归脚本增加模型文件和关键字段断言**
```js
const typesSource = await readFile(
path.resolve(scriptDir, '../src/views/data-process/create/types.ts'),
'utf8',
)
const modelSource = await readFile(
path.resolve(scriptDir, '../src/views/data-process/create/previewModel.ts'),
'utf8',
)
for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) {
assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`)
}
assert.match(modelSource, /export function buildPreviewItems/)
assert.match(modelSource, /export function sourceLines/)
```
- [ ] **Step 2: 定义稳定类型**
```ts
export type ProcessType = 'structured' | 'unstructured'
export type StepId = 'create' | 'preview' | 'generate' | 'results'
export interface PreviewItem {
id: string
originalContent: string
editedContent: string
sourceStart: number | null
sourceEnd: number | null
sourceStartLine: number | null
sourceEndLine: number | null
tokenCount: number
status: 'original' | 'modified' | 'manual' | 'invalid'
}
export interface ResultItem {
id: string
instruction: string
input: string
output: string
status: 'valid' | 'modified' | 'invalid'
error?: string
}
```
- [ ] **Step 3: 实现可重复的来源偏移生成**
```ts
export function buildPreviewItems(sourceText: string, processType: ProcessType): PreviewItem[] {
const lines = sourceText.split('\n')
const groupSize = processType === 'structured' ? 1 : 3
let cursor = 0
const ranges = lines.map((line, index) => {
const start = cursor
cursor += line.length + (index < lines.length - 1 ? 1 : 0)
return { line, lineNumber: index + 1, start, end: start + line.length }
})
const items: PreviewItem[] = []
for (let index = 0; index < ranges.length; index += groupSize) {
const group = ranges.slice(index, index + groupSize)
if (!group.length || group.every((item) => !item.line.trim())) continue
const content = group.map((item) => item.line).join('\n')
items.push({
id: `preview-${items.length + 1}`,
originalContent: content,
editedContent: content,
sourceStart: group[0].start,
sourceEnd: group[group.length - 1].end,
sourceStartLine: group[0].lineNumber,
sourceEndLine: group[group.length - 1].lineNumber,
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
status: 'original',
})
}
return items
}
```
- [ ] **Step 4: 运行回归检查和类型检查**
Run: `cd frontend && npm run test:data-process-wizard && npm run type-check`
Expected: 回归测试继续因组件未完成而失败;`previewModel.ts``types.ts` 不产生 TypeScript 错误。
### Task 3: 实现向导壳层和第一步创建任务
**Files:**
- Create: `frontend/src/views/data-process/create/TaskSetupStep.vue`
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: `ProcessType`、Element Plus 表单与上传组件。
- Produces: `TaskSetupStep``v-model:name``v-model:description``v-model:processType``file-change``remove-file` 事件;父页面提供固定 `WIZARD_STEPS` 和统一底部操作。
- [ ] **Step 1: 将父页面步骤定义改为不可变四步**
```ts
const WIZARD_STEPS = [
{ id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' },
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
] as const
```
- [ ] **Step 2: 创建第一步组件,保留现有校验并改为视觉选择块**
```ts
const props = defineProps<{
name: string
description: string
processType: ProcessType
file: File | null
fileCount: number
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:description': [value: string]
'update:processType': [value: ProcessType]
'file-change': [file: UploadFile]
'remove-file': []
}>()
```
- [ ] **Step 3: 在父页面统一步骤导航和底部动作文案**
```ts
const primaryActionLabel = computed(() => ({
create: '继续:数据预览',
preview: '确认预览并继续',
generate: generation.progress === 100 ? '查看生成结果' : '开始生成',
results: '保存任务',
}[currentStepId.value]))
```
- [ ] **Step 4: 运行回归检查和类型检查**
Run: `cd frontend && npm run test:data-process-wizard && npm run type-check`
Expected: 回归测试因后续三个组件缺失而失败;第一步相关代码通过类型检查。
### Task 4: 实现左右源文件与切片同步预览
**Files:**
- Create: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: `sourceText: string``items: PreviewItem[]``selectedId: string | null``processType: ProcessType`
- Produces: `update:selectedId``update:item-content``restore:item`;选中条目变化时调用 `scrollIntoView({ block: 'center' })`
- [ ] **Step 1: 增加源文范围与选中态的结构断言**
```js
for (const marker of [
'source-viewer',
'source-line',
'is-highlighted',
'preview-item',
'preview-editor',
'scrollIntoView',
]) {
assert.ok(previewSource.includes(marker), `第二步缺少结构:${marker}`)
}
```
- [ ] **Step 2: 通过行偏移判断高亮范围**
```ts
function isLineHighlighted(lineStart: number, lineEnd: number) {
if (!selectedItem.value || selectedItem.value.sourceStart == null || selectedItem.value.sourceEnd == null) {
return false
}
return lineEnd >= selectedItem.value.sourceStart
&& lineStart <= selectedItem.value.sourceEnd
}
```
- [ ] **Step 3: 选中切片后定位首个高亮行**
```ts
watch(selectedItem, async (item) => {
if (!item || item.sourceStart == null) return
await nextTick()
sourceViewerRef.value
?.querySelector<HTMLElement>(`[data-offset="${item.sourceStart}"]`)
?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
```
- [ ] **Step 4: 编辑时只更新 `editedContent` 和状态**
```ts
function updateContent(item: PreviewItem, value: string) {
emit('update:item-content', item.id, value)
}
```
父组件处理事件时不得修改 `sourceText``sourceStart``sourceEnd`
```ts
function updatePreviewContent(id: string, value: string) {
const item = draft.previewItems.find((entry) => entry.id === id)
if (!item) return
item.editedContent = value
item.status = value === item.originalContent ? 'original' : 'modified'
draft.dirty = true
}
```
- [ ] **Step 5: 完成搜索、仅看已修改、上一片、下一片和恢复原文**
Run: `cd frontend && npm run test:data-process-wizard && npm run type-check`
Expected: 第二步结构断言通过,类型检查通过;回归测试只因第三、四步组件缺失而失败。
### Task 5: 实现生成与结果编辑两个独立步骤
**Files:**
- Create: `frontend/src/views/data-process/create/GenerationStep.vue`
- Create: `frontend/src/views/data-process/create/ResultEditorStep.vue`
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: 任务摘要、预览条目、生成状态和结果条目。
- Produces: `start``stop``retry``update:result``restore:result``save` 事件。
- [ ] **Step 1: 生成步骤只保留摘要、进度与状态**
```ts
const emit = defineEmits<{
start: []
stop: []
retry: []
}>()
```
生成完成前底部唯一主操作为 `开始生成`;生成进行中为禁用的 `正在生成`;完成后变为 `查看生成结果`
- [ ] **Step 2: 清理并托管模拟生成计时器**
```ts
let generationTimer: ReturnType<typeof setInterval> | null = null
function stopGenerationTimer() {
if (generationTimer) clearInterval(generationTimer)
generationTimer = null
}
onBeforeUnmount(stopGenerationTimer)
```
- [ ] **Step 3: 将预览条目转换为结构化结果字段**
```ts
function createResults(items: PreviewItem[]): ResultItem[] {
return items.slice(0, 12).map((item, index) => ({
id: `result-${index + 1}`,
instruction: item.editedContent.split('\n')[0] || `数据条目 ${index + 1}`,
input: '',
output: item.editedContent.split('\n').slice(1).join('\n') || item.editedContent,
status: 'valid',
}))
}
```
- [ ] **Step 4: 使用左侧结果列表和右侧字段编辑器替代原始 JSON 文本框**
```ts
function validateResult(item: ResultItem) {
item.error = item.instruction.trim() && item.output.trim() ? undefined : '指令和输出不能为空'
item.status = item.error ? 'invalid' : 'modified'
}
```
- [ ] **Step 5: 运行回归、页面表面和类型检查**
Run: `cd frontend && npm run test:data-process-wizard && npm run test:page-surface && npm run type-check`
Expected: 三项检查全部 PASS。
### Task 6: 完成视觉实现、响应式和浏览器验收
**Files:**
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
- Modify: `frontend/src/views/data-process/create/TaskSetupStep.vue`
- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
- Modify: `frontend/src/views/data-process/create/GenerationStep.vue`
- Modify: `frontend/src/views/data-process/create/ResultEditorStep.vue`
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: 已完成的四步组件和现有全局页面画布。
- Produces: 与确认修订稿一致的桌面布局,以及 900px 以下的上下布局。
- [ ] **Step 1: 落实单层页面、固定步骤和底部操作栏样式**
```scss
.wizard-footer {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 64px;
background: rgba(255, 255, 255, 0.98);
border-top: 1px solid #eef0f5;
}
```
- [ ] **Step 2: 落实桌面左右对照和 900px 响应式**
```scss
.preview-workspace {
display: grid;
grid-template-columns: minmax(0, 58fr) minmax(380px, 42fr);
}
@media (max-width: 900px) {
.preview-workspace {
grid-template-columns: minmax(0, 1fr);
}
}
```
- [ ] **Step 3: 启动页面并逐步验证四步交互**
Run: `cd frontend && npm run dev -- --host 0.0.0.0 --port 6801`
Browser checks at `http://localhost:6801/data-process/create`:
1. 第一步上传文本并选择非结构化数据。
2. 第二步点击至少三个右侧切片,确认左侧滚动目标和高亮范围变化。
3. 修改一个切片并切换前后条目,确认修改状态和内容保留。
4. 完成生成并进入第四步,修改结果字段并保存。
5. 返回前一步,确认草稿和选中项未丢失。
6. 以 1440×1024 和 900px 窄屏分别截图,确认无横向溢出和底部遮挡。
- [ ] **Step 4: 执行完整验证**
Run: `cd frontend && npm run test:data-process-wizard && npm run test:page-surface && npm run type-check && npm run build`
Expected: 所有回归脚本、类型检查和生产构建全部 PASS。
## Self-Review Result
- 规格中的四步稳定语义由 Tasks 1、3、5 覆盖。
- 左右对照、来源映射、滚动高亮、编辑不改源文件由 Tasks 2、4 覆盖。
- 生成状态、计时器清理、结果字段校验由 Task 5 覆盖。
- 单层白底、响应式、路由转场连续性和最终验收由 Task 6 覆盖。
- 未引入新依赖;计划中所有类型和事件名在前置任务中已有定义。

View File

@@ -0,0 +1,117 @@
# 数据处理任务状态切换移除 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 移除数据处理任务列表左上角的三个状态切换按钮,并让表格始终展示全部任务。
**Architecture:** 保持现有 `DataTablePage` 结构不变,仅删除 `DataProcessListView.vue` 内部的页签状态、派生筛选数据、标题插槽和专用样式。新增一个轻量源码回归脚本,锁定“无状态切换组件且表格直接使用完整数据源”的行为。
**Tech Stack:** Vue 3、TypeScript、Element Plus、Node.js `assert`、Vue SFC parser
---
### Task 1: 增加状态切换移除回归检查
**Files:**
- Create: `frontend/scripts/regression-data-process-list.mjs`
- Modify: `frontend/package.json`
- Test: `frontend/scripts/regression-data-process-list.mjs`
- [ ] **Step 1: 编写失败的回归检查**
```js
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessListView.vue')
const source = await readFile(viewPath, 'utf8')
const { descriptor, errors } = parseSfc(source, { filename: viewPath })
assert.equal(errors.length, 0, `数据处理任务列表模板无法解析:${errors[0]}`)
assert.ok(descriptor.template?.content.trim(), '数据处理任务列表缺少可渲染模板')
assert.match(source, /:data="dataList"/, '任务表格必须直接展示完整任务数据')
assert.doesNotMatch(source, /activeTab|filteredDataList/, '不应保留状态切换筛选逻辑')
assert.doesNotMatch(source, /全部任务|处理中|已完成/, '不应保留状态切换按钮文案')
assert.doesNotMatch(source, /capsule-tabs|capsule-tab-item/, '不应保留状态切换专用样式')
console.log('数据处理任务列表状态切换移除回归检查通过')
```
`frontend/package.json``scripts` 中增加:
```json
"test:data-process-list": "node scripts/regression-data-process-list.mjs"
```
- [ ] **Step 2: 运行检查并确认先失败**
Run: `npm run test:data-process-list`
Expected: FAIL错误指出任务表格尚未直接使用 `dataList`,或仍存在状态切换逻辑。
- [ ] **Step 3: 提交回归检查**
```bash
git add frontend/package.json frontend/scripts/regression-data-process-list.mjs
git commit -m "test: 覆盖数据处理任务列表布局"
```
### Task 2: 移除状态切换组件和筛选逻辑
**Files:**
- Modify: `frontend/src/views/data-process/DataProcessListView.vue`
- Test: `frontend/scripts/regression-data-process-list.mjs`
- [ ] **Step 1: 实现最小改动**
将脚本导入改为仅保留 `ref`
```ts
import { ref } from 'vue'
```
删除 `activeTab``filteredDataList`,并将表格数据源改为:
```vue
<DataTablePage
title=""
:data="dataList"
searchable
:search-fields="['name']"
create-text="新建数据处理任务"
create-to="/data-process/create"
row-key="id"
:page-size="10"
>
```
同时删除整个 `#title` 插槽以及 `.capsule-tabs``.capsule-tab-item` 样式,仅保留操作按钮样式。
- [ ] **Step 2: 运行目标回归检查**
Run: `npm run test:data-process-list`
Expected: PASS输出 `数据处理任务列表状态切换移除回归检查通过`
- [ ] **Step 3: 运行前端类型检查**
Run: `npm run type-check`
Expected: PASS退出码为 `0`
- [ ] **Step 4: 检查差异和格式**
Run: `git diff --check && git diff -- frontend/src/views/data-process/DataProcessListView.vue frontend/package.json frontend/scripts/regression-data-process-list.mjs`
Expected: `git diff --check` 无输出,差异仅包含状态切换移除及对应测试。
- [ ] **Step 5: 提交实现**
```bash
git add frontend/src/views/data-process/DataProcessListView.vue
git commit -m "refactor: 移除数据处理状态切换"
```

View File

@@ -0,0 +1,182 @@
# Dataset Task Mock Data Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在数据集管理页的“数据任务”页签展示 4 条任务产出 Mock 数据,并让“本地上传”与“数据任务”按来源稳定分流。
**Architecture:** 保持 `mockDatasets` 为唯一数据源,在 `DatasetItem` 上增加可选来源字段,并由列表页计算属性按来源过滤。使用一个无新增依赖的 Node 回归脚本锁定类型、Mock 数量、数据名称和页签过滤规则。
**Tech Stack:** Vue 3、TypeScript 5.7、Element Plus、Node.js 回归脚本、Vite 6
## Global Constraints
- `source` 只允许 `upload``task`,并保持可选以兼容暂未返回该字段的接口数据。
- 未携带 `source` 的数据归入“本地上传”。
- 现有 6 条 Mock 数据标记为 `upload`,新增 4 条 Mock 数据标记为 `task`
- 不新增依赖,不修改后端接口,不实现真实的数据任务关联。
- 搜索、分页、预览、下载和删除按钮保持现有行为。
---
## File Structure
- `frontend/scripts/regression-dataset-task-tab.mjs`静态回归检查验证来源类型、Mock 数据和页签过滤规则。
- `frontend/package.json`:注册 `test:dataset-task-tab` 命令。
- `frontend/src/types/index.ts`:定义 `DatasetSource` 并扩展 `DatasetItem`
- `frontend/src/mock/data.ts`:标记 6 条上传数据并新增 4 条任务数据。
- `frontend/src/views/dataset/DatasetListView.vue`:按 `source` 过滤两个页签。
### Task 1: 数据任务 Mock 数据与页签分流
**Files:**
- Create: `frontend/scripts/regression-dataset-task-tab.mjs`
- Modify: `frontend/package.json`
- Modify: `frontend/src/types/index.ts:58-78`
- Modify: `frontend/src/mock/data.ts:100-108`
- Modify: `frontend/src/views/dataset/DatasetListView.vue:16-23`
**Interfaces:**
- Consumes: `getDatasetList(): Promise<DatasetItem[]>` 与现有 `DataTablePage``data` 属性。
- Produces: `DatasetSource = 'upload' | 'task'``DatasetItem.source?: DatasetSource`,以及按来源过滤后的 `filteredDataList`
- [ ] **Step 1: 写入会失败的回归检查并注册命令**
创建 `frontend/scripts/regression-dataset-task-tab.mjs`
```js
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const typesPath = path.resolve(scriptDir, '../src/types/index.ts')
const dataPath = path.resolve(scriptDir, '../src/mock/data.ts')
const viewPath = path.resolve(scriptDir, '../src/views/dataset/DatasetListView.vue')
const [typesSource, dataSource, viewSource] = await Promise.all([
readFile(typesPath, 'utf8'),
readFile(dataPath, 'utf8'),
readFile(viewPath, 'utf8'),
])
assert.match(typesSource, /export type DatasetSource = 'upload' \| 'task'/)
assert.match(typesSource, /source\?: DatasetSource/)
assert.equal((dataSource.match(/source: 'upload'/g) || []).length, 6)
assert.equal((dataSource.match(/source: 'task'/g) || []).length, 4)
for (const name of [
'客服对话清洗集',
'通用指令构造集',
'用户反馈脱敏集',
'多轮对话增强集',
]) {
assert.ok(dataSource.includes(`name: '${name}'`), `缺少数据任务 Mock${name}`)
}
assert.match(viewSource, /item\.source === 'task'/)
assert.match(viewSource, /item\.source !== 'task'/)
assert.doesNotMatch(viewSource, /数据任务产生的数据集[\s\S]*?return \[\]/)
console.log('数据任务 Mock 数据与页签分流回归检查通过')
```
`frontend/package.json``scripts` 中加入:
```json
"test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs"
```
- [ ] **Step 2: 运行回归检查并确认红灯**
Run: `cd frontend && npm run test:dataset-task-tab`
Expected: FAIL首个断言提示缺少 `DatasetSource`
- [ ] **Step 3: 增加来源类型**
`frontend/src/types/index.ts` 的数据集类型区加入并使用:
```ts
export type DatasetType = 'train' | 'test' | 'eval' | 'val' | 'other'
export type DatasetStorage = 'local' | 'cloud' | 'minio'
export type DatasetSource = 'upload' | 'task'
export interface DatasetItem {
id: number | string
name: string
type: DatasetType | string
storage_type: DatasetStorage | string
source?: DatasetSource
size?: string | number
count?: number
description?: string
create_time?: string
files?: DatasetFile[]
}
```
- [ ] **Step 4: 标记现有数据并添加 4 条任务数据**
`frontend/src/mock/data.ts``mockDatasets` 更新为:
```ts
export const mockDatasets: DatasetItem[] = [
{ id: 1, name: '金融问答-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '128 MB', count: 8560, description: '金融领域问答对', create_time: '2025-12-20T08:00:00Z' },
{ id: 2, name: '法律文书-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '256 MB', count: 15230, description: '法律文书数据集', create_time: '2025-12-25T10:30:00Z' },
{ id: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', source: 'upload', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' },
{ id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', source: 'upload', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' },
{ id: 5, name: '通用能力评测', type: 'eval', storage_type: 'local', source: 'upload', size: '64 MB', count: 3500, description: '通用能力评测数据集', create_time: '2026-01-12T11:30:00Z' },
{ id: 6, name: '医疗问答-训练集', type: 'train', storage_type: 'local', source: 'upload', size: '180 MB', count: 9800, description: '医疗问答对', create_time: '2026-02-01T15:00:00Z' },
{ id: 7, name: '客服对话清洗集', type: 'train', storage_type: 'minio', source: 'task', size: '96 MB', count: 18240, description: '由客服问答数据清洗任务生成', create_time: '2026-07-08T06:28:00Z' },
{ id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' },
{ id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' },
{ id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' },
]
```
- [ ] **Step 5: 实现两个页签的来源过滤**
`frontend/src/views/dataset/DatasetListView.vue``filteredDataList` 更新为:
```ts
const filteredDataList = computed(() => {
if (activeTab.value === 'task') {
return dataList.value.filter((item) => item.source === 'task')
}
return dataList.value.filter((item) => item.source !== 'task')
})
```
- [ ] **Step 6: 运行针对性回归检查并确认绿灯**
Run: `cd frontend && npm run test:dataset-task-tab`
Expected: PASS输出 `数据任务 Mock 数据与页签分流回归检查通过`
- [ ] **Step 7: 运行类型检查和生产构建**
Run: `cd frontend && npm run type-check`
Expected: PASS若仓库原有错误仍存在保存完整输出并确认本任务修改文件不在错误列表中。
Run: `cd frontend && npx vite build`
Expected: PASS并生成 `dist` 产物。
- [ ] **Step 8: 页面烟雾验证**
启动开发服务器后打开数据集管理页,验证“本地上传”总数为 6切换“数据任务”后总数为 4搜索“脱敏”只显示“用户反馈脱敏集”且预览、下载、删除按钮可见。
- [ ] **Step 9: 提交实现**
```bash
git add frontend/package.json \
frontend/scripts/regression-dataset-task-tab.mjs \
frontend/src/types/index.ts \
frontend/src/mock/data.ts \
frontend/src/views/dataset/DatasetListView.vue
git commit -m "feat: 添加数据任务 mock 数据"
```

View File

@@ -0,0 +1,126 @@
# 多文件预览下拉选择器 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在数据处理向导第二步中支持按文件切换原文和切片,同时保留现有双栏阅读空间。
**Architecture:** 为每个预览条目记录来源文件 ID父页面按当前文件筛选原文与条目。预览组件只负责可搜索下拉选择器和当前文件双栏对照不拼接不同文件的原文。
**Tech Stack:** Vue 3、TypeScript、Element Plus、SCSS、Node 回归脚本。
## Global Constraints
- 预览主区保持原文与切片的双栏比例,不新增常驻第三栏。
- 下拉选择器必须支持 100 个文件的名称筛选。
- 切换文件不得丢失其他文件已编辑的切片内容。
---
### Task 1: 锁定多文件来源映射
**Files:**
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
- Modify: `frontend/src/views/data-process/create/types.ts`
- Modify: `frontend/src/views/data-process/create/previewModel.ts`
**Interfaces:**
- Produces: `PreviewItem.sourceFileId: string`
- Produces: `buildPreviewItems(sourceText, processType, sourceFileId)` 为同一文件生成带文件归属的唯一条目。
- [ ] **Step 1: 写入失败断言**
```js
assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识')
assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识')
```
- [ ] **Step 2: 运行失败断言**
Run: `npm run test:data-process-wizard`
Expected: FAIL提示缺少 `sourceFileId`
- [ ] **Step 3: 实现文件归属**
```ts
export interface PreviewItem {
sourceFileId: string
}
export function buildPreviewItems(sourceText: string, processType: ProcessType, sourceFileId: string) {
// 每个 item 写入 sourceFileId并以它构造稳定 ID。
}
```
- [ ] **Step 4: 再次运行回归脚本**
Run: `npm run test:data-process-wizard`
Expected: 新断言通过;仅保留已知的布局失败(如存在)。
### Task 2: 按文件驱动双栏预览
**Files:**
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
**Interfaces:**
- Consumes: `PreviewItem.sourceFileId`
- Produces: `activePreviewFile``activePreviewItems` 与当前文件选择状态。
- [ ] **Step 1: 为多文件下拉接线添加失败断言**
```js
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
assert.match(viewSource, /buildPreviewItems\(file\.content, processType\.value, String\(file\.uid\)\)/, '预览没有按文件分别生成')
```
- [ ] **Step 2: 运行失败断言**
Run: `npm run test:data-process-wizard`
Expected: FAIL提示缺少当前预览文件状态。
- [ ] **Step 3: 最小实现**
```ts
const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value))
const activePreviewItems = computed(() => previewItems.value.filter((item) => item.sourceFileId === selectedPreviewFileId.value))
```
- [ ] **Step 4: 运行回归脚本**
Run: `npm run test:data-process-wizard`
Expected: 父页面多文件断言通过。
### Task 3: 加入可搜索文件下拉框与布局修复
**Files:**
- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: `files``selectedFileId``items``sourceText`
- Produces: `update:selectedFileId` 事件。
- [ ] **Step 1: 添加失败断言**
```js
assert.match(previewSource, /filterable/, '文件选择器必须可搜索')
assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器')
```
- [ ] **Step 2: 运行失败断言**
Run: `npm run test:data-process-wizard`
Expected: FAIL提示缺少可搜索的文件选择器。
- [ ] **Step 3: 实现下拉框与响应式样式**
```vue
<el-select filterable :model-value="selectedFileId" @update:model-value="emit('update:selectedFileId', $event)">
<el-option v-for="file in files" :key="file.id" :label="file.name" :value="file.id" />
</el-select>
```
- [ ] **Step 4: 将 `.wizard-content` 设为 `min-height: 0` 并运行验证**
Run: `npm run test:data-process-wizard && npm run type-check && npm run build`
Expected: 三个命令退出码均为 0。

View File

@@ -0,0 +1,94 @@
# Page Surface Classification Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 列表页直接使用自身白色卡片,表单和详情页继续使用主布局提供的白色圆角画布。
**Architecture:** 使用 Vue Router `meta.pageSurface` 做显式页面表面分类。主布局默认渲染白色画布,仅在 `pageSurface === 'self'` 时切换为透明、无内边距的承载容器。
**Tech Stack:** Vue 3、Vue Router 4、TypeScript、SCSS、Node.js 回归脚本
## Global Constraints
- 只给六个自带白色列表卡片的路由声明 `pageSurface: 'self'`
- 其他路由默认继续使用白色页面画布。
- 不新增依赖,不修改业务逻辑。
- 先写失败测试,再实现最小修复。
---
### Task 1: 路由级页面表面分类
**Files:**
- Modify: `frontend/scripts/regression-page-surface.mjs`
- Modify: `frontend/src/router/index.ts`
- Modify: `frontend/src/layouts/MainLayout.vue`
- Test: `frontend/scripts/regression-page-surface.mjs`
**Interfaces:**
- Consumes: Vue Router 当前路由对象的 `route.meta.pageSurface`
- Produces: `pageSurface: 'self'` 路由元数据和 `.page-canvas.is-self-surface` 布局状态。
- [ ] **Step 1: 写入失败回归测试**
`regression-page-surface.mjs` 中读取 `src/router/index.ts`,断言六个列表路由包含
`pageSurface: 'self'`;断言 `MainLayout` 使用 `useRoute()` 和动态类;断言状态样式为:
```scss
.page-canvas.is-self-surface {
padding: 0;
border-radius: 0;
background-color: transparent;
box-shadow: none;
}
```
- [ ] **Step 2: 运行测试并确认 RED**
Run: `npm run test:page-surface`
Expected: FAIL提示列表路由缺少 `pageSurface: 'self'` 或主布局缺少自表面状态。
- [ ] **Step 3: 写入最小实现**
在六个列表路由中加入:
```ts
meta: { title: '页面标题', pageSurface: 'self' },
```
`MainLayout.vue` 中使用:
```ts
const route = useRoute()
```
```vue
<div
class="page-canvas"
:class="{ 'is-self-surface': route.meta.pageSurface === 'self' }"
>
```
并加入透明承载容器样式。
- [ ] **Step 4: 运行专项测试并确认 GREEN**
Run: `npm run test:page-surface`
Expected: PASS输出“全局页面背景与内容表面回归检查通过”。
- [ ] **Step 5: 运行相关回归与生产构建**
Run: `npm run test:training-log-layout`
Expected: PASS。
Run: `npx vite build`
Expected: build exit code 0允许保留项目既有字体解析和 chunk size 警告。
- [ ] **Step 6: 浏览器视觉验证**
打开 `/fine-tune`,确认灰色背景上仅有列表自身白色卡片;打开
`/training-log/1`,确认白色圆角页面画布仍存在。两个页面均不得水平溢出,控制台不得新增错误。

View File

@@ -0,0 +1,77 @@
# 切片单面板编辑模式 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将数据预览右侧改为列表和编辑器互斥的单面板,并通过保存或取消控制切片内容写回。
**Architecture:** `PreviewCompareStep.vue` 保留切片筛选、分页和源文件定位,新增组件内编辑模式与临时草稿。父页面仅在收到保存事件时更新 `PreviewItem`,删除仍复用现有确认与删除事件。
**Tech Stack:** Vue 3 Composition API、TypeScript、Element Plus、Node 回归脚本。
## Global Constraints
- 列表行只展示编号、来源行号及编辑、删除图标操作。
- 编辑草稿未保存时不得更新 `PreviewItem.editedContent`
- 搜索、分页或切换文件时退出编辑模式并丢弃草稿。
- 不新增依赖。
---
### Task 1: 切片列表与编辑模式切换
**Files:**
- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
**Interfaces:**
- Consumes: `PreviewItem``update:selectedId``update:item-content``remove:item`
- Produces: `openEditor(item)``closeEditor()``saveEditor()` 与列表/编辑互斥渲染。
- [ ] **Step 1: Write the failing test**
`regression-data-process-wizard.mjs` 断言组件存在 `editingItemId``editorDraft`,列表以图标按钮触发 `openEditor``remove:item`,编辑模式拥有 `保存修改``取消``返回列表`,并且列表不再含 `item-token``item-status``modifiedOnly`
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test:data-process-wizard`
Expected: FAIL提示缺少单面板编辑模式结构。
- [ ] **Step 3: Write minimal implementation**
在组件中增加以下状态和行为:
```ts
const editingItemId = ref<string | null>(null)
const editorDraft = ref('')
function openEditor(item: PreviewItem) {
editingItemId.value = item.id
editorDraft.value = item.editedContent
}
function closeEditor() {
editingItemId.value = null
editorDraft.value = ''
}
function saveEditor() {
if (!editingItem.value) return
emit('update:item-content', editingItem.value.id, editorDraft.value)
closeEditor()
}
```
列表模式只渲染编号、来源和两个无文字图标按钮;编辑模式在同一位置渲染正文输入框以及返回、取消、保存操作。搜索、翻页、文件切换调用 `closeEditor()`
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test:data-process-wizard`
Expected: `数据处理四步向导回归检查通过`
- [ ] **Step 5: Build and visually verify**
Run: `npx vite build`
Expected: Vite completes successfully. Open the second wizard step, verify the list has only the two icon operations and that cancel does not change the selected slice content while save returns to the list.

View File

@@ -0,0 +1,78 @@
# Route Transition Removal Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 移除主布局的页面级透明转场,消除列表与二级页面切换时的闪烁中间帧。
**Architecture:** `router-view` 直接渲染当前路由组件,不再包裹 Vue `transition`。页面表面仍由 `route.meta.pageSurface` 控制,因此内容和表面在同一轮渲染中同步更新。
**Tech Stack:** Vue 3、Vue Router 4、SCSS、Node.js 回归脚本
## Global Constraints
- 删除页面级透明度转场。
- 保留组件内部动画。
- 不改变路由表面分类、业务逻辑或数据加载流程。
- 先写失败测试,再实现最小修复。
---
### Task 1: 移除主布局页面级透明转场
**Files:**
- Modify: `frontend/scripts/regression-page-surface.mjs`
- Modify: `frontend/src/layouts/MainLayout.vue`
- Test: `frontend/scripts/regression-page-surface.mjs`
**Interfaces:**
- Consumes: `router-view` 提供的当前路由组件。
- Produces: 不带页面级透明度动画的同步路由内容渲染。
- [ ] **Step 1: 写入失败回归测试**
`regression-page-surface.mjs` 中断言主布局模板不包含页面级 `transition`,并断言主布局样式不包含 `.fade-enter-*``.fade-leave-*`
- [ ] **Step 2: 运行测试并确认 RED**
Run: `npm run test:page-surface`
Expected: FAIL提示主布局仍包含页面级透明转场。
- [ ] **Step 3: 写入最小实现**
将:
```vue
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
```
改为:
```vue
<component :is="Component" />
```
并删除主布局中的 `.fade-enter-active``.fade-leave-active``.fade-enter-from``.fade-leave-to` 样式。
- [ ] **Step 4: 运行专项回归并确认 GREEN**
Run: `npm run test:page-surface`
Expected: PASS输出“全局页面背景与内容表面回归检查通过”。
- [ ] **Step 5: 运行相关回归与构建**
Run: `npm run test:training-log-layout`
Expected: PASS。
Run: `npx vite build`
Expected: exit code 0允许项目既有字体解析和 chunk size 警告。
- [ ] **Step 6: 浏览器往返验证**
验证 `/fine-tune``/fine-tune/create``/fine-tune``/training-log/1` 以及二级页返回列表;页面内容与表面同步切换,无半透明旧页面、无水平溢出、无新增控制台错误。

View File

@@ -0,0 +1,202 @@
# 源数据上传紧凑文件列表 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将数据处理创建页的已上传文件从大卡片改为固定高度、可滚动且可逐项操作的紧凑列表。
**Architecture:** 只修改 `TaskSetupStep` 的模板和局部样式,继续消费现有的
`uploadedFiles` 属性并派发既有 `remove-file` 事件。使用同一组件内的标题栏和
滚动容器管理信息密度,不改变上传、格式限制或父组件数据流。
**Tech Stack:** Vue 3 `<script setup>`、TypeScript、Element Plus、SCSS、Node.js 回归脚本。
## Global Constraints
- 不新增依赖、组件、数据字段或父子组件事件。
- 上传区、格式限制、校验语义与逐项删除行为必须保持不变。
- 空状态保留大尺寸拖拽上传区与格式提示;存在至少一个文件时,改为列表标题右侧
的小型“继续上传”入口,且复用相同上传属性与文件变更事件。
- 列表默认显示最多 5 行;额外文件只能在列表内部纵向滚动。
- 文件名允许省略,但必须通过原生 `title` 保留完整文本。
- 校验状态必须同时显示图标和文字;删除按钮必须可见且可键盘操作。
- 当前工作区不是 Git 仓库,本计划不包含提交操作。
---
### Task 1: 紧凑上传文件列表
**Files:**
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
- Modify: `frontend/src/views/data-process/create/TaskSetupStep.vue:157-164,314-351`
**Interfaces:**
- Consumes: `uploadedFiles: { uid: string | number; name: string; size: number; count: number }[]`
`remove-file(uid)` 事件。
- Produces: `.uploaded-file-list-header``.uploaded-file-items` 和每条
`.uploaded-file` 紧凑行;不新增对外 TypeScript 接口。
- [ ] **Step 1: 添加会失败的页面结构回归断言**
`frontend/scripts/regression-data-process-wizard.mjs` 的第二步组件检查之后读取
`TaskSetupStep.vue`,并追加以下断言:
```js
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
const taskSetupSource = await readFile(taskSetupPath, 'utf8')
for (const marker of [
'uploaded-file-list-header',
'uploaded-file-items',
'已添加 {{ uploadedFiles.length }} 个文件',
':title="file.name"',
'aria-label="已上传文件列表"',
'继续上传',
'v-if="uploadedFiles.length === 0"',
]) {
assert.ok(taskSetupSource.includes(marker), `源数据文件列表缺少:${marker}`)
}
assert.match(taskSetupSource, /\.uploaded-file-items\s*\{[\s\S]*max-height:\s*240px/, '文件列表没有固定可见高度')
assert.match(taskSetupSource, /\.uploaded-file-items\s*\{[\s\S]*overflow-y:\s*auto/, '超出文件没有在列表内滚动')
assert.match(taskSetupSource, /compact-upload[\s\S]*:accept="uploadAccept"/, '继续上传没有复用格式限制')
assert.match(taskSetupSource, /compact-upload[\s\S]*on-change/, '继续上传没有复用文件变更事件')
```
- [ ] **Step 2: 运行回归脚本,确认新增断言失败**
Run: `npm --prefix frontend run test:data-process-wizard`
Expected: FAIL错误信息包含新增文件列表、列表可访问性或继续上传状态切换的缺失标记。
- [ ] **Step 3: 更新模板为带标题与滚动区域的紧凑列表**
`TaskSetupStep.vue` 中,将当前上传区改为两种互斥状态:空状态保留大尺寸拖拽
区和格式提示;存在文件时显示含继续上传入口的紧凑列表。两个上传入口均保留
`multiple``:accept="uploadAccept"``:auto-upload="false"` 和相同 `on-change` 事件。
文件图标、元信息、成功状态和原有删除事件必须保留:
```vue
<el-upload
v-if="uploadedFiles.length === 0"
class="upload-empty-state"
drag
multiple
:accept="uploadAccept"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
>
<!-- 保留现有大尺寸上传引导和格式提示 -->
</el-upload>
<div v-else class="uploaded-file-list" aria-label="已上传文件列表">
<div class="uploaded-file-list-header">
<span>已添加 {{ uploadedFiles.length }} 个文件</span>
<el-upload
class="compact-upload"
multiple
:accept="uploadAccept"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
>
<el-button size="small" type="primary">继续上传</el-button>
</el-upload>
</div>
<div class="uploaded-file-items">
<div v-for="file in uploadedFiles" :key="file.uid" class="uploaded-file">
<span class="file-icon"><i class="fa fa-file-text-o" /></span>
<div class="file-main">
<strong :title="file.name">{{ file.name }}</strong>
<span>{{ formatSize(file.size) }}<template v-if="file.count"> · {{ file.count.toLocaleString() }} 条</template></span>
</div>
<span class="file-status"><i class="fa fa-check-circle" /> 校验通过</span>
<el-button link type="danger" @click="emit('remove-file', file.uid)">删除</el-button>
</div>
</div>
</div>
```
- [ ] **Step 4: 将现有大卡片样式改为紧凑行和内部滚动**
`TaskSetupStep.vue` 中替换现有 `.uploaded-file-list``.uploaded-file` 相关样式,
使容器、标题、滚动区和行高满足以下实现:
```scss
.uploaded-file-list {
margin-top: 20px;
overflow: hidden;
background: #fff;
border: 1px solid #dfe3ea;
border-radius: 8px;
}
.uploaded-file-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
color: #5f6878;
font-size: 12px;
background: #fbfcfe;
border-bottom: 1px solid #edf0f5;
}
.uploaded-file-items {
max-height: 240px;
overflow-y: auto;
}
.compact-upload {
flex: 0 0 auto;
}
.uploaded-file {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 8px 14px;
border-bottom: 1px solid #edf0f5;
&:last-child {
border-bottom: 0;
}
}
.file-icon {
width: 28px;
height: 28px;
font-size: 14px;
border-radius: 7px;
}
.file-main strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
```
保留 `.file-main` 的弹性填充与 `.file-status` 的成功色;为窄屏媒体查询加入更小的
行间距和可换行的状态文本,保证删除按钮不被文件名挤出。
- [ ] **Step 5: 运行回归脚本并执行类型检查**
Run: `npm --prefix frontend run test:data-process-wizard && npm --prefix frontend run type-check`
Expected: 两个命令均以退出码 `0` 完成,前者输出 `数据处理四步向导回归检查通过`
- [ ] **Step 6: 构建生产包,检查样式与模板编译**
Run: `npm --prefix frontend run build`
Expected: 退出码 `0`Vite 输出生产构建产物信息,无 Vue 模板或 SCSS 编译错误。
- [ ] **Step 7: 检查变更范围**
Run: `git diff -- frontend/src/views/data-process/create/TaskSetupStep.vue frontend/scripts/regression-data-process-wizard.mjs`
Expected: 当前目录不是 Git 仓库时,该命令会报告仓库缺失;改用
`diff -u <(git show HEAD:...) ...` 不可用,因此使用 `sed` 复查两个文件的目标区段,
确认没有修改上传、格式限制或父组件事件。

View File

@@ -0,0 +1,131 @@
# 源数据上传文件分页 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将紧凑源文件列表限制为每页最多 10 条,并在文件总数超过 10 时使用分页器切换文件。
**Architecture:** 分页状态只在 `TaskSetupStep` 内维护;父组件仍只提供完整的
`uploadedFiles` 数组和既有上传、删除事件。组件通过计算属性得到当前页文件,并在
文件数变化后跳转到新增文件所在的末页或将删除后的空页回退到有效页。
**Tech Stack:** Vue 3 `<script setup>`、TypeScript、Element Plus、SCSS、Node.js 回归脚本。
## Global Constraints
- 不新增依赖、组件、数据字段或父子组件事件。
- 每页固定显示最多 10 个文件;只有第 11 个文件出现分页器。
- 不再使用文件列表内部滚动承载额外文件;分页器承担跨页浏览。
- 新增文件自动切换到最后一页;删除后当前页不存在时回退到最后一个有效页。
- 空状态的大尺寸上传区、有文件时的小型继续上传入口、格式限制、校验状态和逐项
删除行为必须保持不变。
- 当前工作区不是 Git 仓库,本计划不包含提交操作。
---
### Task 1: 十条一页的文件列表
**Files:**
- Modify: `frontend/src/views/data-process/create/TaskSetupStep.vue:1-45,158-184,350-420`
- Modify: `frontend/scripts/regression-data-process-wizard.mjs:65-140`
**Interfaces:**
- Consumes: `uploadedFiles` 完整数组和既有 `file-change``remove-file` 事件。
- Produces: 组件内部的 `currentFilePage``pagedUploadedFiles` 与只在超出 10 条时显示的
Element Plus 分页器;不新增对外接口。
- [ ] **Step 1: 先添加分页回归断言**
`frontend/scripts/regression-data-process-wizard.mjs``TaskSetupStep.vue` 检查中追加:
```js
for (const marker of [
'const FILE_PAGE_SIZE = 10',
'const currentFilePage = ref(1)',
'const pagedUploadedFiles = computed',
'v-for="file in pagedUploadedFiles"',
'class="uploaded-file-pagination"',
':page-size="FILE_PAGE_SIZE"',
'uploadedFiles.length > FILE_PAGE_SIZE',
]) {
assert.ok(taskSetupSource.includes(marker), `文件分页缺少:${marker}`)
}
assert.match(taskSetupSource, /newLength > oldLength[\s\S]*currentFilePage\.value = totalPages/, '新增文件后没有跳到最后一页')
assert.match(taskSetupSource, /Math\.min\(currentFilePage\.value, totalPages\)/, '删除文件后没有回退到有效页')
assert.doesNotMatch(taskSetupSource, /\.uploaded-file-items\s*\{[\s\S]*overflow-y:\s*auto/, '文件列表仍依赖内部滚动')
```
- [ ] **Step 2: 运行回归脚本,确认新增断言失败**
Run: `npm --prefix frontend run test:data-process-wizard`
Expected: FAIL错误信息包含 `文件分页缺少const FILE_PAGE_SIZE = 10`
- [ ] **Step 3: 增加组件内分页状态与页码校正**
`TaskSetupStep.vue` 中将 Vue 导入扩展为 `computed, ref, watch`,并在
`uploadAccept` 之后添加:
```ts
const FILE_PAGE_SIZE = 10
const currentFilePage = ref(1)
const totalFilePages = computed(() => Math.max(1, Math.ceil(props.uploadedFiles.length / FILE_PAGE_SIZE)))
const pagedUploadedFiles = computed(() => {
const start = (currentFilePage.value - 1) * FILE_PAGE_SIZE
return props.uploadedFiles.slice(start, start + FILE_PAGE_SIZE)
})
watch(() => props.uploadedFiles.length, (newLength, oldLength) => {
const totalPages = Math.max(1, Math.ceil(newLength / FILE_PAGE_SIZE))
currentFilePage.value = newLength > oldLength
? totalPages
: Math.min(currentFilePage.value, totalPages)
})
```
- [ ] **Step 4: 使用当前页文件并添加紧凑分页器**
将文件行循环改为 `v-for="file in pagedUploadedFiles"`。在 `.uploaded-file-items`
之后、`</section>` 之前插入:
```vue
<el-pagination
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
v-model:current-page="currentFilePage"
:page-size="FILE_PAGE_SIZE"
:total="uploadedFiles.length"
:pager-count="5"
small
background
layout="prev, pager, next"
class="uploaded-file-pagination"
/>
```
保留现有列表语义、继续上传入口、状态、删除按钮和 `aria-label`
- [ ] **Step 5: 移除列表滚动样式并添加分页器间距**
`.uploaded-file-items` 中的 `max-height``overflow-y` 删除,确保 10 条以内
由页面自然高度承载;追加:
```scss
.uploaded-file-pagination {
display: flex;
justify-content: flex-end;
padding: 10px 14px;
border-top: 1px solid #edf0f5;
}
```
在窄屏媒体查询中将分页器改为水平居中,避免与文件操作区争夺宽度。
- [ ] **Step 6: 验证回归与编译**
Run: `npm --prefix frontend run test:data-process-wizard`
Expected: 文件分页新增断言全部通过;脚本若非零,只能在既有父页面的
`.wizard-content` / `min-height: 0` 断言处终止。
Run: `cd frontend && npm exec vite build`
Expected: 退出码 `0`,无 Vue 模板或 SCSS 编译错误。

View File

@@ -0,0 +1,128 @@
# 训练日志详情页双栏改版 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将训练日志详情首屏改造成用户选定的双栏任务档案布局,同时保持原有数据与日志行为。
**Architecture:** 继续由 `TrainingLogView.vue` 负责数据加载与页面编排,仅重写首屏模板和 scoped SCSS。使用原生语义元素与 CSS Grid不新增依赖或全局组件避免影响其他详情页。
**Tech Stack:** Vue 3、TypeScript、Element Plus、SCSS、Node.js 原生断言脚本
## Global Constraints
- 保留所有 API 请求、5 秒轮询、指标解析、ECharts 和日志输出逻辑。
- 使用现有 Indigo/Slate 视觉 token不改全局主题。
- 灰色 `#f3f5f8` 只作为应用外壳留白;所有业务路由必须渲染在统一的白色圆角页面画布内。
- 缺失参数显示“未配置”,缺失输出模型显示“暂未生成”。
- `<= 1100px` 主体改单栏,折叠按钮具备 `aria-expanded`
- 不新增依赖。
---
### Task 1: 锁定布局与可访问性回归
**Files:**
- Create: `frontend/scripts/regression-training-log-layout.mjs`
- Modify: `frontend/package.json`
- Test: `frontend/scripts/regression-training-log-layout.mjs`
**Interfaces:**
- Consumes: `frontend/src/views/system/TrainingLogView.vue` 源文件。
- Produces: `npm run test:training-log-layout` 专项回归命令。
- [ ] **Step 1: 写入失败测试**
测试必须断言:双栏容器、任务/数据集/运行概况分组、语义化参数折叠按钮、`aria-expanded`、缺失值文案和 `1100px` 响应式断点存在;首屏旧 `el-descriptions` 结构已移除。
- [ ] **Step 2: 运行并确认 RED**
Run: `cd frontend && npm run test:training-log-layout`
Expected: FAIL提示缺少 `.overview-layout` 等新结构。
- [ ] **Step 3: 在 package scripts 暴露测试命令**
```json
"test:training-log-layout": "node scripts/regression-training-log-layout.mjs"
```
### Task 2: 实现双栏任务档案首屏
**Files:**
- Modify: `frontend/src/views/system/TrainingLogView.vue`
- Test: `frontend/scripts/regression-training-log-layout.mjs`
**Interfaces:**
- Consumes: 现有 `task``dataset``modelsStore`、枚举映射与 `paramsExpanded`
- Produces: `.overview-layout``.profile-section``.runtime-panel``.parameter-groups` 和语义化 `.params-toggle-button`
- [ ] **Step 1: 重写任务与数据集模板**
使用一个顶层 `PageCard` 承载任务头和双栏内容;左栏用定义列表表达任务信息,数据集使用名称/类型/描述加指标带;右栏使用纵向运行概况项。
- [ ] **Step 2: 重写参数分组模板**
训练参数和 LoRA 参数改为双列轻分隔行,所有缺失值用 `未配置`,折叠按钮绑定 `:aria-expanded="paramsExpanded"`
- [ ] **Step 3: 实现视觉与响应式 SCSS**
添加双栏、信息行、指标带、焦点态和 `@media (max-width: 1100px)` / `700px` 规则;不修改全局样式。
- [ ] **Step 4: 运行并确认 GREEN**
Run: `cd frontend && npm run test:training-log-layout`
Expected: PASS输出“训练日志详情布局回归检查通过”。
### Task 2.5: 统一页面背景与内容表面
**Files:**
- Modify: `frontend/src/styles/index.scss`
- Modify: `frontend/src/layouts/MainLayout.vue`
- Modify: `frontend/src/views/system/TrainingLogView.vue`
- Create: `frontend/scripts/regression-page-surface.mjs`
- [ ] **Step 1: 先写并运行背景层回归检查**
Run: `cd frontend && npm run test:page-surface`
Expected: 首次 FAIL提示缺少 `--app-shell-bg` 和全局 `.page-canvas`
- [ ] **Step 2: 添加全局背景与表面 token**
定义灰色外壳 `--app-shell-bg: #f3f5f8`、白色页面画布 `--app-page-bg: #ffffff` 和白色内容表面 `--app-surface-bg: #ffffff`。在 `MainLayout` 中用 `.page-canvas` 包裹所有业务路由,并消除直接根 `PageCard` 的重复阴影。
- [ ] **Step 3: 运行并确认 GREEN**
Run: `cd frontend && npm run test:page-surface`
Expected: PASS输出“全局页面背景与内容表面回归检查通过”。
### Task 3: 类型、构建与视觉 QA
**Files:**
- Create: `design-qa.md`
- Modify: `frontend/src/views/system/TrainingLogView.vue`(仅在 QA 发现 P0/P1/P2 时)
**Interfaces:**
- Consumes: 选定视觉稿、浏览器渲染截图。
- Produces: 通过的类型检查、生产构建和 `design-qa.md`
- [ ] **Step 1: 静态验证**
Run: `cd frontend && npm run type-check`
Expected: 项目引用模式能检查真实源码;若仓库原有错误仍存在,输出中不得包含本次修改的训练日志页、布局或全局样式。
Run: `cd frontend && npx vite build`
Expected: exit 0。
Run: `cd frontend && npm run build`
Expected: 在类型检查修复前仍会被仓库原有错误阻断,必须记录实际错误文件。
- [ ] **Step 2: 启动并检查页面**
启动 Vite打开 `/login`,使用 mock 账号进入 `/training-log/1`,在 1440px 宽视口检查双栏、折叠交互和控制台错误。
- [ ] **Step 3: 执行 Design QA**
将选定视觉稿与实现截图放在同一比较输入中,检查字体、间距、颜色、图标、文案和交互;修复所有 P0/P1/P2 后更新 `design-qa.md``final result: passed`
- [ ] **Step 4: 最终验证**
重新运行专项回归、类型检查和构建,并记录实际结果。

View File

@@ -0,0 +1,267 @@
# 前端视觉与样式系统重构设计
**日期**2026-07-09
**范围**`frontend/` Vue 3 + Element Plus + Pinia
**目标用户**:内部数据/算法工程师,桌面端浏览器使用
## 背景与动机
当前前端已用 Element Plus + 自定义 SCSS 变量搭起一套"深色侧边栏 + 浅色内容区"的现代 B 端布局,但实际页面观感"乱"且有功能性瑕疵,根因不是设计语言本身有问题,而是**设计系统没有真正落地**
- 颜色散落硬编码(`#1e293b``#475569``#909399``#1890ff``#67c23a`…)
- Element Plus 旧默认色与新主题色(`#4f46e5`)并存
- 用了 FontAwesome **4.7.0**2017 年停止维护),大量 `fa-cogs`/`fa-hdd-o` 这种短横线后缀命名
- 登录页(`LoginView.vue`)背景是纯色 `#dbe0f9`,与主应用玻璃拟态风格割裂
- `AppHeader.vue:189` 引用了主题里**不存在**的 `--warning-color` 变量,导致回退到默认值
- `index.scss` 内有 5+ 处 `!important`,说明覆盖策略不对
- 装饰性动画(按钮悬停 `translateY(-1px)``pulse` 动画、过度渐变)增加视觉噪音
本次重构目标是**收口样式系统**,让"主题改一处,全站生效",并消除明显观感问题。
## 范围与非范围
### 范围内
1. 统一颜色变量系统(消除硬编码、补齐缺失变量)
2. 图标库迁移FA4 → **Element Plus Icons**`@element-plus/icons-vue`,已与 Element Plus 同步)
3. 重新设计登录页,与主应用视觉一致
4. 修复 `AppHeader.vue``--warning-color` 缺失问题
5. 整理 `index.scss``!important` 覆盖,改用更高特异性选择器
6. 适度降低装饰性效果:移除按钮悬停位移、统一卡片阴影阶梯
### 非范围(本次不做)
- 布局结构改动(保留 260px 侧边栏 + 60px 顶栏)
- 深色模式切换
- 任何业务逻辑修改
- 移动端适配(当前项目无移动端需求)
- `web/` 静态版不动
## 方案选型
### 选定方案:方案 A — 克制重塑
保留现有"深色侧边栏 + 浅色内容区"骨架,做样式系统收口。
**未选 方案 B极简重做**改动面太广10+ 视图都要适配,回报/风险比差。
**未选 方案 C全深色科技感**Element Plus 暗色需要大量单独调校,工作量与当前诉求不匹配。
## 设计
### 1. 颜色变量系统(`src/styles/index.scss` 重构)
**目标**:所有颜色、阴影、圆角都走 CSS 变量;后续改主题只改 `:root` 即可。
**新增/补齐变量**`src/styles/index.scss` 顶部):
```scss
:root {
/* Element Plus 主色(已有) */
--el-color-primary: #4f46e5;
--el-color-primary-light-3: #818cf8;
--el-color-primary-light-5: #a5b4fc;
--el-color-primary-light-7: #c7d2fe;
--el-color-primary-light-8: #e0e7ff;
--el-color-primary-light-9: #eef2ff;
--el-color-primary-dark-2: #4338ca;
/* 状态色(已有) */
--el-color-success: #10b981;
--el-color-warning: #f59e0b; /* 修复:之前未显式定义 */
--el-color-danger: #ef4444;
--el-color-info: #64748b;
/* 项目别名(已有 + 新增) */
--primary-color: var(--el-color-primary);
--primary-hover: var(--el-color-primary-light-3);
--success-color: var(--el-color-success);
--warning-color: var(--el-color-warning); /* 新增:修复 AppHeader 引用 */
--danger-color: var(--el-color-danger);
/* 圆角(已有) */
--el-border-radius-base: 8px;
--el-border-radius-small: 6px;
--el-border-radius-round: 20px;
/* ========== 新增:项目语义化变量 ========== */
/* 文字 */
--text-primary: #1e293b; /* 替代硬编码 #1e293b */
--text-secondary: #475569; /* 替代 #475569 */
--text-tertiary: #64748b; /* 替代 #64748b */
--text-placeholder: #94a3b8; /* 替代 #94a3b8 */
/* 背景 */
--bg-page: #f8fafc; /* 替代 --el-bg-color-page */
--bg-card: #ffffff;
--bg-subtle: #f1f5f9; /* 替代硬编码 #f1f5f9 */
--bg-hover: #f8fafc; /* 替代硬编码 #f8fafc */
/* 边框 */
--border-subtle: #e2e8f0; /* 替代硬编码 #e2e8f0 */
--border-base: #f1f5f9; /* 替代硬编码 #f1f5f9 */
/* 侧边栏(已有) */
--sidebar-bg: #0f172a;
--sidebar-text: #94a3b8;
--sidebar-active-bg: rgba(79, 70, 229, 0.15);
--sidebar-active-text: #ffffff;
--sidebar-section-title: #64748b;
/* 阴影(统一阶梯) */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 2px 8px rgba(15, 23, 42, 0.06); /* 替代 --card-shadow */
--shadow-lg: 0 8px 24px rgba(15, 23, 42, 0.08);
--shadow-dialog: 0 20px 40px rgba(15, 23, 42, 0.12);
/* 尺寸 */
--header-height: 60px;
--sidebar-width: 260px;
--content-padding-x: 32px;
--content-padding-y: 32px;
}
```
**改动原则**
- 组件 SCSS 内的硬编码颜色全部替换为对应变量
- 任何新增颜色需求都先在 `:root` 定义,再引用
- 不再使用 `!important`;如确需覆盖,提升特异性(如 `:deep(.el-button)`
### 2. 图标库迁移FA4 → Element Plus Icons
**为什么选 Element Plus Icons**
- 零新增依赖成本(仅在 `package.json` 增加一行)
- 与 Element Plus 组件视觉风格天然一致
- 树摇友好,按需引入
- SVG 渲染,可继承 `currentColor``font-size`,比 FA 字体图标更现代
**实施**
1. `npm i @element-plus/icons-vue`
2. `main.ts` 全局注册常用图标(按需在组件内 import 也可)
3. 替换规则(在每个 view/component 内做局部替换):
| FA4 类名 | Element Plus 图标 | 用在哪 |
|---------|------------------|--------|
| `fa-cogs` | `Setting` | 侧边栏"模型调优" |
| `fa-line-chart` | `TrendCharts` | 侧边栏"模型评测" |
| `fa-server` | `Monitor` | 侧边栏"模型推理" |
| `fa-cube` | `Box` | 侧边栏"模型管理"、Logo |
| `fa-file-text` | `Document` | 侧边栏"数据集" |
| `fa-wrench` | `Tools` | 侧边栏"其他工具" |
| `fa-bar-chart` | `DataAnalysis` | 侧边栏"平台性能" |
| `fa-search` | `Search` | 搜索框前缀 |
| `fa-plus` | `Plus` | 新建按钮 |
| `fa-trash` | `Delete` | 删除按钮 |
| `fa-arrow-left` | `ArrowLeft` | 返回按钮 |
| `fa-microchip` | `Cpu` | 性能监控 |
| `fa-database` | `Files` | 内存监控(数据文件堆) |
| `fa-hdd-o` | `Box` | 磁盘监控(存储箱) |
| `fa-sign-out` | `SwitchButton` | 退出登录 |
| `fa-angle-down` | `ArrowDown` | 下拉箭头 |
| `fa-user-circle-o` | `User` | 个人中心 |
| `fa-cog` | `Setting` | 系统设置 |
| `fa-stop-circle-o` | `VideoPause` | 停止训练 |
| `fa-pencil` | `Edit` | 编辑 |
| `fa-times` | `Close` | 关闭 |
| `fa-globe` | `Connection` | 网络流量 |
| `fa-info-circle` | `InfoFilled` | 系统信息 |
| `fa-user-o` | `User` | 登录页账号图标 |
| `fa-lock-o` | `Lock` | 登录页密码图标 |
| `fa-sign-in` | `Right` | 登录按钮 |
**注意点**
- 替换后调整图标尺寸Element Plus Icons 用 `size` prop 或 CSS `font-size`
- 部分 FA4 类名(如 `fa-file-text-o`)在 FA5/FA6 已无 `-o` 后缀
- `main.ts` 中删除 `import '@/assets/font-awesome/css/font-awesome.min.css'`
-`src/assets/font-awesome/` 整个目录(字体文件 100+KB
### 3. 登录页重新设计(`LoginView.vue`
**目标**:与主应用保持一致的视觉语言,背景改用渐变 + 玻璃卡片。
**设计**
- 背景:渐变 `linear-gradient(135deg, #4f46e5 0%, #818cf8 50%, #c7d2fe 100%)`
- 卡片:白色,圆角 16px阴影 `var(--shadow-dialog)`
- Logo 区域上下结构Logo 48px → 标题 24px → 副标题 14px 灰色)
- 表单:单项之间 20px 间距,输入框 44px 高,圆角 8px
- 按钮主色实心44px 高,全宽
- 选项行flex 布局,记住密码左、忘记密码右
- 整体居中,最大宽 420px
**关键 CSS 改动**
- 背景从 `#dbe0f9` 改为 `linear-gradient(135deg, #4f46e5 0%, #818cf8 50%, #c7d2fe 100%)`
- 卡片圆角从 12px 提升到 16px
- 阴影替换为 `var(--shadow-dialog)`
- 所有内联颜色改用变量
### 4. 修复 `AppHeader.vue` 的 `--warning-color` 缺失
**问题**`AppHeader.vue:189` 使用 `var(--warning-color)`,但 `index.scss` 里只定义了 `--danger-color``--success-color`,没有 `--warning-color`
**修复**:在 `:root` 中新增 `--warning-color: var(--el-color-warning);`(已在第 1 节列入)。
### 5. 整理 `index.scss` 的 `!important`
**现有 `!important` 用法**(来自 `index.scss`
| 选择器 | 用途 | 现状 |
|--------|------|------|
| `.el-card { border: none !important; ... }` | 去掉 EP 默认边框 | 提升为 `.app-container .el-card` 或全局加 `:where()` 重置 |
| `.el-tag { border: none !important; ... }` | 去掉 EP 默认边框 | 同上 |
| `.el-dialog { border-radius: 16px !important; ... }` | 圆角和阴影 | 同上 |
**改写策略**
- 用更高特异性选择器(如 `body .el-card`)替代 `!important`
- 或用 CSS Layer (`@layer reset, overrides, components`) 控制优先级
**本次采用**:直接提高特异性,避免引入 CSS Layer 复杂度(属于项目既定风格之外的引入,需要权衡)。
### 6. 降低装饰性效果
**调整清单**
- `index.scss` 移除 `.el-button:hover { transform: translateY(-1px); box-shadow: ...; }` — 改为仅改变背景色
- `AppHeader.vue` 移除 `pulse` 动画,改为静态红色
- 侧边栏菜单图标的 `transform: scale(1.1)` 悬停动画保留(这是细节点缀,不算噪音)
- 卡片悬停 `transform` 动效保留(仅在 DataTablePage 等列表卡片上,目前没有,不新增)
- 顶栏 `backdrop-filter: blur(12px)` 保留(玻璃感是这个项目的风格语言)
## 实施步骤(落地顺序)
按风险从低到高,便于逐步验证:
1. **步骤 1**:扩展 `src/styles/index.scss` 变量(新增,不删除旧的)
2. **步骤 2**:安装 `@element-plus/icons-vue`,在 `main.ts` 注册
3. **步骤 3**:逐文件替换图标(按表格顺序)
4. **步骤 4**:逐文件替换硬编码颜色为变量
5. **步骤 5**:重新设计 `LoginView.vue`
6. **步骤 6**:删除 `src/assets/font-awesome/` 目录和 `main.ts` 的 import
7. **步骤 7**:运行 `npm run type-check``npm run build` 验证
每步完成后截图(手动)确认未引入新问题。
## 验证标准
- [ ] `npm run type-check` 通过
- [ ] `npm run build` 通过
- [ ] 浏览器控制台无 CSS 警告(无未定义变量)
- [ ] 全站不再有 `#1890ff``#67c23a``#e6a23c``#f56c6c` 这类 EP 旧色硬编码grep 验证)
- [ ] 全站不再有 `class="fa fa-` 的引用grep 验证)
- [ ] 全站不再有 `--warning-color` 这类指向不存在变量的引用
- [ ] `grep -c "!important" src/styles/index.scss` 数量从当前值下降 50% 以上
- [ ] 登录页与主应用风格一致
## 风险与回退
- **风险 1**Element Plus Icons 不覆盖某些冷门 FA 图标(如 `fa-stop-circle-o` 训练停止)
- **应对**:在替换前先做一次图标对照表,确认每个 FA 类名都有 EP 对应项;没有则保留 FA4不删除 FA 资源),但推进用 EP 主流图标
- **风险 2**CSS 变量替换漏掉某处导致视觉回退
- **应对**:分阶段提交,每阶段都跑 build如有遗漏EP 会回退到自身默认色
- **风险 3**`!important` 整理引入新覆盖问题
- **应对**:整理前先 `git diff` 对比编译产物颜色变化,保留 `git stash` 能力
## 后续优化(不在本次范围)
- 抽出 Design Token 文件(`tokens.scss`)做 Figma → Code 同步
- 引入 UnoCSS 或 Windi CSS 做原子化样式(取决于团队接受度)
- 给所有列表页加 skeleton 加载态
- 暗色模式支持(需要先在 EP 主题层注册暗色)

View File

@@ -0,0 +1,194 @@
# 数据处理创建流程四步向导设计
日期2026-07-10
## 1. 目标
重构 `/data-process/create`,把当前职责混杂、步骤数量会变化的流程,调整为四个语义稳定、页面内容相互独立的步骤。用户在任一时刻只处理当前任务,同时始终知道所处阶段、前后关系和唯一主操作。
本次设计沿用现有平台视觉语言:浅灰应用背景、单层白色页面画布、靛蓝主色、克制边框与阴影、固定四步进度导航和底部操作栏。不得出现页面白底中再次嵌套整页白色大卡片的双层白底。
## 2. 固定流程
顶部步骤数量始终为四个,不随结构化或非结构化类型变化:
1. **创建任务**:填写任务信息、上传源文件并选择处理类型。
2. **数据预览**:检查系统解析结果;非结构化数据进入切片预览,结构化数据进入记录预览。
3. **开始生成**:确认摘要、启动生成并查看进度或错误。
4. **结果编辑与保存**:检查并修改生成结果,通过校验后保存任务。
步骤状态只有 `未开始``当前``已完成` 三种。用户返回已完成步骤时保留已经填写或编辑的内容,不因切换处理类型而静默清空任务名称、描述和上传文件。
## 3. 第一步:创建任务
页面只显示创建任务所需内容:
- 任务名称和可选描述。
- 结构化数据、非结构化数据两种处理类型。
- 单文件上传区、文件格式和大小约束。
- 上传后的基础识别结果:文件名、大小、格式、记录数或文本长度、校验状态。
底部操作:`取消``保存草稿`、唯一主操作 `继续:数据预览`
切换处理类型时仅更新后续预览和生成策略;如果已经产生预览结果,需要明确提示预览将重新计算,确认后才清除后续步骤数据。
## 4. 第二步:数据预览
### 4.1 页面职责
本页只负责核对源数据与系统预览结果的对应关系,并允许修改不合理的切片或记录内容。不得显示第一步的大量表单、第三步的生成进度或第四步的最终结果表格。
页面顶部仅保留一行来源摘要,例如:
`finance_qa.jsonl · 非结构化数据 · 8,560 个切片`
### 4.2 非结构化数据:源文件与切片对照
主体为同一白色画布中的左右分栏,使用竖向分隔线区分,不使用两个浮动大卡片:
- **左侧约 58%:源文件阅读器**
- 只读展示完整源文件,支持滚动和行号。
- 当前切片对应的原文使用浅靛蓝背景高亮,并在左边缘显示定位条。
- 顶部提示当前定位,例如 `已定位到切片 #002`
- 左侧原文始终保持源文件真实性,修改右侧切片不会直接改写源文件。
- **右侧约 42%:切片内容**
- 上方为切片列表,支持按编号或内容搜索、仅查看已修改项。
- 每个切片显示编号、来源行范围、Token 数和修改状态。
- 下方为当前切片的可编辑正文、字符数、Token 数和自动保存状态。
- 提供上一片、下一片和恢复原始内容。
### 4.3 对照定位交互
右侧切片是主选择源,左侧原文是定位反馈:
1. 用户点击右侧切片。
2. 系统读取该切片保存的原始来源范围 `sourceStart``sourceEnd`
3. 左侧阅读器自动滚动到目标范围,并将对应原文高亮。
4. 右侧切片进入选中态,左右使用一致的浅靛蓝语义色建立对应关系。
5. 用户切换上一片或下一片时执行相同的同步定位。
为了避免跳动,目标范围应滚动到阅读区中部;如果目标已完全可见,则只更新高亮,不重复滚动。
### 4.4 编辑与来源映射
- 编辑切片正文只改变待生成内容,来源映射仍指向最初的原文范围。
- 编辑后显示 `已修改,草稿已自动保存`,并允许恢复到系统初始切片内容。
- 修改后的文本与原文不再完全一致时,左侧仍高亮原始来源,右侧显示 `内容已调整`,避免误解为源文件被改写。
- 手动新增的切片没有原始来源,显示 `手动新增,无源文件定位`,左侧清除高亮但保留当前位置。
- 删除切片需要二次确认;删除只影响本次处理,不修改源文件。
- 找不到来源范围或范围越界时,右侧显示 `无法定位原文`,左侧不进行错误滚动,用户仍可编辑该切片。
### 4.5 结构化数据适配
第二步的整体布局和交互不变,但语义调整为记录对照:
- 左侧展示源文件中的表格行或原始 JSON 记录。
- 右侧展示系统解析后的单条记录及字段值。
- 点击右侧记录,左侧定位并高亮对应源行或 JSON 范围。
这样可以保持四步流程稳定,同时避免对结构化数据使用不准确的“切片”文案。
底部操作:`返回:创建任务``保存草稿`、唯一主操作 `确认预览并继续`
## 5. 第三步:开始生成
页面只显示:
- 任务名称、数据类型、文件、预览条目数和修改条目数的只读摘要。
- 唯一启动操作 `开始生成`
- 启动后的进度、当前阶段、耗时和状态日志。
- 失败时显示错误摘要与 `重试`;运行中提供 `停止生成`,停止前需要二次确认。
启动前不显示底部“下一步”与页面内“开始生成”两个竞争按钮。生成成功后,唯一主操作切换为 `查看生成结果`
## 6. 第四步:结果编辑与保存
结果不再使用一组原始 JSON 文本框堆叠展示,而采用结构化编辑列表:
- 桌面端左侧为结果条目列表,支持搜索、错误筛选和已修改筛选。
- 桌面端右侧为当前结果的字段编辑区;较窄窗口改为列表在上、编辑区在下。
- 每条结果显示校验状态,错误定位到具体字段。
- 支持上一条、下一条、恢复生成结果。
- 保存前执行完整校验;有阻断错误时禁止保存并聚焦首个错误。
底部操作:`返回:开始生成``保存草稿`、唯一主操作 `保存任务`
## 7. 状态与数据模型
向导需要维护统一草稿状态:
```ts
interface DataProcessDraft {
task: TaskInfo
sourceFile: SourceFileMeta
processingType: 'structured' | 'unstructured'
previewItems: PreviewItem[]
selectedPreviewId: string | null
generation: GenerationState
results: ResultItem[]
currentStep: 1 | 2 | 3 | 4
dirty: boolean
}
interface PreviewItem {
id: string
originalContent: string
editedContent: string
sourceStart: number | null
sourceEnd: number | null
sourceStartLine: number | null
sourceEndLine: number | null
tokenCount: number
status: 'original' | 'modified' | 'manual' | 'invalid'
}
```
源文件位置以字符偏移作为定位依据,行号用于展示。这样即使原文存在长行或不同换行符,也能稳定高亮准确范围。
## 8. 页面组件边界
- `DataProcessWizardShell`:固定四步导航、页面标题和底部操作栏。
- `TaskSetupStep`:任务信息、类型和文件上传。
- `PreviewStep`:数据预览容器,按处理类型选择预览器。
- `SourceViewer`:源文件渲染、滚动定位和范围高亮。
- `PreviewItemList`:切片或记录列表、搜索与选择。
- `PreviewItemEditor`:当前条目编辑、状态和恢复。
- `GenerationStep`:启动、进度、失败与重试。
- `ResultReviewStep`:生成结果列表、字段编辑和保存校验。
`SourceViewer` 不感知切片列表内部逻辑,只接收选中范围;`PreviewItemList` 不直接操纵滚动,只上报选中项。同步行为由 `PreviewStep` 统一协调。
## 9. 异常与离开保护
- 上传失败、解析失败、预览生成失败分别提供明确原因和重试入口。
- 预览数据加载时显示骨架或局部加载状态,不能让整个白色画布闪烁或短暂回退到列表页。
- 刷新、返回列表或关闭页面前,如果存在未持久化修改,必须提示保存草稿或放弃更改。
- 自动保存失败时保留本地编辑内容,并显示可重试状态。
- 生成过程中禁止无提示切换处理类型或替换源文件。
## 10. 响应式与可访问性
- 桌面端左右对照为默认布局,分栏宽度允许拖动但设置最小宽度。
- 较窄窗口下改为上下布局:源文件在上,切片在下;选中切片后仍自动定位高亮。
- 选中、高亮、修改和错误状态不能只依赖颜色,还需文字、图标或边缘标记。
- 键盘支持上下切换切片、聚焦编辑器、保存草稿;滚动定位后不抢走编辑器焦点。
## 11. 验收标准
1. 结构化和非结构化流程都始终显示四个固定步骤。
2. 每一步只包含该阶段必要内容,页面中不存在跨步骤主操作竞争。
3. 点击任一有来源映射的右侧切片后,左侧在一次交互内定位并高亮准确原文范围。
4. 已完全可见的范围不会产生多余滚动;不可定位时有明确反馈且不破坏编辑。
5. 修改切片不会改写左侧源文件,高亮仍指向原始来源。
6. 返回前一步再进入时,选中项、编辑内容、滚动上下文和草稿状态可恢复。
7. 生成开始前只存在一个明确启动操作;生成成功后才允许进入结果编辑。
8. 最终结果保存前执行字段级校验,阻断错误可准确定位。
9. 路由切换不出现列表页或灰色背景闪烁,白色页面画布保持连续。
10. 页面在常用桌面宽度下无横向溢出,底部操作栏不遮挡主体内容。
## 12. 视觉参考
- 当前流程截图:`docs/superpowers/specs/assets/data-process-create-audit/01-create-task.png`
- 当前动态步骤截图:`docs/superpowers/specs/assets/data-process-create-audit/02-unstructured-step-change.png`
- 最终左右对照修订稿:`/Users/caoxiaozhu/.codex/generated_images/019f4981-cb06-79f0-b387-1d685e74ceea/exec-c72de596-4c50-405b-8987-43d122e02b86.png`

View File

@@ -0,0 +1,27 @@
# 数据处理任务状态切换移除设计
## 目标
移除数据处理任务列表左上角的“全部任务”“处理中”“已完成”状态切换组件。
页面默认展示全部任务,用户仍可通过任务状态列查看每条任务的当前状态。
## 改动范围
- 删除 `DataProcessListView.vue` 中的状态切换按钮及标题插槽。
- 删除仅服务于该组件的 `activeTab` 状态和 `filteredDataList` 计算逻辑。
- 将任务表格的数据源直接绑定为完整的 `dataList`
- 删除仅服务于状态切换组件的胶囊样式。
## 保留行为
- 保留任务名称搜索。
- 保留任务状态列及状态标签。
- 保留新建任务、详情、删除和分页等现有行为。
- 不修改任务 Mock 数据及后端接口设计。
## 验证标准
- 页面不再出现“全部任务”“处理中”“已完成”三个切换按钮。
- 表格默认展示所有状态的任务。
- 页面不存在遗留的状态切换变量、筛选分支或专用样式。
- 前端类型检查和相关回归检查通过。

View File

@@ -0,0 +1,35 @@
# 数据预览详情页头部操作移除设计
## 目标
移除数据预览详情页右上角的“打包下载”“删除”“返回”三个按钮,
让该页面仅承担数据集文件与内容预览职责。
## 范围
- 删除 `DatasetPreviewView.vue` 的页头额外操作区域及其三个按钮。
- 删除仅被这三个按钮使用的整包下载、删除和路由返回逻辑及依赖。
- 保留文件列表中的单文件“预览”和“下载”操作。
- 保留数据集信息、文件列表与内容预览区域。
## 方案比较
1. 直接删除页面头部按钮和失效逻辑(采用):无隐藏交互与无用依赖,页面职责清晰。
2. 使用 CSS 隐藏按钮:按钮逻辑仍留在页面中,键盘和后续维护可能误暴露功能。
3. 增加运行时开关:为固定的展示要求引入无必要状态和分支。
## 实现设计
- 移除 `PageCard``extra` 插槽。
- 从组件中移除 `useRouter``ElMessage``ElMessageBox``deleteDataset`
`downloadDatasetUrl` 及对应处理函数。
- 保留 `handleDownloadFile``downloadFileUrl`,确保文件级下载仍可用。
- 新增回归检查,断言详情页不再含这三个页头按钮或其对应处理函数,
并断言文件级预览、下载操作仍存在。
## 验收标准
1. 数据预览详情页不显示“打包下载”“删除”“返回”按钮。
2. 文件列表中的“预览”和“下载”按钮继续显示并可用。
3. 页面不保留已删除头部操作的事件处理函数与未使用依赖。
4. 针对性回归检查和生产构建通过。

View File

@@ -0,0 +1,58 @@
# 数据集管理“数据任务”Mock 数据设计
## 目标
让数据集管理页的“数据任务”页签展示可用于界面演示的任务产出数据,
同时保持“本地上传”页签、数据集预览和下载入口的现有行为。
## 现状
- 数据集列表统一从 `mockDatasets` 获取。
- “本地上传”页签当前展示全部数据集。
- “数据任务”页签固定返回空数组,因此始终为空。
- 数据集详情、预览和下载均基于同一套 Mock 数据接口。
## 方案比较
1. 为数据集增加来源字段并统一过滤(采用)。单一数据源,页签语义清晰,
新增的任务数据可继续复用详情、预览和下载接口。
2. 在列表页内单独维护任务数据。改动较少,但会形成第二份数据源,详情接口
无法自然找到这些记录。
3. 从数据处理任务动态生成数据集。数据关联更强,但需要重构任务 Mock 数据,
超出本次只增加演示数据的范围。
## 数据设计
-`DatasetItem` 中增加可选来源字段 `source`,取值为 `upload``task`
- 现有 6 条 Mock 数据明确标记为 `upload`
- 新增 4 条来源为 `task` 的 Mock 数据,覆盖训练集、评测集等类型,包含名称、
存储位置、大小、条数、描述和创建时间。
- 新记录使用不重复的 ID并保留现有 Mock 适配器的统一查询路径。
## 页面行为
- “本地上传”仅展示 `source !== 'task'` 的数据,兼容后端暂未返回来源字段的情况。
- “数据任务”仅展示 `source === 'task'` 的数据。
- 搜索、分页、预览、下载和删除按钮保持现有行为。
- 切换页签后,公共列表组件会在数据变化时回到第一页并清空选中状态。
## 异常与边界
- 未携带 `source` 的真实接口数据默认归入“本地上传”,避免升级期间数据消失。
- 任务数据 ID 不与现有数据冲突,详情查询仍能返回对应记录。
- 本次不新增依赖,不修改后端接口,不实现真实的数据任务关联。
## 验证
- 先添加一个能验证来源字段和页签过滤规则的回归检查,再实现变更。
- 运行项目类型检查与生产构建;若存在仓库原有错误,需确认本次改动文件没有
引入新的错误。
- 启动页面后检查两个页签:本地上传显示 6 条,数据任务显示 4 条,任务数据可被
搜索且表格字段完整。
## 验收标准
1. “数据任务”页签展示 4 条假数据。
2. “本地上传”页签不混入任务生成的数据。
3. 两个页签的搜索、分页和操作列正常显示。
4. 新增类型定义与 Mock 数据通过针对性回归检查。

View File

@@ -0,0 +1,53 @@
# 页面表面分类设计
## 目标
修正主布局无条件套用白色页面画布的问题,使页面层级符合以下规则:
- 列表页已经拥有自己的白色卡片,因此直接放在灰色应用背景上。
- 表单页、详情页和对话页继续使用统一的白色圆角页面画布。
- 页面分类由路由显式声明,避免依赖路径字符串或 DOM 结构猜测。
## 根因
`MainLayout.vue` 当前无条件为所有子路由渲染 `.page-canvas`。同时,列表页通过
`DataTablePage``.eval-card-container``.manage-card-container` 自带白色卡片,
因此形成“灰色外层 → 白色页面画布 → 白色列表卡片”的重复层级。
## 设计
在列表路由的 `meta` 中增加 `pageSurface: 'self'`
- `/fine-tune`
- `/model-eval`
- `/model-inference`
- `/model-manage`
- `/data-process`
- `/dataset`
其他路由保持默认行为,不需要逐项声明,仍由主布局提供白色页面画布。
`MainLayout.vue` 根据 `route.meta.pageSurface``.page-canvas` 添加
`.is-self-surface` 状态类。该状态仅取消外层画布的白色背景、圆角、阴影和内边距,
保留列表页自身的卡片样式。
对于默认白色画布页面,如果路由直接输出 `PageCard`,可直接扁平化;如果由页面
根容器包裹主 `PageCard`,根容器必须显式声明 `.page-card-host`。只有这两类主卡片
改为透明、无圆角、无阴影,内部业务卡片不受影响,避免创建页出现“白色画布再套
白色卡片”,同时不误伤训练日志的参数卡片。
## 验收标准
1. 六个列表路由只显示自身的一层白色卡片,外部可见灰色页面背景。
2. `/training-log/:id`、创建页、编辑页等仍显示白色圆角页面画布。
3. 不根据 URL 文本或 DOM 自动判断页面类型。
4. 不改变列表、表单和详情页现有业务逻辑。
5. 桌面宽度下不产生水平滚动。
6. 创建训练任务等表单页只显示一层白色画布,根 `PageCard` 不形成第二层表面。
## 测试策略
- 静态回归脚本验证六个列表路由都声明 `pageSurface: 'self'`
- 静态回归脚本验证 `MainLayout` 根据路由 meta 设置状态类。
- 静态回归脚本验证 `.is-self-surface` 取消白底、内边距、圆角和阴影。
- 浏览器分别检查一个列表页和一个详情页。

View File

@@ -0,0 +1,25 @@
# 数据预览切片编辑模式
## 目标
第二步右侧区域不再把切片列表和正文编辑器上下堆叠。默认仅展示可搜索、可分页的切片列表;编辑时在同一右侧区域进入单独的编辑视图。
## 列表模式
- 每行显示切片编号与来源行号。
- 每行右侧提供铅笔和垃圾桶图标按钮,分别用于编辑与删除。
- 不显示 Token、修改状态和“仅看已修改”筛选。
- 保留按编号或正文搜索及每页 6 条分页。
## 编辑模式
- 点击铅笔后,右侧替换为当前切片的编辑界面,左侧源文件继续高亮对应行。
- 编辑使用组件内临时草稿;输入过程不更新父级预览数据。
- “保存修改”将草稿写回父级数据并返回列表。
- “取消”或“返回列表”丢弃未保存草稿并返回列表,用户可继续选择其他切片。
- 删除继续使用确认弹窗;确认后删除该条并返回列表。
## 边界与验证
- 翻页、搜索或切换文件时,应退出编辑模式并丢弃未保存草稿,避免将内容写到错误条目。
- 切片列表、编辑模式切换、保存和取消行为由向导回归脚本覆盖。

View File

@@ -0,0 +1,27 @@
# 二级页面路由转场设计
## 目标
消除列表页进入新建、详情、编辑等二级页面时的透明闪烁,使路由内容与页面表面模式在同一帧完成切换。
## 根因
`MainLayout.vue` 使用 `transition name="fade" mode="out-in"`。路由变化时,
`route.meta.pageSurface` 会立即切换外层画布模式,但旧页面仍执行 150ms 的离场淡出,
因此旧列表会短暂显示在新页面的白色画布中并处于半透明状态。
## 已选方案
移除主布局的页面级 `transition` 和对应 `.fade-*` 样式,让 `router-view` 组件立即替换。
列表、详情、新建和编辑页面的表面模式将与内容同步切换。
保留组件内部动效,包括弹窗、折叠、按钮状态、批量操作栏等局部交互动画。
## 验收标准
1. `MainLayout.vue` 不再使用页面级 `<transition>`
2. 主布局不再包含 `.fade-enter-*``.fade-leave-*` 透明度规则。
3. 列表进入新建、列表进入训练日志、二级页面返回列表时,不出现旧页面半透明中间帧。
4. 页面表面分类和根 `PageCard` 扁平化逻辑保持不变。
5. 页面无水平溢出,控制台无新增错误。

View File

@@ -0,0 +1,86 @@
# 源数据上传紧凑文件列表设计
日期2026-07-10
## 1. 目标
优化数据处理创建页“源数据上传”区域中已选文件的呈现方式。当前每个文件
占用一张独立的大卡片;文件数量增多时,页面会被文件清单拉长,用户也难以
快速定位和删除某一文件。
本次改动将已上传文件改为每页最多 10 条的紧凑列表,并让上传入口随文件状态切换:
空状态使用大尺寸拖拽上传区,已有文件时切换为列表标题右侧的小型继续上传按钮。
这样能保持文件状态和逐项删除能力,让大量文件的查看与操作集中在一个稳定区域内。
## 2. 方案选择
评估过三种方式:
1. 文件名标签流:占用最少,但长文件名、状态与删除操作会拥挤,不适合本页。
2. 折叠摘要:页面最短,但需要额外展开才能操作,增加一步操作成本。
3. 固定高度紧凑列表:每个文件是一行,状态和删除按钮始终可见;超过可见
行数后在列表内滚动。
采用第三种方案。它保留当前已有的逐项删除能力,同时解决页面高度随文件数
量线性增长的问题。
## 3. 布局与交互
空状态只显示现有的大尺寸拖拽上传区及格式提示,用于首次添加文件。
存在文件后,隐藏大尺寸拖拽上传区和其格式提示,在原位置显示文件列表:
- 列表顶部左侧显示 `已添加 N 个文件`,其中 `N` 为当前文件数;右侧显示
小型 `继续上传` 按钮。
- `继续上传` 复用原上传控件的多文件选择、格式限制、非自动上传和文件变更事件,
只改变触发入口的尺寸与位置。
- 每页最多显示 10 行;超过 10 个文件时,在列表底部显示分页器,不使用长列表
或内部滚动承载额外文件。
- 新增文件后自动切换到最后一页,确保用户能立刻看到刚添加的文件;删除当前页
的最后一个文件后,自动回退到仍有文件的最后一页。
- 单行高度约 48px使用细分隔线区分不再使用外框、左侧绿条和大尺寸图标。
- 每行从左至右依次为:文件类型图标、文件名、大小与条目数、校验状态、删除。
- 文件名占据可伸缩区域;空间不足时省略,保留原始 `title` 以便悬停查看全名。
- 删除按钮沿用红色文字链接,点击后继续使用当前的移除事件;不新增确认弹窗
或批量删除,以免改变既有操作语义。
- 校验状态沿用绿色图标和“校验通过”文案;后续若存在失败状态,可在同一位置
显示错误文案而不改变列表结构。
## 4. 视觉约束
- 延续页面的靛蓝文件图标、绿色成功状态和红色危险操作色;继续上传按钮使用现有
主色的紧凑次级按钮样式。
- 列表容器使用现有浅灰边框和 8px 圆角,背景保持白色;不新增大卡片层级。
- 仅当文件数超过 10 个时显示分页器;分页器使用紧凑的上一页、页码、下一页布局,
不提供页大小选择或跳页输入,避免增加不必要的操作。
- 在窄屏下,元信息可收缩,文件名仍保持最优先;删除按钮必须始终可见且可点按。
## 5. 组件边界与数据流
改动只在 `TaskSetupStep` 内完成:
- 继续使用现有 `uploadedFiles` 属性和 `remove-file` 事件。
- 不修改文件上传、格式限制、校验逻辑或父组件中的文件数据模型。
- 新增的文件总数来自 `uploadedFiles.length`,每行信息继续来自现有文件元数据。
## 6. 可访问性
- 删除按钮继续使用真实的 `el-button`,确保键盘可聚焦和激活。
- 文件名省略时通过原生 `title` 暴露完整名称。
- 校验状态同时保留图标和文字,不能只依靠颜色传达结果。
- 滚动列表不截断键盘焦点;聚焦到不可见的删除按钮时,浏览器应能将对应行带入
可见区域。
## 7. 验收标准
1. 尚未添加文件时,只显示大尺寸拖拽上传区与格式提示,不显示文件列表。
2. 上传至少 1 个文件后,大尺寸上传区与格式提示不再显示;文件列表标题右侧显示
可继续选择多个文件的小型上传按钮。
3. 上传 1 至 10 个文件时,所有文件在紧凑列表中直接可见,每行均可单独删除。
4. 上传超过 10 个文件时,列表一次只渲染当前 10 条,分页器可切换其他文件页;
页面高度不随总文件数继续增长。
5. 新增文件后自动显示最后一页;删除造成当前页没有文件时,自动回到有效页。
6. 每一行均展示文件名、大小、可选条目数、校验状态和删除操作。
7. 长文件名不会挤压删除按钮,且悬停时可获取完整名称。
8. 处理类型切换、格式限制、首次上传、继续上传和删除事件的现有行为不变。
9. 常用桌面宽度和窄屏下均无横向溢出,删除操作保持可访问。

View File

@@ -0,0 +1,68 @@
# 训练日志详情页双栏改版设计
**日期**2026-07-10
**页面**`frontend/src/views/system/TrainingLogView.vue`
**视觉基准**:用户选定的第 2 套方案(双栏任务档案型)
**视觉稿**`docs/superpowers/specs/assets/training-log-detail-option-2.png`
## 目标
把当前多组带边框 `el-descriptions` 表格改成更易扫描的任务档案页:左侧聚合任务信息与数据集,右侧集中展示运行概况,下方用轻量折叠区呈现训练参数。数据请求、日志刷新、训练曲线和原始日志行为保持不变。
## 选定布局
1. 页面顶部保留任务名与状态,并增加任务类型、创建时间等辅助上下文。
2. 首屏主体使用 `2fr / 0.9fr` 双栏:
- 左栏依次为“任务信息”和“训练数据集”;
- 右栏为“运行概况”,集中展示时间、时长、存储、训练方式和训练方法。
3. 数据集名称与描述优先,条数和大小放入同一条浅色指标带。
4. 训练参数与 LoRA 参数使用同一卡片内的两个分组;标签和值采用轻分隔行,不再使用描边表格。
5. 缺失值统一显示“未配置”,输出模型缺失显示“暂未生成”,避免大量孤立的 `-`
## 视觉规则
- 应用外壳使用 `--app-shell-bg: #f3f5f8`,灰色只作为页面四周的留白背景。
- 每个业务路由统一渲染在 `--app-page-bg: #ffffff` 的白色圆角页面画布中,所有页面内容都基于这层白底布局。
- 页面内部白色内容表面继续使用 `--app-surface-bg: #ffffff`;当路由根节点本身是 `PageCard` 时,移除其额外阴影和外边距,避免白底里再套一张重复大卡片。
- 沿用项目现有 Indigo `#4f46e5`、Slate 灰阶和成功色 `#10b981`
- 一级卡片使用现有 `PageCard` 和 12px 实际圆角;内部块使用 8px 圆角。
- 仅使用 `#e2e8f0` / `#f1f5f9` 轻分隔,不新增阴影层级和渐变。
- 标题 18px/600分组标题 16px/600正文值 14px辅助标签 1213px。
- 图标继续使用项目现有 Font Awesome 4避免引入依赖或扩大本次改造范围。
## 交互与响应式
- 参数折叠头改为原生 `button`,提供 `aria-expanded`、可见焦点和至少 44px 点击高度。
- 宽屏为双栏;`<= 1100px` 改为单栏;参数明细在窄屏下降为单列。
- 保持 5 秒自动刷新、图表、汇总和原始日志逻辑不变。
## 范围
### 修改
- `frontend/src/views/system/TrainingLogView.vue`
- `frontend/src/layouts/MainLayout.vue`
- `frontend/src/styles/index.scss`
- `frontend/package.json`
- `frontend/scripts/regression-training-log-layout.mjs`
- `frontend/scripts/regression-page-surface.mjs`
### 不修改
- API、Pinia、类型定义、路由和 mock 数据。
- 全局 `PageCard.vue`
- 训练曲线、训练汇总和日志输出的业务逻辑。
## 验收标准
- 首屏不再使用带边框的详情表格表达任务、数据集和训练参数。
- 双栏信息架构与选定视觉稿一致,缺失值文案可读。
- 参数折叠支持鼠标、键盘和辅助技术。
- 1100px 以下无双栏挤压或横向滚动。
- 两个专项回归脚本与 `npx vite build` 通过。
- `npm run type-check` / `npm run build` 使用项目引用模式执行;当前仓库原有的 mock 与微调页面类型错误需如实记录,且本次改动文件不得出现在错误列表中。
## 风险
- 生成视觉稿只覆盖首屏,图表区继续沿用现状;实现以首屏信息架构为高保真目标。
- 项目目录没有 Git 元数据,无法提交设计文档或代码提交;所有变更保持小范围、可直接审阅。

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

22
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
node_modules
dist
dist-ssr
*.local
# 自动生成的类型声明
auto-imports.d.ts
components.d.ts
# Editor
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

66
frontend/README.md Normal file
View File

@@ -0,0 +1,66 @@
# 远光软件微调平台 - 前端Vue 3
> 由原 `web/` 静态多页面 HTML 项目改造而来。
## 技术栈
- **Vue 3** + **TypeScript** + **Vite**
- **Vue Router 4** - 路由
- **Pinia** - 状态管理
- **Element Plus** - UI 组件库(主色 `#1890ff`
- **axios** - HTTP 请求
- **Chart.js** - 训练曲线图表
- **marked + DOMPurify** - Markdown 渲染
- **md-editor-v3** - Prompt 编辑器
- **@vueuse/core** - 组合式工具
## 快速开始
```bash
cd frontend
npm install
npm run dev
```
开发服务器默认运行在 `http://localhost:6801`
后端 API 默认通过 Vite 代理转发到 `http://localhost:7861`(见 `vite.config.ts`)。
## 构建
```bash
npm run build # 类型检查 + 生产构建,产物在 dist/
npm run preview # 本地预览构建产物
npm run type-check # 仅类型检查
```
## 目录结构
```
src/
├── api/ # axios 封装 + 各业务模块 API
├── components/ # 公共组件(侧边栏、顶部栏、通用列表等)
├── composables/ # 组合式函数(流式对话、图表、轮询等)
├── constants/ # 常量与映射表
├── layouts/ # 主布局
├── router/ # 路由配置 + 登录守卫
├── stores/ # Pinia 状态auth/system/models/tools
├── styles/ # 全局样式与主题
├── types/ # TypeScript 类型定义
└── views/ # 页面视图
├── login/ # 登录
├── fine-tune/ # 模型调优
├── eval/ # 模型评测
├── inference/ # 模型推理
├── compare/ # 模型对比
├── model/ # 模型管理
├── dataset/ # 数据集
├── tools/ # 其他工具
└── system/ # 平台性能 / 日志
```
## 与原 web/ 项目的关系
-`web/` 目录**保留不动**,作为功能对照参照。
- 新前端位于 `frontend/`,新旧可并存。
- 后端 API 端点完全不变,仅前端实现方式改为 Vue SPA。

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>远光软件微调平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4242
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
frontend/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "yg-ft-platform-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"type-check": "vue-tsc -b --noEmit",
"test": "node scripts/run-regressions.mjs",
"test:login-layout": "node scripts/regression-login-layout.mjs",
"test:default-dashboard": "node scripts/regression-default-dashboard.mjs",
"test:dashboard": "node scripts/regression-dashboard.mjs",
"test:data-process-list": "node scripts/regression-data-process-list.mjs",
"test:data-process-detail": "node scripts/regression-data-process-detail.mjs",
"test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs",
"test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs",
"test:dataset-preview": "node scripts/regression-dataset-preview.mjs",
"test:data-convert": "node scripts/regression-data-convert.mjs",
"test:eval-create": "node scripts/regression-eval-create-wizard.mjs",
"test:eval-detail": "node scripts/regression-eval-detail.mjs",
"test:model-manage": "node scripts/regression-model-manage.mjs",
"test:hardware": "node scripts/regression-hardware-dashboard.mjs",
"test:user-settings": "node scripts/regression-user-settings.mjs",
"test:training-log-layout": "node scripts/regression-training-log-layout.mjs",
"test:fine-tune-create": "node scripts/regression-fine-tune-create-ui.mjs",
"test:page-surface": "node scripts/regression-page-surface.mjs"
},
"dependencies": {
"@vueuse/core": "^11.3.0",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"dompurify": "^3.2.3",
"echarts": "^6.1.0",
"element-plus": "^2.9.1",
"marked": "^15.0.5",
"md-editor-v3": "^5.1.4",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-echarts": "^8.0.1",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.83.0",
"typescript": "~5.7.2",
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^0.28.0",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

View File

@@ -0,0 +1 @@

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
function read(relativePath) {
return readFileSync(resolve(root, relativePath), 'utf8')
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
const appHeader = read('src/components/AppHeader.vue')
const trainingLog = read('src/views/system/TrainingLogView.vue')
assert(
appHeader.includes('showBackButton'),
'AppHeader should gate the global back button behind showBackButton',
)
assert(
appHeader.includes('v-if="showBackButton"'),
'AppHeader should hide 返回上一页 when the current route is not a detail/sub page',
)
assert(
!trainingLog.includes('返回列表'),
'TrainingLogView should rely on the global 返回上一页 button instead of rendering 返回列表',
)
console.log('back-navigation regression checks passed')

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const [routerSource, dashboardSource, echartsSource, mainLayoutSource] = await Promise.all([
readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/views/dashboard/DashboardView.vue'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/plugins/echarts.ts'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), 'utf8'),
])
assert.match(
routerSource,
/path:\s*['"]dashboard['"][\s\S]*?DashboardView\.vue/,
'服务看板路由应使用独立 DashboardView',
)
assert.match(echartsSource, /import\s*\{[^}]*BarChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 BarChart')
assert.match(echartsSource, /use\(\[[\s\S]*?BarChart[\s\S]*?\]\)/, 'BarChart 未加入 ECharts 按需注册列表')
assert.match(echartsSource, /import\s*\{[^}]*PieChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 PieChart')
assert.match(echartsSource, /use\(\[[\s\S]*?PieChart[\s\S]*?\]\)/, 'PieChart 未加入 ECharts 按需注册列表')
for (const copy of [
'平台运行状态',
'近 7 天训练统计',
'训练次数(次)',
'GPU 使用数(个)',
'平均准确率(%',
'服务状态',
'训练任务',
'查看全部任务',
]) {
assert.ok(dashboardSource.includes(copy), `服务看板缺少关键内容:${copy}`)
}
assert.match(dashboardSource, /yAxis:\s*\[[\s\S]*?次数 \/ GPU 数[\s\S]*?准确率/, '柱状图应使用双 Y 轴表达不同单位')
assert.match(dashboardSource, /name:\s*['"]平均准确率(%['"][\s\S]*?yAxisIndex:\s*1/, '准确率柱应绑定右侧百分比坐标轴')
assert.match(dashboardSource, /router\.push\(['"]\/fine-tune['"]\)/, '查看全部任务应进入模型微调列表')
assert.match(dashboardSource, /router\.push\(`\/training-log\/\$\{task\.id\}`\)/, '训练任务详情应进入训练日志页')
assert.doesNotMatch(dashboardSource, /class=["']dashboard-heading["']/, '服务看板不应重复展示页面标题栏')
assert.doesNotMatch(dashboardSource, /查看告警/, '服务看板不应保留冗余的顶部告警按钮')
assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?gap:\s*16px;/, '服务看板区块间距应保持舒展')
assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?min-height:\s*100%;/, '服务看板应至少填满页面可用高度')
assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?min-height:\s*0;/, '中间区域不应因新增统计卡片被压缩')
assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*minmax\(300px,\s*0\.82fr\);/, '服务状态列应收窄,为训练图表释放更多宽度')
assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度')
assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸')
assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高')
assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*36px;/, '训练任务表格行应适当加高')
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*36px;[\s\S]*?th\s*\{[\s\S]*?height:\s*32px;/, '低高度桌面视口也应提高训练任务占比')
assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片')
assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示')
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数')
assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称')
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?grid:\s*\{\s*top:\s*8,\s*right:\s*12,\s*bottom:\s*6,\s*left:\s*8,[\s\S]*?max:\s*Math\.ceil\(Math\.max[\s\S]*?position:\s*['"]insideRight['"]/, '登录时长图应收紧左右边距、按数据范围拉伸,并将数值置于柱内')
assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列')
assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列')
assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列')
assert.match(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*auto;/, '服务看板应允许在内容超出视口时纵向滚动')
assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 0 auto;[\s\S]*?overflow:\s*visible;/, '服务看板画布应允许新增卡片完整显示')
console.log('服务看板回归检查通过')

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const sourceRoot = path.resolve(scriptDir, '../src')
const [viewSource, routerSource, toolsSource] = await Promise.all([
readFile(path.join(sourceRoot, 'views/data-convert/DataConvertView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
readFile(path.join(sourceRoot, 'views/tools/ToolsView.vue'), 'utf8'),
])
assert.match(viewSource, /JSON 转 JSONL/, '转换页缺少明确的格式标题')
assert.match(viewSource, /class="upload-zone"/, '转换页缺少源文件上传区域')
assert.match(viewSource, /class="converter-form"/, '转换页缺少标准表单区域')
assert.match(viewSource, /class="form-row"/, '转换页缺少输出配置区域')
assert.match(viewSource, /开始转换/, '转换页缺少主操作按钮')
assert.match(viewSource, /当前为 UI 原型/, '转换页没有明确说明 UI 原型范围')
assert.match(viewSource, /<el-button type="primary" disabled>/, '未接入功能前主操作按钮必须禁用')
assert.match(viewSource, /var\(--primary-color\)/, '转换页没有使用项目主色变量')
assert.match(viewSource, /var\(--el-color-primary-light-9\)/, '转换页没有使用项目主色浅色变量')
assert.doesNotMatch(viewSource, /#1890ff/i, '转换页仍包含未对齐当前主题的旧蓝色')
assert.match(viewSource, /\.converter-panel\s*\{[\s\S]*?width:\s*100%;/, '转换区域没有铺满页面宽度')
assert.match(viewSource, /min-height:\s*calc\(100vh\s*-\s*220px\)/, '转换区域没有铺满页面可用高度')
assert.doesNotMatch(viewSource, /max-width:\s*860px/, '转换区域仍被限制为窄卡片')
// 当前迭代只允许界面开发,防止误接入文件读取、解析、转换或下载逻辑。
for (const forbiddenImplementation of [
/FileReader/,
/\.text\(\)/,
/JSON\.parse/,
/new Blob/,
/createObjectURL/,
]) {
assert.doesNotMatch(viewSource, forbiddenImplementation, 'UI 原型中不应包含实际转换实现')
}
assert.match(
routerSource,
/path:\s*['"]data-convert['"][\s\S]*?DataConvertView\.vue/,
'数据类型转换路由未接入新页面',
)
assert.doesNotMatch(
routerSource,
/path:\s*['"]data-convert['"][\s\S]{0,180}?PlaceholderView\.vue/,
'数据类型转换路由仍指向占位页',
)
assert.match(
toolsSource,
/id === ['"]json2jsonl['"][\s\S]{0,100}?router\.push\(['"]\/data-convert['"]\)/,
'其他工具中的 JSON 转 JSONL 卡片未接入转换页',
)
console.log('JSON 转 JSONL UI 原型回归检查通过')

View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const sourceRoot = path.resolve(scriptDir, '../src')
const [detailSource, listSource, routerSource] = await Promise.all([
readFile(path.join(sourceRoot, 'views/data-process/DataProcessDetailView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'views/data-process/DataProcessListView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
])
const { descriptor, errors } = parseSfc(detailSource, { filename: 'DataProcessDetailView.vue' })
assert.equal(errors.length, 0, `数据处理详情页无法解析:${errors[0]}`)
assert.ok(descriptor.template?.content.trim(), '数据处理详情页缺少可渲染模板')
assert.match(routerSource, /path:\s*['"]data-process\/:id['"]/, '缺少数据处理详情动态路由')
assert.match(routerSource, /name:\s*['"]data-process-detail['"]/, '数据处理详情路由缺少名称')
assert.match(routerSource, /DataProcessDetailView\.vue/, '数据处理详情路由未加载详情页面')
assert.match(routerSource, /title:\s*['"]数据处理详情['"]/, '数据处理详情路由标题不正确')
assert.match(listSource, /name:\s*['"]data-process-detail['"]/, '列表详情按钮未使用详情命名路由')
assert.match(listSource, /params:\s*\{\s*id:\s*taskId\s*\}/, '列表详情按钮未传递任务 ID')
assert.doesNotMatch(listSource, /查看详情功能开发中/, '详情按钮仍保留开发中提示')
for (const requiredCopy of [
'处理耗时',
'开始时间',
'完成时间',
'输入数据',
'输出结果',
'处理统计',
'处理配置',
'结果明细',
]) {
assert.match(detailSource, new RegExp(requiredCopy), `详情页缺少必要信息:${requiredCopy}`)
}
for (const status of ['completed', 'running', 'pending', 'failed']) {
assert.match(detailSource, new RegExp(`status:\\s*['"]${status}['"]`), `详情 Mock 缺少 ${status} 状态`)
}
assert.match(detailSource, /const completedResults:\s*ResultRow\[\]/, '完成任务缺少结果明细 Mock')
assert.match(detailSource, /:data="paginatedResults"/, '结果表格未绑定分页后的处理结果')
assert.match(detailSource, /v-model="keyword"/, '结果明细缺少搜索能力')
assert.match(detailSource, /v-model="statusFilter"/, '结果明细缺少状态筛选')
assert.match(detailSource, /router\.push\(`\/dataset\/\$\{detail\.outputDatasetId\}\/preview`\)/, '输出数据集未接入预览入口')
assert.match(detailSource, /未找到数据处理任务/, '未知任务 ID 缺少明确空状态')
assert.match(detailSource, /width:\s*100%/, '详情页没有铺满内容区域')
assert.doesNotMatch(detailSource, /^\s*max-width:\s*\d+px/m, '详情页不应使用固定最大宽度')
assert.doesNotMatch(detailSource, /\b(?:password|secret|token)\b/i, '详情页不得展示敏感凭据字段')
console.log('数据处理任务详情 UI 回归检查通过')

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessListView.vue')
const source = await readFile(viewPath, 'utf8')
const { descriptor, errors } = parseSfc(source, { filename: viewPath })
assert.equal(errors.length, 0, `数据处理任务列表模板无法解析:${errors[0]}`)
assert.ok(descriptor.template?.content.trim(), '数据处理任务列表缺少可渲染模板')
assert.match(source, /:data="dataList"/, '任务表格必须直接展示完整任务数据')
assert.doesNotMatch(source, /activeTab|filteredDataList/, '不应保留状态切换筛选逻辑')
assert.doesNotMatch(source, /全部任务|处理中|已完成/, '不应保留状态切换按钮文案')
assert.doesNotMatch(source, /capsule-tabs|capsule-tab-item/, '不应保留状态切换专用样式')
console.log('数据处理任务列表状态切换移除回归检查通过')

View File

@@ -0,0 +1,886 @@
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
import ts from 'typescript'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue')
const createDir = path.resolve(scriptDir, '../src/views/data-process/create')
const confirmDialogPath = path.resolve(scriptDir, '../src/components/AppConfirmDialog.vue')
const layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue')
const viewSource = await readFile(viewPath, 'utf8')
const layoutSource = await readFile(layoutPath, 'utf8')
const [draftSource, stateSource, generationSource, viewStyleSource] = await Promise.all([
readFile(path.join(createDir, 'useDataProcessDraft.ts'), 'utf8'),
readFile(path.join(createDir, 'dataProcessCreateState.ts'), 'utf8'),
readFile(path.join(createDir, 'useDataProcessGeneration.ts'), 'utf8'),
readFile(path.join(createDir, 'data-process-create.scss'), 'utf8'),
])
const implementationSource = [viewSource, draftSource, stateSource, generationSource].join('\n')
assert.ok(existsSync(confirmDialogPath), '缺少公共确认弹窗组件 AppConfirmDialog')
const confirmDialogSource = await readFile(confirmDialogPath, 'utf8')
for (const marker of ['<Teleport to="body">', 'role="alertdialog"', ':aria-modal="true"', 'handleKeydown', 'Escape']) {
assert.ok(confirmDialogSource.includes(marker), `公共确认弹窗缺少可访问性能力:${marker}`)
}
assert.match(confirmDialogSource, /min-height:\s*44px/, '公共确认弹窗按钮触控区域不足 44px')
assert.match(confirmDialogSource, /focus\(\)/, '公共确认弹窗打开后没有管理键盘焦点')
assert.match(confirmDialogSource, /defineExpose\(\{ open \}\)/, '公共确认弹窗没有暴露 Promise 式 open API')
assert.match(confirmDialogSource, /width:\s*min\(480px,\s*100%\)/, '企业级确认弹窗宽度应保持紧凑的 480px')
assert.match(confirmDialogSource, /border-radius:\s*8px/, '企业级确认弹窗应使用克制的 8px 圆角')
assert.doesNotMatch(confirmDialogSource, /backdrop-filter/, '企业级确认弹窗不应使用装饰性背景模糊')
assert.ok(confirmDialogSource.includes('app-confirm-header'), '企业级确认弹窗缺少独立标题栏')
assert.match(confirmDialogSource, /\.app-confirm-button\s*\{[\s\S]*?height:\s*34px/, '桌面端操作按钮应使用紧凑的 34px 高度')
assert.match(confirmDialogSource, /@media \(max-width: 520px\)[\s\S]*?\.app-confirm-button\s*\{[\s\S]*?min-height:\s*44px/, '移动端操作按钮仍需保留 44px 触控高度')
assert.match(viewSource, /import AppConfirmDialog from '@\/components\/AppConfirmDialog\.vue'/, '创建页没有接入公共确认弹窗')
assert.match(viewSource, /<AppConfirmDialog/, '创建页模板缺少公共确认弹窗实例')
assert.match(viewSource, /onBeforeRouteLeave\(async \(\) =>/, '路由离开确认没有改为异步公共弹窗流程')
assert.doesNotMatch(viewSource, /window\.confirm|ElMessageBox/, '创建页仍在使用系统或 Element Plus 确认框')
assert.match(viewSource, /const WIZARD_STEPS = \[/, '向导步骤尚未改为固定常量')
for (const title of ['创建任务', '大模型选择', '上传文件', '数据预览', '开始生成', '结果编辑与保存']) {
assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`)
}
assert.match(
viewSource,
/\{ id: 'create',[\s\S]*?\{ id: 'model',[\s\S]*?\{ id: 'upload',[\s\S]*?\{ id: 'preview',[\s\S]*?\{ id: 'generate',[\s\S]*?\{ id: 'results'/,
'六步向导顺序必须为创建任务、大模型选择、上传文件、数据预览、开始生成、结果编辑与保存',
)
assert.doesNotMatch(viewSource, /steps\s*=\s*computed|all\.filter/, '步骤仍根据处理类型动态增减')
assert.match(
viewStyleSource,
/@media \(max-width: 1100px\)[\s\S]*?\.step-title\s*\{[\s\S]*?display:\s*none[\s\S]*?\.step-item\.is-active \.step-title\s*\{[\s\S]*?display:\s*block/,
'六步向导在中等宽度下没有收起非当前步骤标题',
)
assert.match(draftSource, /localStorage\.setItem\(DATA_PROCESS_DRAFT_STORAGE_KEY/, '草稿没有持久化')
assert.match(draftSource, /localStorage\.getItem\(DATA_PROCESS_DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
assert.ok(viewSource.split('\n').length < 800, 'DataProcessCreateView 拆分后仍超过 800 行')
const expectedComponents = [
'TaskSetupStep.vue',
'ModelSelectionStep.vue',
'SourceUploadStep.vue',
'PreviewCompareStep.vue',
'GenerationStep.vue',
'ResultEditorStep.vue',
]
for (const component of expectedComponents) {
assert.ok(existsSync(path.join(createDir, component)), `缺少步骤组件:${component}`)
assert.ok(viewSource.includes(component.replace('.vue', '')), `父页面未使用:${component}`)
}
const typesPath = path.join(createDir, 'types.ts')
const modelPath = path.join(createDir, 'previewModel.ts')
assert.ok(existsSync(typesPath), '缺少向导类型定义')
assert.ok(existsSync(modelPath), '缺少来源映射模型')
const [typesSource, modelSource, previewSource] = await Promise.all([
readFile(typesPath, 'utf8'),
readFile(modelPath, 'utf8'),
readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'),
])
for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) {
assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`)
}
assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识')
assert.match(typesSource, /export type StepId = 'create' \| 'model' \| 'upload' \| 'preview' \| 'generate' \| 'results'/, '步骤类型缺少独立大模型选择步骤')
assert.match(modelSource, /export function buildPreviewItems/, '缺少切片来源映射生成函数')
assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数')
assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识')
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
assert.match(
viewSource,
/buildPreviewItems\([\s\S]*?file\.content,[\s\S]*?processType\.value,[\s\S]*?String\(file\.uid\),[\s\S]*?unstructuredOptions\.value/,
'预览没有按文件分别生成或未传入非结构化切分配置',
)
for (const marker of [
'preview-workspace',
'source-viewer',
'source-line',
'is-highlighted',
'preview-item',
'preview-editor',
'scrollIntoView',
]) {
assert.ok(previewSource.includes(marker), `第四步缺少结构或行为:${marker}`)
}
assert.match(previewSource, /sourceStart/, '第四步未使用来源起始偏移')
assert.match(previewSource, /sourceEnd/, '第四步未使用来源结束偏移')
assert.match(previewSource, /filterable/, '文件选择器必须可搜索')
assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器')
assert.doesNotMatch(previewSource, /located-badge|sync-label|已定位到/, '源文件栏不应显示冗余定位提示')
assert.match(previewSource, /const PREVIEW_PAGE_SIZE = 6/, '切片列表必须限制每页展示数量')
assert.match(previewSource, /const pagedItems = computed/, '切片列表缺少分页数据')
assert.match(previewSource, /v-for="item in pagedItems"/, '切片列表没有使用分页数据')
assert.match(previewSource, /<el-pagination[\s\S]*:page-size="PREVIEW_PAGE_SIZE"/, '切片列表缺少分页控件')
assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '预览工作区高度不足以展示切片正文')
assert.match(previewSource, /const editingItemId = ref<string \| null>\(null\)/, '缺少切片编辑模式状态')
assert.match(previewSource, /const editorDraft = ref\(''\)/, '缺少编辑临时草稿')
assert.match(previewSource, /function openEditor\(item: PreviewItem\)/, '列表缺少打开切片编辑器的动作')
assert.match(previewSource, /function closeEditor\(\)/, '编辑器缺少返回列表的动作')
assert.match(previewSource, /function saveEditor\(\)/, '编辑器缺少保存动作')
assert.match(previewSource, /<template v-if="!editingItem">[\s\S]*?<template v-else>/, '切片列表与编辑器必须互斥展示')
assert.match(previewSource, /fa-pencil/, '切片列表缺少铅笔编辑按钮')
assert.match(previewSource, /fa-trash-o/, '切片列表缺少垃圾桶删除按钮')
assert.match(previewSource, /class="preview-item"[\s\S]*?@click="selectItem\(item\.id\)"/, '点击切片行必须更新当前选中切片')
assert.match(previewSource, /v-model="editorDraft"/, '编辑器必须绑定临时草稿')
assert.match(previewSource, />取消<\/el-button>/, '编辑器缺少取消按钮')
assert.doesNotMatch(previewSource, /返回列表/, '编辑器不应同时显示返回列表和取消两个相同作用的按钮')
assert.match(previewSource, />保存修改<\/el-button>/, '编辑器缺少保存修改按钮')
assert.match(previewSource, /\.editor-actions\s*\{[\s\S]*?justify-content:\s*flex-end/, '取消和保存按钮必须在编辑器右侧对齐')
assert.doesNotMatch(previewSource, /item-token|item-status|modifiedOnly|仅看已修改/, '切片列表不应再显示 Token 或修改状态')
assert.match(previewSource, /\.preview-editor\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?overflow-y:\s*auto/, '编辑模式必须占据右侧剩余区域并可滚动')
assert.match(previewSource, /@media \(max-width: 900px\)/, '第四步缺少窄屏上下布局')
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
const structuredOptionsPath = path.join(createDir, 'StructuredOptionsPanel.vue')
const unstructuredOptionsPath = path.join(createDir, 'UnstructuredOptionsPanel.vue')
const datasetSplitEditorPath = path.join(createDir, 'DatasetSplitEditor.vue')
const generationOptionsPath = path.join(createDir, 'GenerationOptionsPanel.vue')
const modelSelectionPath = path.join(createDir, 'ModelSelectionStep.vue')
const sourceUploadPath = path.join(createDir, 'SourceUploadStep.vue')
const [
taskSetupSource,
structuredOptionsSource,
unstructuredOptionsSource,
datasetSplitEditorSource,
generationControlSource,
modelSelectionSource,
sourceUploadSource,
] = await Promise.all([
readFile(taskSetupPath, 'utf8'),
readFile(structuredOptionsPath, 'utf8'),
readFile(unstructuredOptionsPath, 'utf8'),
readFile(datasetSplitEditorPath, 'utf8'),
readFile(generationOptionsPath, 'utf8'),
readFile(modelSelectionPath, 'utf8'),
readFile(sourceUploadPath, 'utf8'),
])
const taskSetupFeatureSource = [
taskSetupSource,
structuredOptionsSource,
unstructuredOptionsSource,
datasetSplitEditorSource,
].join('\n')
for (const componentPath of [structuredOptionsPath, unstructuredOptionsPath, datasetSplitEditorPath]) {
assert.ok(existsSync(componentPath), `缺少任务配置拆分组件:${path.basename(componentPath)}`)
}
assert.ok(taskSetupSource.split('\n').length < 800, 'TaskSetupStep 拆分后仍超过 800 行')
assert.match(taskSetupSource, /<StructuredOptionsPanel/, '任务配置没有挂载结构化选项面板')
assert.match(taskSetupSource, /<UnstructuredOptionsPanel/, '任务配置没有挂载非结构化选项面板')
assert.match(structuredOptionsSource, /<DatasetSplitEditor/, '结构化选项没有复用数据集划分编辑器')
assert.match(unstructuredOptionsSource, /<DatasetSplitEditor/, '非结构化选项没有复用数据集划分编辑器')
for (const marker of ['<el-upload', '源数据上传', '数据源配置', 'uploadedFiles']) {
assert.ok(!taskSetupFeatureSource.includes(marker), `第一步仍包含上传职责:${marker}`)
}
assert.match(viewSource, /<ModelSelectionStep\s+[\s\S]*?v-else-if="currentStepId === 'model'"/, '第二步没有挂载独立大模型选择组件')
assert.match(viewSource, /<SourceUploadStep\s+[\s\S]*?v-else-if="currentStepId === 'upload'"/, '第三步没有挂载独立上传组件')
assert.match(viewSource, /if \(currentStepId\.value === 'create'\) return '继续:选择大模型'/, '第一步主按钮没有指向大模型选择')
assert.match(viewSource, /if \(currentStepId\.value === 'model'\) return '继续:上传文件'/, '第二步主按钮没有指向上传文件')
assert.match(viewSource, /if \(currentStepId\.value === 'upload'\) return '继续:数据预览'/, '第三步主按钮没有指向数据预览')
assert.match(draftSource, /DATA_PROCESS_DRAFT_SCHEMA_VERSION = 6/, '安全草稿格式必须升级到 v6')
const nextFromCreateStart = viewSource.indexOf('async function nextFromCreate()')
const nextFromModelStart = viewSource.indexOf('async function nextFromModel()', nextFromCreateStart)
const nextFromUploadStart = viewSource.indexOf('function nextFromUpload()', nextFromModelStart)
const selectPreviewFileStart = viewSource.indexOf('function selectPreviewFile(', nextFromUploadStart)
assert.ok(
nextFromCreateStart >= 0 && nextFromModelStart > nextFromCreateStart && nextFromUploadStart > nextFromModelStart,
'缺少创建、大模型选择与上传步骤的独立跳转函数',
)
const nextFromCreateSource = viewSource.slice(nextFromCreateStart, nextFromModelStart)
const nextFromModelSource = viewSource.slice(nextFromModelStart, nextFromUploadStart)
const nextFromUploadSource = viewSource.slice(nextFromUploadStart, selectPreviewFileStart)
assert.match(nextFromCreateSource, /taskSetupRef\.value\?\.validate\(\)/, '创建步骤继续前没有校验任务配置')
assert.match(nextFromCreateSource, /goToStep\('model'\)/, '创建步骤校验通过后没有进入大模型选择')
assert.doesNotMatch(nextFromCreateSource, /uploadedFiles|buildPreviewItems/, '创建步骤仍在校验文件或提前生成预览')
assert.match(nextFromModelSource, /modelSelectionRef\.value\?\.validate\(\)/, '大模型选择步骤继续前没有校验模型配置')
assert.match(nextFromModelSource, /goToStep\('upload'\)/, '大模型选择完成后没有进入上传文件')
assert.match(nextFromUploadSource, /uploadedFiles\.value\.length === 0/, '上传步骤继续前没有校验源数据')
assert.match(nextFromUploadSource, /buildPreviewItems\(/, '上传步骤没有在进入预览前生成预览数据')
assert.match(nextFromUploadSource, /goToStep\('preview'\)/, '上传步骤完成后没有进入数据预览')
assert.match(viewSource, /function goToStep\(stepId: StepId\)[\s\S]*?WIZARD_STEPS\.findIndex/, '向导跳转没有使用稳定步骤标识')
assert.match(viewSource, /currentStepId\.value === 'preview'[\s\S]*?goToStep\('generate'\)/, '数据预览步骤没有进入开始生成')
assert.match(viewSource, /generation\.status === 'success'[\s\S]*?goToStep\('results'\)/, '生成成功后没有进入结果编辑与保存')
assert.match(draftSource, /currentStepId:\s*bindings\.currentStepId\.value/, '草稿没有保存稳定步骤标识')
const draftSnapshotSource = draftSource.slice(
draftSource.indexOf('function draftSnapshot()'),
draftSource.indexOf('function writeDraft'),
)
for (const forbiddenField of ['uploadedFiles', 'previewItems', 'results', 'password', 'token']) {
assert.ok(!draftSnapshotSource.includes(forbiddenField), `安全草稿不应持久化:${forbiddenField}`)
}
assert.match(draftSource, /writeDraft\(false\)/, '恢复旧草稿后没有立即覆盖潜在敏感数据')
assert.match(viewSource, /<ResultEditorStep\s+[\s\S]*?v-else-if="currentStepId === 'results'"/, '结果编辑器必须只在结果步骤渲染')
assert.match(viewSource, /watch\(processType,[\s\S]*?resetSourceDataForProcessTypeChange\(\)/, '切换处理类型后没有失效旧源数据')
assert.match(viewSource, /function resetSourceDataForProcessTypeChange\(\)[\s\S]*?uploadedFiles\.value = \[\][\s\S]*?selectedPreviewFileId\.value = null/, '旧源数据失效没有同步清理文件与预览选择')
assert.match(taskSetupSource, /v-if="processType === 'structured'"/, '结构化配置必须仅在结构化数据类型下显示')
for (const option of [
'清理无效数据',
'识别表格结构',
'重复数据去重',
'数据格式标准化',
'异常数据过滤',
'敏感信息脱敏',
]) {
assert.ok(structuredOptionsSource.includes(option), `结构化预处理缺少选项:${option}`)
}
assert.ok(structuredOptionsSource.includes('生成选项'), '结构化配置缺少生成选项分类')
for (const splitName of ['训练集', '验证集', '测试集']) {
assert.ok(datasetSplitEditorSource.includes(splitName), `生成选项缺少数据集划分:${splitName}`)
}
assert.match(datasetSplitEditorSource, /const splitTotal = computed/, '数据集划分缺少比例总和计算')
assert.match(taskSetupSource, /splitTotal\.value !== 100/, '数据集划分缺少总和 100% 校验')
assert.ok(datasetSplitEditorSource.includes('训练集、验证集和测试集比例总和必须为 100%'), '数据集划分缺少就地错误提示')
for (const splitField of ['train', 'validation', 'test']) {
assert.match(
datasetSplitEditorSource,
new RegExp(`modelValue\\.${splitField}[\\s\\S]*?:min="0"[\\s\\S]*?:max="100"[\\s\\S]*?:step="1"[\\s\\S]*?:precision="0"`),
`数据集划分字段 ${splitField} 缺少 0100 的整数限制`,
)
}
assert.match(structuredOptionsSource, /<el-input-number[\s\S]*options\.qaPairsPerRow[\s\S]*:min="1"[\s\S]*:max="5"/, '每行生成数量必须限制在 1 到 5')
assert.match(viewSource, /const structuredOptions = ref<StructuredProcessOptions>/, '父页面缺少结构化配置状态')
assert.match(stateSource, /datasetSplit:\s*\{ train: 80, validation: 10, test: 10 \}/, '数据集划分默认值必须为 80/10/10')
assert.match(draftSource, /structuredOptions:\s*\{[\s\S]*\.\.\.bindings\.structuredOptions\.value/, '结构化配置没有写入草稿')
assert.match(draftSource, /bindings\.structuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.structuredOptions/, '结构化配置没有从草稿恢复')
assert.match(viewSource, /v-model:structured-options="structuredOptions"/, '父页面没有双向绑定结构化配置')
assert.match(generationSource, /createResults\([\s\S]*bindings\.structuredOptions\.value/, '每行生成数量没有接入结果生成逻辑')
for (const field of [
'generationModelId',
'generationPrompt',
'qualityFilterEnabled',
'filterLowQuality',
'filterShortContent',
'minOutputLength',
]) {
assert.ok(typesSource.includes(field), `生成控制配置缺少字段:${field}`)
assert.ok(implementationSource.includes(field), `父页面默认值或草稿状态缺少字段:${field}`)
}
assert.match(structuredOptionsSource, /GenerationOptionsPanel/, '结构化生成选项没有复用统一的质量筛选组件')
assert.match(unstructuredOptionsSource, /GenerationOptionsPanel/, '非结构化生成选项没有复用统一的质量筛选组件')
assert.match(structuredOptionsSource, /:options="options"/, '结构化生成选项未接入统一配置组件')
assert.match(unstructuredOptionsSource, /:options="options"/, '非结构化生成选项未接入统一配置组件')
assert.doesNotMatch(taskSetupFeatureSource, /<h3>大模型<\/h3>|section="model"/, '第一步不应继续承载大模型配置')
assert.match(modelSelectionSource, /<h3[^>]*>大模型选择<\/h3>/, '独立步骤缺少大模型选择标题')
assert.match(modelSelectionSource, /section="model"/, '独立步骤没有挂载模型配置')
assert.match(modelSelectionSource, /defineExpose\(\{ validate \}\)/, '独立大模型选择步骤没有暴露继续前校验')
assert.match(modelSelectionSource, /class="form-section"/, '大模型选择步骤没有沿用第一步的通栏表单分区')
assert.doesNotMatch(modelSelectionSource, /max-width:\s*980px/, '大模型选择步骤不应使用比第一步更窄的固定内容宽度')
assert.match(taskSetupFeatureSource, /section="quality"/, '质量筛选没有保留在生成选项分类中')
assert.doesNotMatch(generationControlSource, /<h4>大模型<\/h4>/, '大模型不应继续作为生成选项内部子分类')
for (const label of ['大模型', '数据生成模型', '默认提示语', '质量筛选', '过滤低质量内容', '过滤过短内容', '最少字数']) {
assert.ok(generationControlSource.includes(label), `生成控制界面缺少:${label}`)
}
assert.match(generationControlSource, /filterable/, '数据生成模型下拉必须支持搜索')
assert.match(generationControlSource, /maxlength="500"/, '默认提示语缺少合理的长度限制')
assert.match(generationControlSource, /\.model-field\s*\{[\s\S]*?display:\s*flex[\s\S]*?flex-direction:\s*column/, '大模型字段没有使用稳定的纵向表单布局')
assert.match(generationControlSource, /\.generation-config-group\s*\{[\s\S]*?border:\s*1px solid #e2e5ec/, '大模型配置没有保留统一配置面板边框')
assert.match(generationControlSource, /\.model-config-group\s*\{[\s\S]*?padding:\s*0[\s\S]*?border:\s*0/, '独立大模型步骤仍存在嵌套卡片挤压')
assert.match(
generationControlSource,
/\.model-config-group \.advanced-settings-grid\s*\{[\s\S]*?grid-template-columns:\s*1fr/,
'大模型高级参数没有改为与第一步一致的纵向布局',
)
assert.match(stateSource, /const DEFAULT_GENERATION_PROMPT\s*=\s*['"][^'"]{40,}['"]/, '大模型配置缺少可直接使用的默认提示语')
assert.equal((stateSource.match(/generationPrompt:\s*DEFAULT_GENERATION_PROMPT/g) || []).length, 2, '结构化与非结构化任务必须共用默认提示语')
assert.match(generationControlSource, /v-if="options\.qualityFilterEnabled"/, '质量规则没有随总开关渐进显示')
assert.match(generationControlSource, /v-if="options\.filterShortContent"/, '最少字数没有随短内容规则显示')
assert.match(generationControlSource, /:min="1"[\s\S]*:max="1000"/, '最少字数缺少 1 到 1000 的边界限制')
assert.match(taskSetupSource, /qualityValidationMessage/, '质量规则缺少继续前校验')
assert.match(viewSource, /useModelsStore/, '创建页没有加载模型列表')
assert.match(viewSource, /model\.type === 'LLM'/, '数据生成模型列表没有排除非大模型')
assert.match(viewSource, /<ModelSelectionStep[\s\S]*?:models="generationModels"/, '创建页没有向独立大模型选择步骤传递模型列表')
assert.match(typesSource, /export interface UnstructuredProcessOptions/, '缺少非结构化处理选项类型')
for (const field of [
'preprocessOptions',
'chunkMethod',
'chunkSize',
'chunkOverlap',
'minChunkSize',
'customDelimiter',
'preserveTables',
'preserveCodeBlocks',
'preserveLists',
'semanticEnrichment',
'qaPairsPerChunk',
'datasetSplit',
]) {
assert.ok(typesSource.includes(field), `非结构化处理选项缺少字段:${field}`)
}
for (const removedField of ['contextScope', 'generationTypes', 'skipUnanswerable']) {
assert.ok(!typesSource.includes(removedField), `简化后仍保留低频生成字段:${removedField}`)
}
assert.match(taskSetupSource, /v-if="processType === 'unstructured'"/, '非结构化配置必须仅在非结构化数据类型下显示')
assert.ok(unstructuredOptionsSource.includes('智能预处理'), '简化后缺少智能预处理总开关')
assert.ok(unstructuredOptionsSource.includes('敏感信息脱敏'), '简化后缺少脱敏开关')
assert.match(unstructuredOptionsSource, /const smartPreprocessEnabled = computed/, '智能预处理没有映射到内部处理项')
assert.match(unstructuredOptionsSource, /function updateSmartPreprocess/, '智能预处理开关缺少更新逻辑')
assert.match(unstructuredOptionsSource, /function updateDesensitize/, '脱敏开关缺少更新逻辑')
assert.ok(unstructuredOptionsSource.includes('切分选项'), '非结构化配置缺少切分选项分类')
for (const method of ['自动语义切分', '按标题和段落', '按固定长度', '自定义分隔符']) {
assert.ok(unstructuredOptionsSource.includes(method), `切分方式缺少选项:${method}`)
}
for (const label of ['切片长度', '重叠长度', '最小切片长度', '保护表格、代码和列表']) {
assert.ok(unstructuredOptionsSource.includes(label), `切分选项缺少配置:${label}`)
}
assert.doesNotMatch(unstructuredOptionsSource, /advancedChunkSettingsOpen|>高级设置</, '切分核心参数不应再隐藏在高级设置中')
assert.match(taskSetupSource, /if \(chunkValidationMessage\.value\) \{[\s\S]*?revealValidation\(\)[\s\S]*?return false/, '高级切分配置校验失败时没有重新展开定位')
assert.match(unstructuredOptionsSource, /const preserveSpecialContentEnabled = computed/, '特殊内容保护没有合并为单一开关')
assert.match(unstructuredOptionsSource, /function updateSpecialContentProtection/, '特殊内容保护开关缺少更新逻辑')
assert.match(unstructuredOptionsSource, /options\.chunkSize[\s\S]*?:min="200"[\s\S]*?:max="2000"/, '切片长度必须限制在 200 到 2000 Token')
assert.match(unstructuredOptionsSource, /options\.chunkOverlap[\s\S]*?:min="0"[\s\S]*?:max="500"/, '重叠长度必须限制在 0 到 500 Token')
assert.match(unstructuredOptionsSource, /options\.minChunkSize[\s\S]*?:min="20"[\s\S]*?:max="500"/, '最小切片长度必须限制在 20 到 500 Token')
for (const label of ['每个切片生成数量', '数据集划分']) {
assert.ok(taskSetupFeatureSource.includes(label), `非结构化生成选项缺少:${label}`)
}
for (const removedLabel of ['上下文范围', '问题类型', '跳过无法回答的内容']) {
assert.ok(!taskSetupFeatureSource.includes(removedLabel), `简化后仍显示低频选项:${removedLabel}`)
}
assert.match(unstructuredOptionsSource, /options\.qaPairsPerChunk[\s\S]*?:min="1"[\s\S]*?:max="3"/, '每个切片生成数量必须限制在 1 到 3')
assert.match(taskSetupSource, /unstructuredSplitTotal\.value !== 100/, '非结构化数据集划分缺少总和 100% 校验')
assert.match(taskSetupSource, /chunkOverlap \+ props\.unstructuredOptions\.minChunkSize[\s\S]*?> props\.unstructuredOptions\.chunkSize/, '切分配置未校验重叠长度与最小切片长度的组合边界')
assert.ok(unstructuredOptionsSource.includes('Token 数为轻量估算值'), '切片长度缺少 Token 估算说明')
assert.match(unstructuredOptionsSource, /\.chunk-settings-grid\s*\{[\s\S]*?grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '核心切分参数没有收紧为三列布局')
assert.match(viewSource, /const unstructuredOptions = ref<UnstructuredProcessOptions>/, '父页面缺少非结构化配置状态')
assert.match(stateSource, /chunkMethod:\s*'semantic'/, '非结构化默认切分方式必须为自动语义切分')
assert.match(stateSource, /chunkSize:\s*800/, '默认切片长度必须为 800 Token')
assert.match(stateSource, /chunkOverlap:\s*100/, '默认重叠长度必须为 100 Token')
assert.match(stateSource, /minChunkSize:\s*100/, '默认最小切片长度必须为 100 Token')
assert.match(stateSource, /qaPairsPerChunk:\s*1/, '默认每个切片必须生成 1 个问答对')
assert.match(draftSource, /unstructuredOptions:\s*\{[\s\S]*\.\.\.bindings\.unstructuredOptions\.value/, '非结构化配置没有写入草稿')
assert.match(draftSource, /bindings\.unstructuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.unstructuredOptions/, '非结构化配置没有从草稿恢复')
assert.match(viewSource, /v-model:unstructured-options="unstructuredOptions"/, '父页面没有双向绑定非结构化配置')
assert.match(draftSource, /DATA_PROCESS_DRAFT_SCHEMA_VERSION = \d+/, '草稿缺少版本标识')
assert.match(draftSource, /schemaVersion:\s*DATA_PROCESS_DRAFT_SCHEMA_VERSION/, '草稿快照没有写入版本标识')
assert.match(draftSource, /bindings\.goToStep\('create'\)/, '恢复配置后应回到安全的创建步骤')
assert.match(viewSource, /JSON\.stringify\(previewAffectingOptions\(\)\)/, '影响切分的非结构化配置没有纳入预览失效判断')
const previewOptionsStart = viewSource.indexOf('function previewAffectingOptions()')
const previewOptionsEnd = viewSource.indexOf('function generationAffectingOptions()', previewOptionsStart)
assert.ok(previewOptionsStart >= 0 && previewOptionsEnd > previewOptionsStart, '缺少预览影响配置签名函数')
const previewOptionsSource = viewSource.slice(previewOptionsStart, previewOptionsEnd)
for (const field of [
'preprocessOptions',
'chunkMethod',
'chunkSize',
'chunkOverlap',
'minChunkSize',
'customDelimiter',
'preserveTables',
'preserveCodeBlocks',
'preserveLists',
]) {
assert.ok(previewOptionsSource.includes(field), `预览签名缺少切分影响字段:${field}`)
}
for (const field of ['semanticEnrichment', 'qaPairsPerChunk', 'datasetSplit']) {
assert.ok(!previewOptionsSource.includes(field), `生成字段 ${field} 不应导致预览重建并丢失编辑`)
}
const generationOptionsStart = viewSource.indexOf('function generationAffectingOptions()')
const generationOptionsEnd = viewSource.indexOf('const generationOptionsSignature', generationOptionsStart)
assert.ok(generationOptionsStart >= 0 && generationOptionsEnd > generationOptionsStart, '缺少生成影响配置签名函数')
const generationOptionsSource = viewSource.slice(generationOptionsStart, generationOptionsEnd)
for (const field of [
'semanticEnrichment',
'qaPairsPerChunk',
'datasetSplit',
'generationModelId',
'generationPrompt',
'qualityFilterEnabled',
'filterLowQuality',
'filterShortContent',
'minOutputLength',
]) {
assert.ok(generationOptionsSource.includes(field), `生成签名缺少字段:${field}`)
}
assert.match(
viewSource,
/watch\(generationOptionsSignature,[\s\S]*?resetDownstream\(\)/,
'生成配置变化后没有仅失效下游结果',
)
for (const mutationFunction of [
'updatePreviewContent',
'restorePreviewItem',
'addPreviewItem',
'removePreviewItem',
]) {
const mutationStart = viewSource.indexOf(`function ${mutationFunction}`)
const mutationEnd = viewSource.indexOf('\nfunction ', mutationStart + 1)
const mutationSource = viewSource.slice(mutationStart, mutationEnd === -1 ? undefined : mutationEnd)
assert.ok(mutationSource.includes('resetDownstream()'), `预览变更 ${mutationFunction} 后没有失效旧生成结果`)
}
assert.match(modelSource, /unstructuredOptions\?: UnstructuredProcessOptions/, '切片预览没有接收非结构化配置')
assert.match(modelSource, /qaPairsPerChunk/, '每个切片生成数量没有接入结果生成逻辑')
const transpiledModel = ts.transpileModule(modelSource, {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
}).outputText
const previewModelModule = await import(`data:text/javascript;base64,${Buffer.from(transpiledModel).toString('base64')}`)
const longDocument = Array.from(
{ length: 180 },
(_, index) => `${index + 1}. 这是用于验证非结构化切分边界的完整文本段落。`,
).join('\n')
const baseUnstructuredOptions = {
preprocessOptions: [],
chunkMethod: 'semantic',
chunkSize: 200,
chunkOverlap: 50,
minChunkSize: 50,
customDelimiter: '',
preserveTables: false,
preserveCodeBlocks: false,
preserveLists: false,
semanticEnrichment: false,
qaPairsPerChunk: 3,
datasetSplit: { train: 80, validation: 10, test: 10 },
generationModelId: 1,
generationPrompt: '仅输出问答对',
qualityFilterEnabled: false,
filterLowQuality: true,
filterShortContent: true,
minOutputLength: 20,
}
for (const chunkMethod of ['semantic', 'heading', 'fixed', 'custom']) {
const options = {
...baseUnstructuredOptions,
chunkMethod,
customDelimiter: chunkMethod === 'custom' ? '\\n' : '',
}
const previewItems = previewModelModule.buildPreviewItems(longDocument, 'unstructured', chunkMethod, options)
assert.ok(previewItems.length > 1, `${chunkMethod} 切分方式未生成多个切片`)
assert.ok(
previewItems.every((item) => longDocument.slice(item.sourceStart, item.sourceEnd) === item.originalContent),
`${chunkMethod} 切分方式的来源偏移不准确`,
)
assert.ok(
previewItems.every((item) => item.sourceStartLine <= item.sourceEndLine),
`${chunkMethod} 切分方式的来源行号不准确`,
)
}
const overlapDocument = '甲'.repeat(1200)
const overlapItems = previewModelModule.buildPreviewItems(overlapDocument, 'unstructured', 'overlap-check', {
...baseUnstructuredOptions,
chunkMethod: 'fixed',
})
assert.equal(
overlapItems[0].sourceEnd - overlapItems[1].sourceStart,
100,
'固定长度切分没有按配置保留 50 个估算 Token 的重叠内容',
)
const headingDocument = `${'甲'.repeat(150)}\n# 第二章\n${'乙'.repeat(600)}`
const headingItems = previewModelModule.buildPreviewItems(headingDocument, 'unstructured', 'heading-check', {
...baseUnstructuredOptions,
chunkMethod: 'heading',
chunkOverlap: 0,
})
assert.ok(!headingItems[0].originalContent.includes('# 第二章'), '按标题切分未在新标题前结束上一切片')
assert.ok(headingItems[1].originalContent.startsWith('# 第二章'), '按标题切分未从新标题开始下一切片')
const customDocument = `${'甲'.repeat(150)}<CUT>${'乙'.repeat(600)}`
const customItems = previewModelModule.buildPreviewItems(customDocument, 'unstructured', 'custom-check', {
...baseUnstructuredOptions,
chunkMethod: 'custom',
chunkOverlap: 0,
customDelimiter: '<CUT>',
})
assert.ok(customItems[0].originalContent.endsWith('<CUT>'), '自定义切分未在指定分隔符处结束切片')
function assertProtectedContent(optionField, block, label) {
const document = `${'前言。'.repeat(50)}\n${block}\n${'结尾。'.repeat(100)}`
const enabledItems = previewModelModule.buildPreviewItems(document, 'unstructured', `${optionField}-on`, {
...baseUnstructuredOptions,
chunkMethod: 'fixed',
chunkOverlap: 0,
preserveTables: false,
preserveCodeBlocks: false,
preserveLists: false,
[optionField]: true,
})
const disabledItems = previewModelModule.buildPreviewItems(document, 'unstructured', `${optionField}-off`, {
...baseUnstructuredOptions,
chunkMethod: 'fixed',
chunkOverlap: 0,
preserveTables: false,
preserveCodeBlocks: false,
preserveLists: false,
})
assert.ok(enabledItems.some((item) => item.originalContent.includes(block)), `${label}开启后仍被从内部切断`)
assert.ok(!disabledItems.some((item) => item.originalContent.includes(block)), `${label}关闭后的对照用例未命中切分边界`)
}
const codeBlock = ['```ts', ...Array.from({ length: 36 }, (_, index) => `const value${index} = ${index};`), '```'].join('\n')
const tableBlock = [
'| 字段 | 说明 |',
'| --- | --- |',
...Array.from({ length: 36 }, (_, index) => `| field_${index} | 字段说明 ${index} |`),
].join('\n')
const listBlock = Array.from({ length: 42 }, (_, index) => `- 列表项 ${index + 1}:这是需要完整保留的内容。`).join('\n')
assertProtectedContent('preserveCodeBlocks', codeBlock, '代码块')
assertProtectedContent('preserveTables', tableBlock, '表格')
assertProtectedContent('preserveLists', listBlock, '列表')
const samplePreviewItems = previewModelModule.buildPreviewItems(
longDocument,
'unstructured',
'generation-check',
baseUnstructuredOptions,
)
assert.ok(samplePreviewItems.length > 12, '测试文档未生成足够的切片')
const generatedResults = previewModelModule.createResults(samplePreviewItems.slice(0, 13), baseUnstructuredOptions)
assert.equal(generatedResults.length, 39, '每个切片生成 3 个问答对未完整应用到所有切片')
const shortContentItems = [{
...samplePreviewItems[0],
editedContent: '问:示例\n短回答',
}]
const filteredShortResults = previewModelModule.createResults(shortContentItems, {
...baseUnstructuredOptions,
qualityFilterEnabled: true,
filterLowQuality: false,
filterShortContent: true,
minOutputLength: 20,
})
assert.equal(filteredShortResults.length, 0, '开启过短内容过滤后仍保留低于最少字数的结果')
const invalidContentItems = [{
...samplePreviewItems[0],
status: 'invalid',
}]
const filteredInvalidResults = previewModelModule.createResults(invalidContentItems, {
...baseUnstructuredOptions,
qualityFilterEnabled: true,
filterLowQuality: true,
filterShortContent: false,
})
assert.equal(filteredInvalidResults.length, 0, '开启低质量过滤后仍保留标记为无效的结果')
const legacyExternalItems = previewModelModule.buildPreviewItems('a\nb\nc\nd', 'external', 'legacy-check')
assert.equal(legacyExternalItems.length, 2, '外来数据原有的每 3 行分组行为被破坏')
function findNextStyleBlockStart(source, startIndex) {
let quote = null
for (let index = startIndex; index < source.length; index += 1) {
const character = source[index]
const nextCharacter = source[index + 1]
if (quote) {
if (character === '\\') {
index += 1
} else if (character === quote) {
quote = null
}
continue
}
if (character === '/' && nextCharacter === '*') {
const commentEnd = source.indexOf('*/', index + 2)
index = commentEnd === -1 ? source.length : commentEnd + 1
continue
}
if (character === '/' && nextCharacter === '/') {
const commentEnd = source.indexOf('\n', index + 2)
index = commentEnd === -1 ? source.length : commentEnd
continue
}
if (character === '\'' || character === '"') {
quote = character
continue
}
if (character === '{') return index
}
return -1
}
function findStyleBlockEnd(source, blockStart) {
let depth = 0
let quote = null
for (let index = blockStart; index < source.length; index += 1) {
const character = source[index]
const nextCharacter = source[index + 1]
if (quote) {
if (character === '\\') {
index += 1
} else if (character === quote) {
quote = null
}
continue
}
if (character === '/' && nextCharacter === '*') {
const commentEnd = source.indexOf('*/', index + 2)
index = commentEnd === -1 ? source.length : commentEnd + 1
continue
}
if (character === '/' && nextCharacter === '/') {
const commentEnd = source.indexOf('\n', index + 2)
index = commentEnd === -1 ? source.length : commentEnd
continue
}
if (character === '\'' || character === '"') {
quote = character
continue
}
if (character === '{') {
depth += 1
} else if (character === '}' && --depth === 0) {
return index
}
}
return -1
}
function resolveNestedSelector(selector, parentSelector) {
if (!parentSelector) return selector
if (selector.includes('&')) return selector.replace(/&/g, parentSelector)
return `${parentSelector} ${selector}`
}
function collectStyleRules(source, parentSelector = '') {
const rules = []
let ruleStart = 0
let cursor = 0
while (cursor < source.length) {
const blockStart = findNextStyleBlockStart(source, cursor)
if (blockStart === -1) break
const blockEnd = findStyleBlockEnd(source, blockStart)
if (blockEnd === -1) break
const rawSelector = source.slice(ruleStart, blockStart).trim()
const declarations = source.slice(blockStart + 1, blockEnd)
if (rawSelector) {
const isAtRule = rawSelector.startsWith('@')
const selector = isAtRule ? rawSelector : resolveNestedSelector(rawSelector, parentSelector)
rules.push({ selector, declarations })
rules.push(...collectStyleRules(declarations, isAtRule ? parentSelector : selector))
}
cursor = blockEnd + 1
ruleStart = cursor
}
return rules
}
function directStyleDeclarations(source) {
let result = ''
let nestedDepth = 0
let quote = null
for (let index = 0; index < source.length; index += 1) {
const character = source[index]
const nextCharacter = source[index + 1]
if (quote) {
if (character === '\\') {
index += 1
} else if (character === quote) {
quote = null
}
if (nestedDepth === 0) result += ' '
continue
}
if (character === '/' && nextCharacter === '*') {
const commentEnd = source.indexOf('*/', index + 2)
index = commentEnd === -1 ? source.length : commentEnd + 1
if (nestedDepth === 0) result += ' '
continue
}
if (character === '/' && nextCharacter === '/') {
const commentEnd = source.indexOf('\n', index + 2)
index = commentEnd === -1 ? source.length : commentEnd
if (nestedDepth === 0) result += ' '
continue
}
if (character === '\'' || character === '"') {
quote = character
if (nestedDepth === 0) result += ' '
continue
}
if (character === '{') {
nestedDepth += 1
if (nestedDepth === 1) result += ' '
continue
}
if (character === '}') {
nestedDepth = Math.max(0, nestedDepth - 1)
if (nestedDepth === 0) result += ' '
continue
}
if (nestedDepth === 0) result += character
}
return result
}
const nestedUploadedFileItemsStyles = collectStyleRules(`
.upload-context {
.uploaded-file {
&-items {
max-height: 20rem;
}
@media (min-width: 1px) {
&-items {
overflow: auto;
}
}
@supports (display: grid) {
&-items {
overflow-x: hidden;
overflow-y: auto;
}
}
}
}
`).filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector))
assert.equal(
nestedUploadedFileItemsStyles.length,
3,
'嵌套的 &-items 选择器必须展开为 .uploaded-file-items且不能被 at-rule 上下文遮蔽',
)
for (const property of ['max-height', 'overflow', 'overflow-x', 'overflow-y']) {
assert.ok(
nestedUploadedFileItemsStyles.some(({ declarations }) =>
new RegExp(`(?:^|;)\\s*${property}\\s*:`, 'i').test(directStyleDeclarations(declarations)),
),
`嵌套 .uploaded-file-items 必须识别受限样式属性:${property}`,
)
}
for (const marker of [
'uploaded-file-list-header',
'uploaded-file-items',
'已添加 {{ uploadedFiles.length }} 个文件',
':title="file.name"',
]) {
assert.ok(sourceUploadSource.includes(marker), `源数据文件列表缺少:${marker}`)
}
assert.match(
sourceUploadSource,
/^const[ \t]+FILE_PAGE_SIZE[ \t]*=[ \t]*10[ \t]*;?[ \t]*$/m,
'文件分页大小必须固定为整数 10',
)
assert.match(
sourceUploadSource,
/const pagedUploadedFiles\s*=\s*computed\(\(\)\s*=>\s*\{\s*const start = \(currentFilePage\.value - 1\) \* FILE_PAGE_SIZE\s*return props\.uploadedFiles\.slice\(start, start \+ FILE_PAGE_SIZE\)\s*\}\)/,
'文件分页必须按当前页偏移切片完整文件列表',
)
assert.match(
sourceUploadSource,
/watch\(\(\)\s*=>\s*props\.uploadedFiles\.length,\s*\(newLength, oldLength\)\s*=>\s*\{[\s\S]*?if \(newLength > oldLength\)\s*\{\s*currentFilePage\.value = totalPages[\s\S]*?\}[\s\S]*?currentFilePage\.value = Math\.min\(currentFilePage\.value, totalPages\)[\s\S]*?\}\)/,
'文件数变化时必须新增跳至末页、删除回退到有效页',
)
const filePaginationTags = [...sourceUploadSource.matchAll(/<el-pagination\b[\s\S]*?\/>/g)]
assert.ok(filePaginationTags.length >= 1, '文件列表必须包含分页器')
for (const [filePaginationTag] of filePaginationTags) {
for (const attribute of [
'v-if="uploadedFiles.length > FILE_PAGE_SIZE"',
'v-model:current-page="currentFilePage"',
':page-size="FILE_PAGE_SIZE"',
':total="uploadedFiles.length"',
]) {
assert.ok(filePaginationTag.includes(attribute), `文件分页器缺少属性:${attribute}`)
}
}
const { descriptor: sourceUploadDescriptor } = parseSfc(sourceUploadSource, { filename: sourceUploadPath })
const uploadedFileItemsStyles = sourceUploadDescriptor.styles
.flatMap(({ content }) => collectStyleRules(content))
.filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector))
for (const { declarations } of uploadedFileItemsStyles) {
assert.doesNotMatch(
directStyleDeclarations(declarations),
/(?:^|;)\s*(?:max-height|overflow|overflow-x|overflow-y)\s*:/i,
'文件列表不能用内部滚动替代分页',
)
}
assert.match(
sourceUploadSource,
/<el-upload\s+v-if="uploadedFiles\.length === 0"[\s\S]*?<\/el-upload>\s*<section\s+v-else\s+class="uploaded-file-list"\s+aria-label="已上传文件列表">/,
'有文件状态缺少带 aria-label="已上传文件列表" 的语义列表容器',
)
assert.match(
sourceUploadSource,
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>/,
'文件列表标题结构或文件数量文案缺失',
)
assert.match(
sourceUploadSource,
/<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">/,
'文件列表缺少分页后的文件行容器',
)
assert.match(
sourceUploadSource,
/<el-upload\s+v-if="uploadedFiles\.length === 0"\s+drag\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"[^>]*>/,
'无文件时未保留原有大拖拽上传区或上传配置',
)
assert.match(
sourceUploadSource,
/<el-upload\s+v-if="uploadedFiles\.length === 0"\s+drag\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"[^>]*>[\s\S]*?<template\s+#tip>\s*<div\s+class="el-upload__tip">\s*\{\{\s*processType === 'unstructured'\s*\? '支持 TXT、Markdown、PDF、Word、JSON、JSONL单文件不超过 200MB'\s*:\s*'支持 JSON、JSONL、CSV、Excel单文件不超过 200MB'\s*\}\}/,
'无文件时大拖拽上传区缺少格式提示槽、处理类型分支或完整格式提示',
)
assert.match(
sourceUploadSource,
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>\s*<div\s+class="continue-upload">\s*<el-upload\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"[^>]*>\s*<el-button\s+size="small"\s+type="primary">继续上传<\/el-button>\s*<\/el-upload>\s*<\/div>\s*<\/div>/,
'有文件时缺少标题右侧的继续上传触发器或上传配置',
)
assert.match(
sourceUploadSource,
/\.uploaded-file-list-header\s*\{[^}]*display:\s*flex[^}]*justify-content:\s*space-between/,
'文件列表标题未布局为右侧继续上传按钮',
)
assert.match(
sourceUploadSource,
/\.continue-upload\s+:deep\(\.el-upload\)\s*\{[^}]*width:\s*auto;?[^}]*margin-top:\s*0;?/,
'继续上传未覆盖内层上传节点的宽度和顶部间距',
)
assert.match(sourceUploadSource, /\.uploaded-file\s*\{[^}]*min-height:\s*48px/, '文件行没有保持 48px 最小高度')
assert.match(sourceUploadSource, /<span class="file-status"><i class="fa fa-check-circle"[^>]*\/> 校验通过<\/span>/, '文件行缺少校验成功状态')
assert.match(sourceUploadSource, /@click="emit\('remove-file', file\.uid\)"/, '文件行缺少 remove-file 删除动作')
const { descriptor } = parseSfc(viewSource, { filename: viewPath })
const template = descriptor.template?.content || ''
assert.equal((template.match(/class="wizard-primary-action"/g) || []).length, 1, '页面必须只有一个主操作入口')
assert.match(viewSource, /onBeforeUnmount\(\(\) => \{[\s\S]*?stopGenerationTimer\(\)[\s\S]*?clearTimeout\(connectionTimer\)[\s\S]*?clearTimeout\(pullTimer\)/, '生成与外部数据源计时器没有在卸载时清理')
assert.match(viewSource, /function scrollToStepTop/, '步骤切换后没有恢复页面顶部上下文')
assert.match(viewSource, /nextTick\(scrollToStepTop\)/, '步骤切换没有触发页面滚动复位')
assert.match(viewStyleSource, /\.wizard-content\s*\{[\s\S]*min-height:\s*400px/, '第一步内容区必须保留足够高度以显示底部操作栏')
assert.match(
layoutSource,
/&:has\(\.create-wizard-layout\)\s*\{[\s\S]*?overflow-y:\s*hidden[\s\S]*?\.page-canvas\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?min-height:\s*0/,
'创建任务页必须约束画布高度,避免底部操作栏被裁掉',
)
assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '对照预览高度不足以展示切片正文')
console.log('数据处理六步向导回归检查通过')

View File

@@ -0,0 +1,180 @@
import fs from 'node:fs'
import path from 'node:path'
import ts from 'typescript'
const root = process.cwd()
const previewPath = path.join(root, 'src/views/dataset/DatasetPreviewView.vue')
const previewComponentsDir = path.join(root, 'src/views/dataset/preview')
const previewComponentPaths = [
'DatasetVersionBar.vue',
'DatasetRecordTable.vue',
'DatasetRecordEditorDialog.vue',
'DatasetRawPreview.vue',
].map((name) => path.join(previewComponentsDir, name))
const apiPath = path.join(root, 'src/api/modules/dataset.ts')
const adapterPath = path.join(root, 'src/mock/adapter.ts')
const mockPath = path.join(root, 'src/mock/data.ts')
const recordsPath = path.join(root, 'src/views/dataset/datasetRecords.ts')
const versionsPath = path.join(root, 'src/mock/datasetVersions.ts')
const source = fs.readFileSync(previewPath, 'utf8')
const previewLogicSource = source.slice(0, source.indexOf('<style'))
const previewComponentSources = previewComponentPaths.map((componentPath) => {
expect(fs.existsSync(componentPath), `缺少数据集预览叶子组件:${path.basename(componentPath)}`)
return fs.readFileSync(componentPath, 'utf8')
})
const previewSurfaceSource = [source, ...previewComponentSources].join('\n')
const apiSource = fs.readFileSync(apiPath, 'utf8')
const adapterSource = fs.readFileSync(adapterPath, 'utf8')
const mockSource = fs.readFileSync(mockPath, 'utf8')
const recordsSource = fs.readFileSync(recordsPath, 'utf8')
const versionsSource = fs.readFileSync(versionsPath, 'utf8')
const recordsModuleCode = ts.transpileModule(recordsSource, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
const { parseDatasetRecords, updateDatasetRecord } = await import(
`data:text/javascript;base64,${Buffer.from(recordsModuleCode).toString('base64')}`
)
const versionsModuleCode = ts.transpileModule(versionsSource, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
const {
activateDatasetVersion,
appendDatasetVersion,
createInitialVersionState,
deleteDatasetVersion,
getActiveDatasetVersion,
} = await import(`data:text/javascript;base64,${Buffer.from(versionsModuleCode).toString('base64')}`)
function expect(condition, message) {
if (!condition) throw new Error(message)
}
expect(previewLogicSource.split('\n').length < 520, 'DatasetPreviewView.vue 的逻辑与模板应保持在 520 行以内')
for (const componentName of ['DatasetVersionBar', 'DatasetRecordTable', 'DatasetRecordEditorDialog', 'DatasetRawPreview']) {
expect(source.includes(`import ${componentName}`), `主页面未导入叶子组件:${componentName}`)
expect(source.includes(`<${componentName}`), `主页面未挂载叶子组件:${componentName}`)
}
expect(source.includes('class="preview-workspace"'), '详情页应提供数据文件工作区')
expect(!source.includes('class="file-pane"'), '单文件数据集详情不应展示文件选择侧栏')
expect(previewSurfaceSource.includes('class="code-line"'), '内容查看器应提供逐行展示')
expect(previewSurfaceSource.includes('class="line-number"'), '内容查看器应显示行号')
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
expect(source.includes('previewError'), '内容加载失败时应提供错误状态')
expect(previewSurfaceSource.includes('class="records-viewer"'), '详情页应将结构化文件展示为样本列表')
expect(previewSurfaceSource.includes('class="record-table"'), '样本数据应使用企业级表格展示')
expect(previewSurfaceSource.includes('fixed="right"'), '逐条编辑操作列应固定在表格右侧')
expect(previewSurfaceSource.includes('v-for="fieldKey in tableFieldKeys"'), '企业表格应根据数据结构动态生成字段列')
expect(previewSurfaceSource.includes("emit('edit', asDatasetRecord(row))"), '每条样本应向主页面发送编辑事件')
expect(previewSurfaceSource.includes('暂存修改'), '单条编辑应先暂存到当前页面')
expect(source.includes('hasPendingVersionChanges && isViewingActiveVersion'), '存在暂存修改时才应显示保存版本按钮')
expect(source.includes('保存版本'), '文件工具栏应提供保存版本按钮')
expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本')
expect(previewSurfaceSource.includes('class="version-control-bar"'), '详情页应提供独立版本控制区')
expect(previewSurfaceSource.includes('设为当前版本'), '历史版本应支持显式切换为当前版本')
expect(previewSurfaceSource.includes('class="version-actions"'), '历史版本操作应收纳到省略号菜单')
expect(previewSurfaceSource.includes('删除版本'), '历史版本操作菜单应提供删除入口')
expect(previewSurfaceSource.includes('历史版本(只读)'), '历史版本应明确展示只读状态')
expect(source.includes('AppConfirmDialog'), '删除历史版本前应使用公共危险确认弹窗')
expect(source.includes("tone: 'danger'"), '删除历史版本确认弹窗应使用危险态')
expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本')
expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容')
expect(previewSurfaceSource.includes('v-model:current-page="currentPage"'), '样本列表应支持分页浏览')
expect(!source.includes('class="content-editor"'), '详情页不应继续提供整文件编辑器')
expect(source.includes('saveRecord'), '逐条编辑器应提供单条保存动作')
expect(source.includes('hasUnsavedChanges'), '在线编辑器应跟踪未保存修改')
expect(source.includes('onBeforeRouteLeave'), '离开页面时应保护未保存修改')
expect(previewSurfaceSource.includes("event.key.toLowerCase() === 's'"), '在线编辑器应支持快捷键保存')
expect(source.includes('loadVersions(selectedFile)'), '版本内容加载失败后应支持重新加载')
expect(source.includes('function resetRecordEditor()'), '主页面应提供统一的编辑器状态清理函数')
const resetEditorStart = source.indexOf('function resetRecordEditor()')
const updateFieldStart = source.indexOf('function updateEditField', resetEditorStart)
const resetEditorSource = source.slice(resetEditorStart, updateFieldStart)
for (const marker of [
'editorVisible.value = false',
'editingRecord.value = null',
'editFields.value = []',
"rawDraft.value = ''",
"originalDraft.value = ''",
]) {
expect(resetEditorSource.includes(marker), `编辑器状态清理不完整:${marker}`)
}
const versionChangeStart = source.indexOf('async function handleViewedVersionChange')
const activateVersionStart = source.indexOf('async function activateViewedVersion', versionChangeStart)
const versionChangeSource = source.slice(versionChangeStart, activateVersionStart)
expect(versionChangeSource.includes('resetRecordEditor()'), '成功切换版本后必须关闭并清空旧编辑器状态')
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
expect(!source.includes('deleteDataset('), '详情页不应恢复删除整个数据集的逻辑')
expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑')
expect(mockSource.includes('files: ['), 'Mock 数据集应包含可验收的文件列表')
expect(mockSource.includes('mockDatasetPreviews'), 'Mock 数据应包含文件预览内容')
expect(mockSource.includes("'mock-jsonl'"), 'Mock 数据应为通用数据集提供模拟样本')
expect(mockSource.includes("'mock-readme'"), 'Mock 数据应为通用数据集提供模拟说明')
expect(mockSource.includes('.map((dataset)'), '所有 Mock 数据集都应自动补齐预览文件')
expect(mockSource.includes(': [{ id: `dataset-${dataset.id}-samples`'), '每个 Mock 数据集应只对应一个数据文件')
expect(apiSource.includes('createDatasetFileVersion'), '数据集 API 应提供创建新版本方法')
expect(apiSource.includes('activateDatasetFileVersion'), '数据集 API 应提供切换当前版本方法')
expect(apiSource.includes('deleteDatasetFileVersion'), '数据集 API 应提供删除历史版本方法')
expect(apiSource.includes('getDatasetFileVersionContent'), '数据集 API 应支持读取历史版本内容')
expect(apiSource.includes('expected_current_version_id'), '创建和激活版本应携带乐观锁版本指针')
expect(!apiSource.includes('updateDatasetFileContent'), '数据编辑不应继续覆盖历史版本内容')
expect(adapterSource.includes('mock:dataset-versions:'), 'Mock 版本历史应支持刷新持久化')
expect(adapterSource.includes('当前版本已被其他用户更新'), 'Mock 应拒绝基于过期版本的并发覆盖')
expect(adapterSource.includes('历史版本不可覆盖'), '旧的覆盖保存接口应明确拒绝修改历史版本')
expect(versionsSource.includes('当前版本不可删除'), 'Mock 应拒绝删除当前版本')
expect(versionsSource.includes('初始版本不可删除'), 'Mock 应拒绝删除初始版本')
expect(adapterSource.includes('Mock 存储空间不足'), 'Mock 存储失败时应返回明确错误且保留编辑草稿')
const jsonl = [
'{"instruction":"第一条","output":"A"}',
'',
'{"instruction":"第二条","output":["B","C"]}',
].join('\n')
const parsed = parseDatasetRecords(jsonl, 'samples.jsonl')
expect(parsed.supported && parsed.records.length === 2, 'JSONL 应跳过空行并解析为逐条样本')
expect(parsed.records[1].sourceIndex === 2, '样本应保留原文件物理行位置')
const updated = updateDatasetRecord(jsonl, 'samples.jsonl', 2, {
instruction: '第二条已修改',
output: ['B', 'C'],
})
const beforeLines = jsonl.split('\n')
const afterLines = updated.split('\n')
expect(afterLines[0] === beforeLines[0] && afterLines[1] === '', '单条保存不得改写其他样本或空行')
expect(JSON.parse(afterLines[2]).instruction === '第二条已修改', '单条保存应准确更新目标样本')
expect(Array.isArray(JSON.parse(afterLines[2]).output), '单条保存不应丢失数组等非字符串字段类型')
const invalid = parseDatasetRecords('{invalid json}', 'samples.jsonl')
expect(invalid.records[0].kind === 'invalid' && invalid.records[0].error, '非法 JSON 行应被单独标记并给出错误')
const repairedJson = updateDatasetRecord('{invalid json}', 'samples.json', 0, { repaired: true })
expect(JSON.parse(repairedJson).repaired === true, '非法 JSON 文件应支持按单条方式修复')
const v1State = createInitialVersionState('A', '2026-07-13T00:00:00Z')
const v2State = appendDatasetVersion(v1State, 'B', '2026-07-13T01:00:00Z', '修改第一条')
expect(v2State !== v1State, '创建版本应返回新的不可变状态')
expect(v1State.versions.length === 1 && v1State.versions[0].content === 'A', '创建新版本不得修改历史版本')
expect(v2State.versions[1].version === 2 && v2State.active_version_id === 'v2', '新版本编号应递增并自动激活')
const switchedToV1 = activateDatasetVersion(v2State, 'v1')
expect(getActiveDatasetVersion(switchedToV1).content === 'A', '切换当前版本后应读取对应历史内容')
expect(v2State.active_version_id === 'v2' && v2State.versions[1].content === 'B', '切换版本不得污染原状态或历史快照')
const v3State = appendDatasetVersion(v2State, 'C', '2026-07-13T02:00:00Z', '修改第二条')
const deletedV2State = deleteDatasetVersion(v3State, 'v2')
expect(!deletedV2State.versions.some((item) => item.id === 'v2'), '删除历史版本后不应继续返回该版本')
expect(deletedV2State.active_version_id === 'v3', '删除历史版本不得改变当前版本指针')
const v4State = appendDatasetVersion(deletedV2State, 'D', '2026-07-13T03:00:00Z', '继续编辑')
expect(v4State.versions.at(-1)?.version === 4, '删除历史版本后不得复用旧版本号')
for (const protectedVersionId of ['v1', 'v3']) {
let protectedVersionRejected = false
try {
deleteDatasetVersion(v3State, protectedVersionId)
} catch {
protectedVersionRejected = true
}
expect(protectedVersionRejected, `${protectedVersionId} 受保护版本不得删除`)
}
let invalidVersionRejected = false
try {
activateDatasetVersion(v2State, 'missing')
} catch {
invalidVersionRejected = true
}
expect(invalidVersionRejected, '不存在的版本不得切换为当前版本')
console.log('dataset preview regression checks passed')

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const typesPath = path.resolve(scriptDir, '../src/types/index.ts')
const dataPath = path.resolve(scriptDir, '../src/mock/data.ts')
const viewPath = path.resolve(scriptDir, '../src/views/dataset/DatasetListView.vue')
const [typesSource, dataSource, viewSource] = await Promise.all([
readFile(typesPath, 'utf8'),
readFile(dataPath, 'utf8'),
readFile(viewPath, 'utf8'),
])
assert.match(typesSource, /export type DatasetSource = 'upload' \| 'task'/)
assert.match(typesSource, /source\?: DatasetSource/)
assert.equal((dataSource.match(/source: 'upload'/g) || []).length, 6)
assert.equal((dataSource.match(/source: 'task'/g) || []).length, 4)
for (const name of [
'客服对话清洗集',
'通用指令构造集',
'用户反馈脱敏集',
'多轮对话增强集',
]) {
const datasetLine = dataSource.split('\n').find((line) => line.includes(`name: '${name}'`))
assert.ok(datasetLine, `缺少数据任务 Mock${name}`)
assert.match(datasetLine, /source: 'task'/, `${name} 必须标记为数据任务来源`)
}
assert.match(viewSource, /const activeTab = ref<DatasetSource>\('upload'\)/)
assert.match(
viewSource,
/if \(activeTab\.value === 'task'\) \{\s*return dataList\.value\.filter\(\(item\) => item\.source === 'task'\)\s*\}/,
)
assert.match(viewSource, /return dataList\.value\.filter\(\(item\) => item\.source !== 'task'\)/)
assert.doesNotMatch(viewSource, /数据任务产生的数据集[\s\S]*?return \[\]/)
console.log('数据任务 Mock 数据与页签分流回归检查通过')

View File

@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
function read(relativePath) {
return readFileSync(resolve(root, relativePath), 'utf8')
}
function assert(condition, message) {
if (!condition) throw new Error(message)
}
const router = read('src/router/index.ts')
const login = read('src/views/login/LoginView.vue')
const sidebar = read('src/components/AppSidebar.vue')
assert(
router.includes("redirect: '/dashboard'"),
'The authenticated root and fallback routes should default to 服务看板',
)
assert(
router.includes("next('/dashboard')"),
'An authenticated user visiting the login page should enter 服务看板',
)
assert(
login.includes("router.push('/dashboard')"),
'A successful login should navigate to 服务看板',
)
assert(
sidebar.includes("route.path.split('/')[1] || 'dashboard'"),
'The sidebar fallback active item should be 服务看板',
)
console.log('default-dashboard regression checks passed')

View File

@@ -0,0 +1,194 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const sourceRoot = path.resolve(scriptDir, '../src')
const evalViewDir = path.join(sourceRoot, 'views/eval')
const [
createSource,
taskStepSource,
ruleStepSource,
basicMetricStepSource,
startStepSource,
dimensionFieldsSource,
listSource,
routerSource,
apiSource,
] = await Promise.all([
readFile(path.join(evalViewDir, 'EvalCreateView.vue'), 'utf8'),
readFile(path.join(evalViewDir, 'create/EvalTaskSetupStep.vue'), 'utf8'),
readFile(path.join(evalViewDir, 'create/EvalRuleSetupStep.vue'), 'utf8'),
readFile(path.join(evalViewDir, 'create/BasicMetricSetupStep.vue'), 'utf8'),
readFile(path.join(evalViewDir, 'create/StartEvalStep.vue'), 'utf8'),
readFile(path.join(evalViewDir, 'create/DimensionFormFields.vue'), 'utf8'),
readFile(path.join(evalViewDir, 'EvalView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
readFile(path.join(sourceRoot, 'api/modules/eval.ts'), 'utf8'),
])
function extractRouteBlock(source, routePath) {
const pathPattern = new RegExp(`path:\\s*['"]${routePath.replaceAll('/', '\\/')}['"]`)
const pathIndex = source.search(pathPattern)
assert.notEqual(pathIndex, -1, `未找到路由 ${routePath}`)
const openBrace = source.lastIndexOf('{', pathIndex)
assert.notEqual(openBrace, -1, `路由 ${routePath} 缺少左花括号`)
let depth = 0
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') depth += 1
if (source[index] === '}') depth -= 1
if (depth === 0) return source.slice(openBrace + 1, index)
}
assert.fail(`路由 ${routePath} 缺少右花括号`)
}
const createDimensionRoute = extractRouteBlock(routerSource, 'model-eval/dimension/create')
assert.match(
createDimensionRoute,
/redirect:\s*['"]\/model-eval\/create['"]/,
'独立创建维度入口必须收敛到新建评测向导',
)
assert.doesNotMatch(
createDimensionRoute,
/DimensionCreateView/,
'独立创建维度路由不应继续加载旧创建页',
)
const editDimensionRoute = extractRouteBlock(routerSource, 'model-eval/dimension/:id/edit')
assert.match(
editDimensionRoute,
/DimensionCreateView\.vue/,
'编辑维度路由必须继续复用 DimensionCreateView.vue',
)
assert.doesNotMatch(listSource, /评测维度/, '模型评测页不应保留评测维度页签及内容')
assert.doesNotMatch(listSource, /activeTab\s*===\s*['"]dimensions['"]/, '模型评测页不应保留评测维度状态分支')
assert.doesNotMatch(listSource, /getDimensionList|deleteDimension|dimensionList/, '模型评测页不应继续加载维度列表数据')
assert.match(
apiSource,
/createDimension\s*=\s*\([^)]*\)\s*=>\s*[\s\S]*?post<\{\s*id:\s*string\s*\|\s*number\s*\}>\(['"]\/dimension['"]\s*,\s*data\)/,
'createDimension 必须声明返回包含 string | number 类型 id',
)
assert.match(createSource, /EvalTaskSetupStep/, '向导缺少任务配置子组件')
assert.match(createSource, /EvalRuleSetupStep/, '向导缺少评测规则子组件')
assert.match(createSource, /BasicMetricSetupStep/, '向导缺少基础评测指标子组件')
assert.match(createSource, /StartEvalStep/, '向导缺少开始评测确认子组件')
assert.match(
createSource,
/任务配置[\s\S]*?大模型评测指标[\s\S]*?基础评测指标[\s\S]*?开始评测/,
'评测创建向导必须按“任务配置、大模型评测指标、基础评测指标、开始评测”定义四个步骤',
)
assert.match(
ruleStepSource,
/:show-description=["']false["']/,
'大模型评测指标步骤不应显示重复的规则描述输入框',
)
assert.match(
ruleStepSource,
/:show-status-settings=["']false["']/,
'大模型评测指标步骤不应显示维度管理状态字段',
)
assert.match(
ruleStepSource,
/LLM_METRIC_TYPES[^=]*=\s*\[['"]classification['"],\s*['"]metric['"]\][\s\S]*?:allowed-types=["']LLM_METRIC_TYPES["']/,
'大模型评测指标步骤只应提供依赖大模型的分类与评分指标',
)
assert.match(
dimensionFieldsSource,
/v-if=["']showDescription["'][^>]*label=["']描述["']/,
'共享维度字段必须支持按场景隐藏描述输入框',
)
assert.match(
dimensionFieldsSource,
/score_min[\s\S]*?score_max[\s\S]*?pass_threshold[\s\S]*?Math\.min[\s\S]*?Math\.max/,
'指标型评分区间变化时必须把通过阈值约束在合法范围内',
)
assert.match(
dimensionFieldsSource,
/prop=["']score_min["'][\s\S]*?:max=["'][^"']*score_max[^"']*["'][\s\S]*?prop=["']score_max["'][\s\S]*?:min=["'][^"']*score_min[^"']*["']/,
'评分最小值和最大值输入必须具备交叉边界约束',
)
assert.match(createSource, /currentStep\s*=\s*ref\(0\)/, '评测创建向导缺少当前步骤状态')
assert.match(createSource, /class=["']custom-wizard-steps["']/, '评测向导未复用数据处理的自定义步骤条结构')
assert.match(createSource, /class=["']step-connector["']/, '评测向导步骤条缺少连接线')
assert.match(createSource, /:aria-current=/, '步骤条缺少当前步骤的可访问性绑定')
assert.match(createSource, /currentStep === index \? 'step' : undefined/, '步骤条当前步骤语义不正确')
assert.doesNotMatch(createSource, /<el-steps\b|<el-step\b/, '评测向导不应继续使用 Element Plus 默认步骤条')
assert.match(
createSource,
/<EvalTaskSetupStep[\s\S]*?v-if=["'][^"']*currentStep[^"']*0[^"']*["']/,
'任务配置子组件没有绑定第一步',
)
assert.match(
createSource,
/<EvalRuleSetupStep[\s\S]*?currentStep[^"']*1/,
'大模型评测指标子组件没有绑定第二步',
)
assert.match(
createSource,
/<BasicMetricSetupStep[\s\S]*?currentStep[^"']*2/,
'基础评测指标子组件没有绑定第三步',
)
assert.match(
createSource,
/<StartEvalStep[\s\S]*?v-else/,
'开始评测确认子组件没有绑定第四步',
)
assert.match(taskStepSource, /prop=["']dataset_id["']/, '数据集字段缺少表单校验标识')
assert.match(
`${createSource}\n${taskStepSource}`,
/data_source[\s\S]*?dataset[\s\S]*?dataset_id/,
'选择评测数据集时必须校验 dataset_id',
)
assert.match(
basicMetricStepSource,
/BLEU[\s\S]*?ROUGE[\s\S]*?(?:Cosine|余弦)/,
'基础评测指标步骤必须提供 BLEU、ROUGE 与余弦相似度配置',
)
assert.match(
basicMetricStepSource,
/bleu_enabled[\s\S]*?rouge_enabled[\s\S]*?cosine_enabled/,
'基础评测指标必须支持分别决定是否启用',
)
assert.match(
createSource,
/await\s+createDimension\([\s\S]*?await\s+startEval\(/,
'最终提交必须先创建新维度,再使用维度 id 启动评测',
)
assert.match(
createSource,
/createdDimension(?:\.value)?\.id|createdDimensionId|dimensionResult\.id/,
'启动评测前必须读取新建维度返回的 id',
)
assert.match(
createSource,
/basic_metrics[\s\S]*?bleu[\s\S]*?rouge[\s\S]*?cosine/,
'启动评测时必须提交基础评测指标配置',
)
assert.match(
startStepSource,
/任务信息[\s\S]*?大模型评测指标[\s\S]*?基础评测指标/,
'开始评测步骤必须展示前三步配置摘要',
)
assert.match(createSource, /:loading=["']submitting["']/, '最终提交按钮缺少 loading 状态')
assert.match(createSource, /:disabled=["'][^"']*submitting/, '提交期间必须禁用重复操作')
assert.match(createSource, />\s*开始评测\s*</, '最终提交按钮必须命名为“开始评测”')
assert.match(createSource, /class=["'][^"']*wizard-footer/, '评测向导缺少统一底部操作栏')
assert.match(
createSource,
/\.wizard-footer\s*\{[\s\S]*?display:\s*flex[\s\S]*?justify-content:\s*flex-end/,
'底部操作栏必须保持企业表单常用的右对齐布局',
)
console.log('评测创建向导回归检查通过')

View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const sourceRoot = path.resolve(scriptDir, '../src')
const [routerSource, listSource, detailSource, apiSource, mockSource] = await Promise.all([
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
readFile(path.join(sourceRoot, 'views/eval/EvalView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'views/eval/EvalDetailView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'api/modules/eval.ts'), 'utf8'),
readFile(path.join(sourceRoot, 'mock/adapter.ts'), 'utf8'),
])
function extractRouteBlock(source, routePath) {
const pathPattern = new RegExp(`path:\\s*['"]${routePath.replaceAll('/', '\\/')}['"]`)
const pathIndex = source.search(pathPattern)
assert.notEqual(pathIndex, -1, `未找到路由 ${routePath}`)
const openBrace = source.lastIndexOf('{', pathIndex)
assert.notEqual(openBrace, -1, `路由 ${routePath} 缺少左花括号`)
let depth = 0
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') depth += 1
if (source[index] === '}') depth -= 1
if (depth === 0) return source.slice(openBrace + 1, index)
}
assert.fail(`路由 ${routePath} 缺少右花括号`)
}
const detailRoute = extractRouteBlock(routerSource, 'model-eval/:id')
assert.match(detailRoute, /name:\s*['"]model-eval-detail['"]/, '评测详情路由名称不正确')
assert.match(
detailRoute,
/component:\s*\(\)\s*=>\s*import\(['"]@\/views\/eval\/EvalDetailView\.vue['"]\)/,
'评测详情路由未加载 EvalDetailView.vue',
)
assert.match(
listSource,
/router\.push\(\s*\{\s*name:\s*['"]model-eval-detail['"]\s*,\s*params:\s*\{\s*id:\s*row\.id\s*\}\s*\}\s*\)/,
'评测任务详情按钮必须使用命名路由并携带任务 id',
)
assert.doesNotMatch(listSource, /详情功能开发中/, '评测任务详情入口仍是占位提示')
assert.match(apiSource, /export\s+const\s+getEvalDetail\b/, '评测 API 缺少 getEvalDetail')
assert.match(
apiSource,
/get(?:<[^>]+>)?\(`\/model-eval\/\$\{id\}`\)/,
'getEvalDetail 未请求 GET /model-eval/:id',
)
assert.ok(
mockSource.includes('/^\\/model-eval\\/([^/]+)$/'),
'Mock 未按任务 id 匹配 /model-eval/:id',
)
assert.match(mockSource, /method\s*===\s*['"]get['"]/, '评测详情 Mock 未处理 GET 请求')
for (const className of [
'overall-review',
'score-hero',
'improvement-list',
'sample-results-table',
'dimension-summary',
]) {
assert.match(
detailSource,
new RegExp(`class=["'][^"']*\\b${className}\\b`),
`评测详情页缺少 .${className} 结构`,
)
}
for (const label of ['输入', '标准答案', '模型回答', '得分', '判定']) {
assert.match(
detailSource,
new RegExp(`(?:label=["'][^"']*${label}[^"']*["']|>${label}<)`),
`样本结果列表缺少“${label}”语义`,
)
}
assert.match(detailSource, /\bloading\b/, '评测详情页缺少加载状态')
assert.match(detailSource, /\berror\b/, '评测详情页缺少错误状态')
assert.match(
detailSource,
/(?:el-empty|empty-text|样本结果为空|暂无样本|暂无数据)/,
'评测详情页缺少空状态',
)
assert.match(detailSource, /<el-pagination\b/, '评测详情页缺少样本分页')
for (const marketingLabel of ['OVERALL SCORE', 'LLM REVIEW', 'DIMENSIONS', 'SAMPLE RESULTS']) {
assert.doesNotMatch(detailSource, new RegExp(marketingLabel), `企业详情页不应保留展示型英文标签:${marketingLabel}`)
}
assert.doesNotMatch(detailSource, /linear-gradient\s*\(/, '企业详情页不应使用大面积渐变背景')
assert.match(detailSource, /grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)/, '桌面端概览或维度区应采用紧凑四列布局')
console.log('评测详情页回归检查通过')

View File

@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
const source = readFileSync(resolve(root, 'src/views/fine-tune/FineTuneCreateView.vue'), 'utf8')
const modelDialogSource = readFileSync(resolve(root, 'src/components/ModelSelectDialog.vue'), 'utf8')
const formModelSource = readFileSync(resolve(root, 'src/views/fine-tune/fineTuneFormModel.ts'), 'utf8')
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
assert(source.includes(':rows="4"'), '任务描述 textarea should be taller than the original 2 rows')
assert(source.includes('gpu-index'), 'GPU cards should use a restrained index label')
assert(source.includes('GPU-{{ idx }}'), 'GPU cards should show the GPU number in a compact format')
assert(source.includes('gpu-usage-bar'), 'GPU cards should visualize usage with an enterprise-style progress bar')
assert(source.includes('is-busy'), 'GPU cards should have a distinct busy state for usage over 80%')
assert(source.includes('gpu.gpu_percent > 80'), 'GPU busy state should be driven by usage over 80%')
assert(source.includes('modelDialogVisible'), 'Model selection should open a dialog instead of a plain select')
assert(!modelDialogSource.includes('width="78vw"'), 'Model dialog should not use the previous oversized 78vw width')
assert(modelDialogSource.includes('width="860px"'), 'Model dialog should use a compact enterprise modal width')
assert(modelDialogSource.includes('model-series-list'), 'Model dialog should include a model series list column')
assert(modelDialogSource.includes('model-version-list'), 'Model dialog should include a snapshot/version list column')
assert(modelDialogSource.includes('handleConfirm'), 'Model dialog should confirm the selected model before updating the form')
assert(formModelSource.includes('auto_merge: false'), 'Auto merge should default to disabled')
assert(source.includes('v-if="form.train_type === \'SFT\'"'), 'Merge model settings should only be visible for SFT')
assert(source.includes('content-position="left">合并模型'), 'SFT form should include a merge model section below data configuration')
assert(source.includes('v-model="form.auto_merge"'), 'Merge model section should provide an auto merge selector')
assert(source.includes('label="自动合并权重并保存"'), 'Auto merge selector should use a clear visible label')
assert(formModelSource.includes("auto_merge: form.train_type === 'SFT' && form.auto_merge"), 'Auto merge should be normalized by the shared payload builder')
assert(source.includes('const payload = buildFineTunePayload(form, selectedGpus.value)'), 'Create and start should share one normalized payload')
assert(source.includes('startFineTune({ ...payload, task_id: taskId })'), 'Start request should reuse the normalized payload')
assert(source.includes('Object.assign(form, DEFAULT_TRAINING_PARAMS)'), 'Reset should reuse the canonical defaults')
assert(!source.includes('const taskData = {'), 'The duplicated create payload should be removed')
assert(!source.includes('const createRes: any'), 'Create response should use the API return type')
assert(!source.includes('(check as any).exists'), 'Name-check response should use its API return type')
assert(source.includes('任务名校验失败'), 'Name-check failures should be visible and block submission')
const runnableSource = ts.transpileModule(
formModelSource.replace("import type { FineTuneStartPayload, FineTuneTask } from '@/types'", ''),
{ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } },
).outputText
const model = await import(`data:text/javascript;base64,${Buffer.from(runnableSource).toString('base64')}`)
const defaults = model.createDefaultFineTuneForm()
const customized = { ...defaults, train_type: 'DPO', train_method: 'full', auto_merge: true, quantization_bit: 4 }
const payload = model.buildFineTunePayload(customized, [0, 2])
assert(payload.auto_merge === false, 'Non-SFT tasks must never enable auto merge')
assert(payload.quantization_bit === 0, 'Full fine-tuning must never send QLoRA quantization')
assert(payload.gpus.join(',') === '0,2', 'Selected GPUs should be preserved in the shared payload')
const resetDefaults = { ...model.DEFAULT_TRAINING_PARAMS }
assert(resetDefaults.lora_alpha === defaults.lora_alpha, 'Reset and initial defaults must share LoRA values')
console.log('fine-tune create UI regression checks passed')

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const sourceRoot = path.resolve(scriptDir, '../src')
const [viewSource, typeSource, mockSource] = await Promise.all([
readFile(path.join(sourceRoot, 'views/system/HardwareView.vue'), 'utf8'),
readFile(path.join(sourceRoot, 'types/index.ts'), 'utf8'),
readFile(path.join(sourceRoot, 'mock/data.ts'), 'utf8'),
])
assert.match(viewSource, /title="平台性能"/, '平台性能页缺少明确标题')
assert.match(viewSource, /硬件资源概览/, '平台性能页缺少硬件资源概览')
assert.match(viewSource, /系统资源趋势/, '平台性能页缺少资源趋势')
assert.match(viewSource, /GPU 资源池/, '平台性能页缺少 GPU 资源池')
assert.match(viewSource, /gpu-detail-drawer/, 'GPU 卡片缺少详情抽屉')
assert.match(viewSource, /GPU 进程/, 'GPU 详情缺少占用进程表')
assert.match(viewSource, /visibilitychange/, '轮询没有在页面不可见时暂停')
assert.match(viewSource, /requesting \|\| disposed/, '轮询缺少防重入保护')
assert.match(viewSource, /slice\(-60\)/, '趋势快照没有限制为最近 60 个采样点')
assert.match(viewSource, /@keydown="handleGpuKeydown/, 'GPU 卡片缺少键盘访问能力')
assert.match(viewSource, /download_mb_s/, '网络概览没有使用明确的实时速率字段')
assert.doesNotMatch(
viewSource,
/download_mb_s\s*\?\?\s*info\.network\?\.download_mb/,
'不能把累计网络流量回退显示为实时速率',
)
assert.match(typeSource, /export interface GpuProcess/, '系统监控类型缺少 GPU 进程结构')
assert.match(typeSource, /processes\?: GpuProcess\[\]/, 'GPU 类型缺少进程详情')
assert.match(typeSource, /download_mb_s\?: number/, '系统类型缺少实时网络接收速率')
assert.match(typeSource, /power_limit_w\?: number/, 'GPU 类型缺少功耗上限')
for (let id = 0; id < 8; id += 1) {
assert.match(mockSource, new RegExp(`id:\\s*${id},[\\s\\S]*?uuid:\\s*'GPU-MOCK-A800-0${id}'`), `Mock 缺少 GPU ${id} 详情`)
}
console.log('平台性能页回归检查通过')

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/model/ModelManageView.vue')
const source = await readFile(viewPath, 'utf8')
const { descriptor, errors } = parseSfc(source, { filename: viewPath })
assert.equal(errors.length, 0, `模型管理页面模板无法解析:${errors[0]}`)
assert.ok(descriptor.template?.content.trim(), '模型管理页面缺少可渲染模板')
assert.equal((source.match(/<template>/g) || []).length, 1, '模型管理页面只能有一个根模板标签')
const configTableStart = source.indexOf('v-if="activeTab === \'config\'"')
const trainedTableStart = source.indexOf('<DataTablePage\n v-else')
assert.ok(configTableStart >= 0 && trainedTableStart > configTableStart, '模型管理页面缺少两个标签对应的列表分支')
assert.doesNotMatch(
source.slice(configTableStart, trainedTableStart),
/activeTab === 'trained'/,
'配置模型分支不应再比较已不可能的训练模型状态',
)
assert.doesNotMatch(
source.slice(trainedTableStart),
/activeTab === 'config'/,
'训练模型分支不应再比较已不可能的配置模型状态',
)
console.log('模型管理页面模板回归检查通过')

View File

@@ -0,0 +1,191 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseTemplate } from '@vue/compiler-dom'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const [globalStyles, routerSource, mainLayoutSource, trainingLogSource, trainingOverviewSource, fineTuneCreateSource] = await Promise.all([
readFile(path.resolve(scriptDir, '../src/styles/index.scss'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/views/system/training-log/TrainingTaskOverview.vue'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/views/fine-tune/FineTuneCreateView.vue'), 'utf8'),
])
function extractCssBlock(css, marker) {
const markerIndex = css.indexOf(marker)
assert.notEqual(markerIndex, -1, `未找到样式规则:${marker}`)
const openBrace = css.indexOf('{', markerIndex)
assert.notEqual(openBrace, -1, `样式规则缺少左花括号:${marker}`)
let depth = 0
for (let index = openBrace; index < css.length; index += 1) {
if (css[index] === '{') depth += 1
if (css[index] === '}') depth -= 1
if (depth === 0) return css.slice(openBrace + 1, index)
}
assert.fail(`样式规则缺少右花括号:${marker}`)
}
function findElements(node, predicate, result = []) {
if (node?.type === 1 && predicate(node)) result.push(node)
for (const child of node?.children || []) findElements(child, predicate, result)
return result
}
function staticAttribute(node, name) {
const prop = node.props.find((item) => item.type === 6 && item.name === name)
return prop?.value?.content
}
function boundAttribute(node, name) {
const prop = node.props.find(
(item) => item.type === 7 && item.name === 'bind' && item.arg?.content === name,
)
return prop?.exp?.content
}
function extractRouteBlock(source, routePath) {
const pathPattern = new RegExp(`path:\\s*['"]${routePath.replaceAll('/', '\\/')}['"]`)
const pathIndex = source.search(pathPattern)
assert.notEqual(pathIndex, -1, `未找到路由 /${routePath}`)
const openBrace = source.lastIndexOf('{', pathIndex)
assert.notEqual(openBrace, -1, `路由 /${routePath} 缺少左花括号`)
let depth = 0
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') depth += 1
if (source[index] === '}') depth -= 1
if (depth === 0) return source.slice(openBrace + 1, index)
}
assert.fail(`路由 /${routePath} 缺少右花括号`)
}
const selfSurfaceRoutes = [
'fine-tune',
'model-eval',
'model-inference',
'model-inference/chat/:id',
'model-manage',
'data-process',
'data-process/create',
'dataset',
'user-settings',
]
for (const routePath of selfSurfaceRoutes) {
const routeBlock = extractRouteBlock(routerSource, routePath)
assert.match(
routeBlock,
/meta:\s*\{[^}]*pageSurface:\s*['"]self['"][^}]*\}/,
`列表路由 /${routePath} 未声明 pageSurface: 'self'`,
)
}
assert.equal(
routerSource.match(/pageSurface:\s*['"]self['"]/g)?.length,
selfSurfaceRoutes.length,
"只能给指定的自带白色表面的列表路由声明 pageSurface: 'self'",
)
const rootBlock = extractCssBlock(globalStyles, ':root')
assert.match(rootBlock, /--app-shell-bg:\s*#f3f5f8;/, '全局样式缺少灰色外层背景 token')
assert.match(rootBlock, /--app-page-bg:\s*#ffffff;/, '全局样式缺少白色页面画布 token')
assert.match(rootBlock, /--app-surface-bg:\s*#ffffff;/, '全局样式缺少统一内容表面 token')
const bodyBlock = extractCssBlock(globalStyles, '\nbody {')
assert.match(bodyBlock, /background-color:\s*var\(--app-shell-bg\);/, 'body 未使用灰色外层背景')
const cardBlock = extractCssBlock(globalStyles, '.el-card {')
assert.match(cardBlock, /background-color:\s*var\(--app-surface-bg\);/, '全局卡片未使用统一内容表面')
const mainLayoutDescriptor = parseSfc(mainLayoutSource).descriptor
const mainLayoutScript = mainLayoutDescriptor.scriptSetup?.content || ''
const mainLayoutStyle = mainLayoutDescriptor.styles.map((item) => item.content).join('\n')
const mainLayoutTemplate = mainLayoutDescriptor.template?.content || ''
const mainLayoutAst = parseTemplate(mainLayoutTemplate)
assert.match(mainLayoutScript, /import\s*\{[^}]*\buseRoute\b[^}]*\}\s*from\s*['"]vue-router['"]/, '主布局未引入 useRoute')
assert.match(mainLayoutScript, /const\s+route\s*=\s*useRoute\(\)/, '主布局未获取当前路由')
const pageCanvases = findElements(
mainLayoutAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('page-canvas'),
)
assert.equal(pageCanvases.length, 1, '主布局必须且只能提供一个全局白色页面画布')
assert.match(
boundAttribute(pageCanvases[0], 'class') || '',
/['"]is-self-surface['"]\s*:\s*route\.meta\.pageSurface\s*===\s*['"]self['"]/,
'主布局未根据 route.meta.pageSurface 为页面画布添加 is-self-surface 类',
)
assert.equal(
findElements(pageCanvases[0], (node) => node.tag === 'router-view').length,
1,
'所有业务路由必须渲染在全局页面画布内部',
)
assert.equal(
findElements(mainLayoutAst, (node) => node.tag.toLowerCase() === 'transition').length,
0,
'主布局仍包含会造成列表与二级页面表面错位的页面级 Transition',
)
assert.doesNotMatch(
mainLayoutStyle,
/\.fade-(?:enter|leave)-(?:active|from|to)/,
'主布局仍包含页面级透明度转场样式',
)
const layoutContentBlock = extractCssBlock(mainLayoutStyle, '.layout-content')
assert.match(layoutContentBlock, /background-color:\s*var\(--app-shell-bg\);/, '主内容区未使用灰色外层背景')
const pageCanvasBlock = extractCssBlock(mainLayoutStyle, '\n.page-canvas {')
assert.match(pageCanvasBlock, /flex:\s*1 0 auto;/, '白色页面画布没有铺满可用高度')
assert.match(pageCanvasBlock, /padding:\s*24px;/, '白色页面画布缺少统一内容内边距')
assert.match(pageCanvasBlock, /border-radius:\s*16px;/, '白色页面画布圆角与参考不一致')
assert.match(pageCanvasBlock, /background-color:\s*var\(--app-page-bg\);/, '全局页面画布未使用白色背景')
const selfSurfaceBlock = extractCssBlock(mainLayoutStyle, '.page-canvas.is-self-surface')
assert.match(selfSurfaceBlock, /padding:\s*0;/, '自带表面的页面仍保留全局画布内边距')
assert.match(selfSurfaceBlock, /border-radius:\s*0;/, '自带表面的页面仍保留全局画布圆角')
assert.match(selfSurfaceBlock, /background-color:\s*transparent;/, '自带表面的页面未透出灰色应用背景')
assert.match(selfSurfaceBlock, /box-shadow:\s*none;/, '自带表面的页面仍保留全局画布阴影')
assert.match(
mainLayoutStyle,
/\.page-canvas:not\(\.is-self-surface\)\s*>\s*:deep\(\.page-card-host\s*>\s*\.page-card\)/,
'显式根卡片宿主内的 PageCard 未被识别为页面根卡片',
)
assert.doesNotMatch(
mainLayoutStyle,
/:deep\(\*\s*>\s*\.page-card\)/,
'通用层级选择器会误伤训练日志等页面的内部业务卡片',
)
assert.match(
fineTuneCreateSource,
/class=["'][^"']*\bfine-tune-create\b[^"']*\bpage-card-host\b[^"']*["']/,
'创建训练任务页未显式标记根 PageCard 宿主',
)
const rootPageCardBlock = extractCssBlock(
mainLayoutStyle,
'.page-canvas:not(.is-self-surface) > :deep(.page-card)',
)
assert.match(rootPageCardBlock, /margin-bottom:\s*0;/, '页面根卡片仍在白色画布内保留额外外边距')
assert.match(rootPageCardBlock, /background-color:\s*transparent;/, '页面根卡片仍形成第二层白色背景')
assert.match(rootPageCardBlock, /border-radius:\s*0\s*!important;/, '页面根卡片仍形成第二层圆角边界')
assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根卡片仍形成重复卡片层级')
const trainingLogStyle = [trainingLogSource, trainingOverviewSource]
.flatMap((source) => parseSfc(source).descriptor.styles.map((item) => item.content))
.join('\n')
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.profile-section,\n.runtime-panel')
assert.match(
businessSurfaceBlock,
/background:\s*var\(--app-surface-bg\);/,
'任务、数据集和运行概况未使用统一白色内容表面',
)
console.log('全局页面背景与内容表面回归检查通过')

View File

@@ -0,0 +1,223 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import ts from 'typescript'
import { parse as parseTemplate } from '@vue/compiler-dom'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue')
const overviewPath = path.resolve(scriptDir, '../src/views/system/training-log/TrainingTaskOverview.vue')
const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts')
const [source, overviewSource, modelSource] = await Promise.all([
readFile(viewPath, 'utf8'),
readFile(overviewPath, 'utf8'),
readFile(modelPath, 'utf8'),
])
const { descriptor } = parseSfc(source, { filename: viewPath })
const { descriptor: overviewDescriptor } = parseSfc(overviewSource, { filename: overviewPath })
const template = [descriptor.template?.content, overviewDescriptor.template?.content].filter(Boolean).join('\n')
const viewStyle = descriptor.styles.map((item) => item.content).join('\n')
const overviewStyle = overviewDescriptor.styles.map((item) => item.content).join('\n')
const style = `${viewStyle}\n${overviewStyle}`
const templateAst = parseTemplate(template)
const modelModuleCode = ts.transpileModule(modelSource, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
const {
parseTrainingLog,
resolveTrainingLogFile,
} = await import(`data:text/javascript;base64,${Buffer.from(modelModuleCode).toString('base64')}`)
function findElements(node, predicate, result = []) {
if (node?.type === 1 && predicate(node)) result.push(node)
for (const child of node?.children || []) findElements(child, predicate, result)
return result
}
function staticAttribute(node, name) {
const prop = node.props.find((item) => item.type === 6 && item.name === name)
return prop?.value?.content
}
function boundExpression(node, name) {
const prop = node.props.find(
(item) => item.type === 7
&& item.name === 'bind'
&& item.arg?.type === 4
&& item.arg.content === name,
)
return prop?.exp?.type === 4 ? prop.exp.content : undefined
}
function extractCssBlock(css, marker) {
const markerIndex = css.indexOf(marker)
assert.notEqual(markerIndex, -1, `未找到样式规则:${marker}`)
const openBrace = css.indexOf('{', markerIndex)
assert.notEqual(openBrace, -1, `样式规则缺少左花括号:${marker}`)
let depth = 0
for (let index = openBrace; index < css.length; index += 1) {
if (css[index] === '{') depth += 1
if (css[index] === '}') depth -= 1
if (depth === 0) return css.slice(openBrace + 1, index)
}
assert.fail(`样式规则缺少右花括号:${marker}`)
}
const logFiles = [
{ file: 'first_pid111.log', name: 'first-task', size: '1 KB', pid: 111 },
{ file: 'target_pid222.log', name: 'target-task', size: '1 KB', pid: 222 },
]
assert.equal(
resolveTrainingLogFile(logFiles, { process_id: 222, name: 'target-task' })?.file,
'target_pid222.log',
'训练日志必须按 task.process_id 精确匹配文件 pid不能只判断两者是否存在',
)
assert.equal(
resolveTrainingLogFile(logFiles, { process_id: 999, name: 'target-task' })?.file,
'target_pid222.log',
'PID 无匹配时应按任务名回退选择日志',
)
assert.equal(
resolveTrainingLogFile(logFiles, { process_id: 999, name: 'missing-task' }),
undefined,
'任务无匹配日志时不得静默退回第一份日志',
)
const parsed = parseTrainingLog([
"INFO {'epoch': .5, 'learning_rate': 1.2E-5, 'grad_norm': -2.5e+0, 'loss': +3.25}",
'***** train metrics *****',
'train_runtime = 1.7852e3',
"'train_loss': -3.42e-1",
'epoch: 1.0',
'***** train metrics end *****',
].join('\n'))
assert.deepEqual(parsed.metrics.loss, [3.25], '指标解析应允许字段乱序和带符号数值')
assert.deepEqual(parsed.metrics.gradNorm, [-2.5], '梯度范数应支持科学计数法')
assert.deepEqual(parsed.metrics.lr, [1.2e-5], '学习率应支持大小写科学计数法')
assert.deepEqual(
parsed.summary,
{ epoch: '1.0', trainLoss: '-3.42e-1', runtime: '1.7852e3' },
'训练汇总应同时支持等号、冒号、可选引号和科学计数法',
)
assert.deepEqual(
parseTrainingLog('普通日志,无训练指标').summary,
{ epoch: '', trainLoss: '', runtime: '' },
'每次解析必须返回全新的空汇总,避免保留上一份日志的旧值',
)
assert.match(
source,
/const currentTask = await loadTask\(\)[\s\S]*?loadLog\(currentTask\)/,
'刷新流程必须先加载 task再使用该 task 选择日志',
)
assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源')
assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新')
assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠')
assert.match(source, /onUnmounted\([\s\S]*?clearInterval\(timer\)/, '组件卸载时必须清理轮询定时器')
const overview = findElements(
templateAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
)
assert.equal(overview.length, 1, '标准任务概况容器必须且只能存在一个')
assert.doesNotMatch(overviewSource, /<aside\b/, '任务概况仍保留左右侧栏结构')
assert.doesNotMatch(overviewSource, /<i class="fa\b/, '任务概况仍包含过多装饰性图标')
const toggleButtons = findElements(
templateAst,
(node) => node.tag === 'el-button'
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
)
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的标准按钮')
const toggleButton = toggleButtons[0]
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
assert.equal(staticAttribute(toggleButton, 'aria-controls'), 'training-parameter-content', '折叠按钮缺少正确的 aria-controls')
const controlledRegions = findElements(
templateAst,
(node) => staticAttribute(node, 'id') === 'training-parameter-content',
)
assert.equal(controlledRegions.length, 1, 'aria-controls 指向的参数内容区域不存在或重复')
const pageTitleIndex = source.indexOf('id="task-page-title"')
const summaryCardIndex = source.indexOf('id="training-overview-title"')
const taskOverviewIndex = source.indexOf('<TrainingTaskOverview')
assert.notEqual(pageTitleIndex, -1, '页面缺少独立的训练任务标题')
assert.ok(pageTitleIndex < summaryCardIndex, '训练任务标题必须出现在训练概览之前')
assert.ok(summaryCardIndex < taskOverviewIndex, '训练概览必须出现在任务信息之前')
assert.equal((source.match(/<h1\b/g) || []).length, 1, '训练详情页必须且只能有一个一级标题')
assert.doesNotMatch(overviewSource, /<h1\b/, '任务信息卡片不得重复渲染页面一级标题')
assert.ok(overviewSource.includes('title="任务信息"'), '任务详情卡片缺少明确的“任务信息”标题')
const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
assert.ok(template.includes('training-progress-panel'), '训练概览缺少独立的主进度区域')
assert.ok(template.includes('training-metric-grid'), '训练概览缺少次级训练指标摘要')
assert.ok(template.includes('gpu-device-list'), '训练概览缺少紧凑的 GPU 设备列表')
assert.ok(template.includes('gpu-list-header'), 'GPU 设备列表缺少企业表格式列标题')
assert.ok(template.includes('gpu-device-row'), 'GPU 设备列表缺少统一行结构')
assert.doesNotMatch(template, /training-summary-strip|gpu-device-card/, '训练概览仍保留同级宫格或 GPU 嵌套卡片结构')
assert.ok(template.includes('id="training-overview-title"'), '训练概览缺少可访问的卡片标题')
assert.ok(
template.indexOf('id="training-overview-title"') < template.indexOf('<TrainingTaskOverview'),
'训练概览必须位于任务档案之前,成为页面第一块内容',
)
assert.ok(
template.indexOf('training-progress-panel') < template.indexOf('training-metric-grid')
&& template.indexOf('training-metric-grid') < template.indexOf('gpu-device-list'),
'训练概览必须按主进度、次级指标、GPU 设备列表的顺序展示',
)
assert.ok(template.includes('id="gpu-monitor-title"'), '训练概览缺少 GPU 运行状态区域')
assert.ok(template.includes('计算利用率'), 'GPU 运行状态缺少计算利用率')
assert.ok(template.includes('显存占用'), 'GPU 运行状态缺少显存占用')
assert.ok(template.includes('每 5 秒刷新'), 'GPU 运行状态缺少刷新频率说明')
assert.ok(template.includes(':aria-label="`GPU ${item.index} 计算利用率'), 'GPU 强度条缺少可访问说明')
assert.match(source, /const GPU_PREVIEW_LIMIT = 4/, '多 GPU 默认预览数量必须限制为 4 张')
assert.match(source, /const visibleGpuItems = computed/, '多 GPU 缺少渐进披露列表计算')
assert.match(source, /gpuRuntimePriority/, '折叠状态必须优先展示异常和运行中的 GPU')
assert.ok(template.includes('v-for="item in visibleGpuItems"'), 'GPU 列表没有使用受控预览数据')
assert.ok(template.includes('taskGpuItems.length > GPU_PREVIEW_LIMIT'), 'GPU 数量超过预览上限时没有展开入口')
const gpuToggleButtons = findElements(
templateAst,
(node) => node.tag === 'el-button'
&& staticAttribute(node, 'class')?.split(/\s+/).includes('gpu-toggle-button'),
)
assert.equal(gpuToggleButtons.length, 1, '多 GPU 展开控制必须且只能存在一个')
assert.equal(boundExpression(gpuToggleButtons[0], 'aria-expanded'), 'gpuExpanded', 'GPU 展开按钮未绑定 aria-expanded')
assert.equal(staticAttribute(gpuToggleButtons[0], 'aria-controls'), 'gpu-device-list', 'GPU 展开按钮缺少正确的 aria-controls')
assert.ok(template.includes('title="训练曲线"'), '训练曲线没有使用标准 PageCard 标题')
assert.ok(template.includes('title="训练日志"'), '训练日志没有使用标准 PageCard 标题')
assert.ok(template.includes('每 5 秒刷新'), '训练监控区缺少自动刷新状态说明')
assert.ok((template.match(/<el-descriptions\b/g) || []).length >= 3, '任务概况和训练参数没有使用标准详情表格')
assert.equal((template.match(/class="chart-section"/g) || []).length, 3, '三组训练曲线没有按上下结构完整展示')
assert.doesNotMatch(template, /<el-row\b|<el-col\b/, '训练曲线仍保留左右分栏布局')
assert.doesNotMatch(template, /class="chart-card"/, '训练曲线仍存在卡片嵌套')
assert.doesNotMatch(template, /summary-metric is-primary/, '训练概览仍保留大块装饰性主色背景')
for (const selector of ['.summary-card', '.parameters-card', '.metrics-panel', '.log-card']) {
const cardBlock = extractCssBlock(style, selector)
assert.match(cardBlock, /border-radius:\s*8px/, `${selector} 未统一为 8px 企业卡片圆角`)
assert.match(cardBlock, /box-shadow:\s*none/, `${selector} 仍保留不统一的浮层阴影`)
}
assert.ok(template.includes("task?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
const overviewLayoutBlock = extractCssBlock(overviewStyle, '.overview-layout')
assert.match(overviewLayoutBlock, /width:\s*100%/, '任务概况没有使用全宽单列布局')
assert.doesNotMatch(overviewLayoutBlock, /grid-template-columns/, '任务档案仍保留左右分栏规则')
const overviewMedia700 = extractCssBlock(overviewStyle, '@media (max-width: 700px)')
assert.ok(overviewMedia700.includes('.task-descriptions'), '700px 断点缺少任务详情表单列规则')
assert.match(overviewMedia700, /display:\s*block/, '移动端任务详情表没有转换为单列')
const viewMedia600 = extractCssBlock(viewStyle, '@media (max-width: 600px)')
assert.ok(viewMedia600.includes('.parameter-descriptions'), '600px 断点缺少训练参数表单列规则')
assert.match(viewMedia600, /display:\s*block/, '移动端训练参数表没有转换为单列')
console.log('训练日志详情布局回归检查通过')

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const mockDataPath = path.resolve(scriptDir, '../src/mock/data.ts')
const mockAdapterPath = path.resolve(scriptDir, '../src/mock/adapter.ts')
const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts')
const [mockData, mockAdapter, modelSource] = await Promise.all([
readFile(mockDataPath, 'utf8'),
readFile(mockAdapterPath, 'utf8'),
readFile(modelPath, 'utf8'),
])
function objectFromMarker(source, marker) {
const markerIndex = source.indexOf(marker)
assert.notEqual(markerIndex, -1, `未找到 Mock 数据标记:${marker}`)
const openBrace = source.lastIndexOf('{', markerIndex)
assert.notEqual(openBrace, -1, `Mock 数据缺少对象起点:${marker}`)
let depth = 0
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') depth += 1
if (source[index] === '}') depth -= 1
if (depth === 0) return source.slice(openBrace, index + 1)
}
assert.fail(`Mock 数据缺少对象终点:${marker}`)
}
const taskNames = [
'finance-sft-001',
'legal-sft-002',
'medical-cpt-001',
'service-dpo-001',
'finance-sft-002',
'general-sft-001',
]
const commonTrainingFields = [
'output_model_name',
'batch_size',
'learning_rate',
'n_epochs',
'save_steps',
'lr_scheduler_type',
'max_length',
'warmup_ratio',
'weight_decay',
]
for (const taskName of taskNames) {
const task = objectFromMarker(mockData, `name: '${taskName}'`)
for (const field of commonTrainingFields) {
assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少训练参数:${field}`)
}
if (/train_method:\s*'lora'/.test(task)) {
for (const field of ['lora_rank', 'lora_alpha', 'lora_dropout']) {
assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少 LoRA 参数:${field}`)
}
}
}
const medicalTask = objectFromMarker(mockData, "name: 'medical-cpt-001'")
assert.match(medicalTask, /\bprocess_id\s*:/, '运行中的医疗训练任务缺少进程 ID')
assert.match(mockData, /medical-cpt-001_pid28741\.log/, '医疗训练任务缺少 PID 对应的日志文件')
assert.match(mockData, /export const mockTrainingLogContents/, '训练日志没有按文件提供独立 Mock 内容')
assert.match(mockData, /Num examples\s*=\s*9,800/, '医疗训练日志缺少样本规模信息')
assert.match(mockData, /Total optimization steps\s*=\s*921/, '医疗训练日志缺少真实训练步数信息')
assert.match(mockData, /\\'epoch\\':\s*1\.92/, '医疗训练日志的 Epoch 未与 64% 进度对齐')
assert.match(mockData, /\\'loss\\':\s*1\.146/, '医疗训练日志缺少当前 Loss 指标')
assert.match(mockAdapter, /const file = String\(params\.file/, '训练日志接口没有读取请求中的日志文件名')
assert.match(mockAdapter, /mockTrainingLogContents\[file\]/, '训练日志接口没有按请求文件返回对应内容')
assert.match(modelSource, /epoch:\s*number\[\]/, '训练指标模型没有保留逐步 Epoch')
console.log('训练日志 Mock 数据回归检查通过')

View File

@@ -0,0 +1,15 @@
import { readdir } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'
import path from 'node:path'
const scriptsDir = path.resolve(process.cwd(), 'scripts')
const regressionScripts = (await readdir(scriptsDir))
.filter((file) => file.startsWith('regression-') && file.endsWith('.mjs'))
.sort()
for (const script of regressionScripts) {
console.log(`\n${script}`)
await import(pathToFileURL(path.join(scriptsDir, script)).href)
}
console.log(`\n${regressionScripts.length} 个前端回归脚本全部通过`)

7
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
// 根组件,仅承载路由出口
</script>
<template>
<router-view />
</template>

View File

@@ -0,0 +1,80 @@
import { get, post, del } from '../request'
import type { CompareTask, CompareModelRef } from '@/types'
/** 推理/对比任务列表 */
export const getCompareList = () => get<CompareTask[]>('/model-compare')
/** 任务详情 */
export const getCompare = (id: string | number) => get<CompareTask>(`/model-compare/${id}`)
/** 创建任务 */
export const createCompare = (data: Partial<CompareTask>) =>
post<{ id: string | number }>('/model-compare', data)
/** 删除任务 */
export const deleteCompare = (id: string | number) => del(`/model-compare/${id}`)
/** 更新任务加载状态 */
export const updateLoadStatus = (id: string | number, load_status: any) =>
post(`/model-compare/${id}/load-status`, { load_status })
/** 查询任务加载状态 */
export const getLoadStatus = (id: string | number) =>
get<{ all_ready: boolean; loaded_models: any[] }>(`/model-compare/${id}/load-status`)
/** 停止所有旧模型服务 */
export const stopAllModels = () => post('/model-compare/all/stop-all')
/** 启动单个模型服务 */
export const startModel = (id: string | number, data: Partial<CompareModelRef>) =>
post<{ pid: number; port: number }>(`/model-compare/${id}/start-model`, data)
/** 按 PID 停止模型进程 */
export const stopModelByPid = (pid: number) =>
post('/model-compare/stop-by-pid', { pid })
/** 加载任务 */
export const loadCompare = (id: string | number) => post(`/model-compare/${id}/load`)
/** 卸载任务 */
export const unloadCompare = (id: string | number) => post(`/model-compare/${id}/unload`)
/** 流式对话mock 模式下返回非流式响应,调用方需兼容) */
export const streamChat = async (data: any): Promise<any> => {
// Mock 环境:返回非流式响应对象,调用方检测 content-type 决定如何处理
const resp = await post<{ response: string }>('/model-compare/stream-chat', data)
return {
ok: true,
body: {
getReader: () => {
// 模拟流式读取:把整个响应切成小块逐个返回
const text = resp?.response || ''
const encoder = new TextEncoder()
const chunks = [text.slice(0, text.length / 3), text.slice(text.length / 3, 2 * text.length / 3), text.slice(2 * text.length / 3)]
let i = 0
return {
read: async () => {
if (i >= chunks.length) return { done: true, value: undefined }
const value = encoder.encode(chunks[i++])
return { done: false, value }
},
}
},
},
}
}
/** 非流式对话(按端口代理) */
export const chatWithPort = (data: any) => post('/model-compare/chat-with-port', data)
/** 批量对话API 类型模型) */
export const batchChat = (data: any) => post('/model-chat/batch', data)
/** 本地 transformers 模型对话 */
export const localChat = (data: any) => post('/model-chat/local/chat', data)
/** 预加载本地模型 */
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data)
/** 预加载已训练模型 */
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data)

View File

@@ -0,0 +1,98 @@
import { get, post, put, del } from '../request'
import type { DatasetItem, DatasetVersion, DatasetVersionList } from '@/types'
/** 数据集列表 */
export const getDatasetList = () => get<DatasetItem[]>('/dataset-manage')
/** 数据集详情 */
export const getDataset = (id: string | number) => get<DatasetItem>(`/dataset-manage/${id}`)
/** 创建数据集 */
export const createDataset = (data: Partial<DatasetItem>) =>
post<{ id: string | number }>('/dataset-manage', data)
/** 更新数据集 */
export const updateDataset = (id: string | number, data: Partial<DatasetItem>) =>
put(`/dataset-manage/${id}`, data)
/** 删除数据集 */
export const deleteDataset = (id: string | number) => del(`/dataset-manage/${id}`)
/** 上传数据集文件multipart字段名 files */
export const uploadDatasetFiles = (datasetId: string | number, files: File[]) => {
const formData = new FormData()
files.forEach((f) => formData.append('files', f))
return post(`/dataset-manage/upload/${datasetId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
/** 预览数据集文件内容 */
export const previewDatasetFile = (fileId: string | number) =>
get<{ content: string }>(`/dataset-manage/preview/${encodeURIComponent(fileId)}`)
/** 数据集文件版本列表 */
export const getDatasetFileVersions = (fileId: string | number) =>
get<DatasetVersionList>(`/dataset-manage/versions/${encodeURIComponent(fileId)}`)
/** 读取指定历史版本内容(不改变当前版本) */
export const getDatasetFileVersionContent = (fileId: string | number, versionId: string) =>
get<{ version: DatasetVersion; content: string }>(
`/dataset-manage/versions/${encodeURIComponent(fileId)}/${encodeURIComponent(versionId)}`,
)
/** 保存为新版本,并自动设为当前版本 */
export const createDatasetFileVersion = (
fileId: string | number,
content: string,
options: {
description?: string
baseVersionId: string
expectedCurrentVersionId: string
},
) =>
post<{ version: DatasetVersion; content: string }>(
`/dataset-manage/versions/${encodeURIComponent(fileId)}`,
{
content,
description: options.description || '在线编辑',
base_version_id: options.baseVersionId,
expected_current_version_id: options.expectedCurrentVersionId,
},
)
/** 切换当前使用的数据集版本 */
export const activateDatasetFileVersion = (
fileId: string | number,
versionId: string,
expectedCurrentVersionId: string,
) =>
put<{ version: DatasetVersion; content: string }>(
`/dataset-manage/versions/${encodeURIComponent(fileId)}/active`,
{ version_id: versionId, expected_current_version_id: expectedCurrentVersionId },
)
/** 删除非当前、非初始的历史版本 */
export const deleteDatasetFileVersion = (
fileId: string | number,
versionId: string,
expectedCurrentVersionId: string,
) =>
del<DatasetVersionList>(
`/dataset-manage/versions/${encodeURIComponent(fileId)}/${encodeURIComponent(versionId)}`,
{ expected_current_version_id: expectedCurrentVersionId },
)
/** 下载文件 URL */
export const downloadFileUrl = (
datasetId: string | number,
fileId: string | number,
versionId?: string,
) => {
const baseUrl = `/api/dataset-manage/download/${datasetId}/${fileId}`
return versionId ? `${baseUrl}?version_id=${encodeURIComponent(versionId)}` : baseUrl
}
/** 打包下载数据集 URL */
export const downloadDatasetUrl = (datasetId: string | number) =>
`/api/dataset-manage/download/${datasetId}`

View File

@@ -0,0 +1,31 @@
import { get, post, put, del } from '../request'
import type { EvalTask, EvalTaskDetail, Dimension, StartEvalPayload } from '@/types'
/** 评测任务列表 */
export const getEvalList = () => get<EvalTask[]>('/model-eval')
/** 评测任务详情 */
export const getEvalDetail = (id: string | number) => get<EvalTaskDetail>(`/model-eval/${id}`)
/** 删除评测任务 */
export const deleteEval = (id: string | number) => del(`/model-eval/${id}`)
/** 启动评测 */
export const startEval = (data: StartEvalPayload) => post('/model-eval/start', data)
/** 评测维度列表 */
export const getDimensionList = () => get<Dimension[]>('/dimension')
/** 评测维度详情 */
export const getDimension = (id: string | number) => get<Dimension>(`/dimension/${id}`)
/** 创建维度 */
export const createDimension = (data: Partial<Dimension>) =>
post<{ id: string | number }>('/dimension', data)
/** 编辑维度 */
export const updateDimension = (id: string | number, data: Partial<Dimension>) =>
put(`/dimension/${id}`, data)
/** 删除维度 */
export const deleteDimension = (id: string | number) => del(`/dimension/${id}`)

View File

@@ -0,0 +1,40 @@
import { get, post, put, del } from '../request'
import type { FineTuneStartPayload, FineTuneTask, TrainingProgress } from '@/types'
/** 训练任务列表 */
export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune')
/** 训练任务详情 */
export const getFineTune = (id: string | number) => get<FineTuneTask>(`/fine-tune/${id}`)
/** 任务名查重 */
export const checkFineTuneName = (name: string) =>
get<{ exists: boolean }>('/fine-tune/check-name', { name })
/** 创建训练任务记录(第一步) */
export const createFineTune = (data: Partial<FineTuneTask>) =>
post<{ id: string | number }>('/fine-tune', data)
/** 启动训练(第二步) */
export const startFineTune = (data: FineTuneStartPayload) => post('/fine-tune/start', data)
/** 更新训练任务 */
export const updateFineTune = (id: string | number, data: Partial<FineTuneTask>) =>
put(`/fine-tune/${id}`, data)
/** 停止训练任务 */
export const stopFineTune = (id: string | number) => post(`/fine-tune/stop/${id}`)
/** 删除训练任务 */
export const deleteFineTune = (id: string | number) => del(`/fine-tune/${id}`)
/** 获取训练进度 */
export const getFineTuneProgress = (id: string | number) =>
get<TrainingProgress>(`/fine-tune/progress/${id}`)
/** 启动 TensorBoard */
export const startTensorboard = () => post('/fine-tune/tensorboard/start')
/** 提交 Web 日志 */
export const sendWebLog = (level: string, message: string, page: string) =>
post('/web-log', { level, message, page, timestamp: new Date().toISOString() })

View File

@@ -0,0 +1,18 @@
import { get } from '../request'
import type { LogFile, LogContent, TrainingLogFile } from '@/types'
/** 按日期获取系统日志文件列表 */
export const getLogFiles = (date: string) =>
get<LogFile[]>('/log-files', { date })
/** 获取系统日志内容 */
export const getLogContent = (file: string) =>
get<LogContent>('/log-content', { file })
/** 训练日志文件列表 */
export const getTrainingLogFiles = () =>
get<TrainingLogFile[]>('/training-log-files')
/** 训练日志内容 */
export const getTrainingLogContent = (file: string) =>
get<LogContent>('/training-log-content', { file })

View File

@@ -0,0 +1,48 @@
import { get, post, put, del } from '../request'
import type { ModelItem, ModelForm, TrainedModel } from '@/types'
/** 模型列表 */
export const getModelList = () => get<ModelItem[]>('/model-manage')
/** 模型详情 */
export const getModel = (id: string | number) => get<ModelItem>(`/model-manage/${id}`)
/** 按名称查模型 */
export const getModelByName = (name: string) => get<ModelItem>(`/model-manage/name/${name}`)
/** 本地模型路径列表 */
export const getLocalModels = () =>
get<{ models: { path: string; name: string }[] }>('/model-manage/local-models')
/** 已训练模型列表 */
export const getTrainedModels = () =>
get<{ models: TrainedModel[] }>('/model-manage/trained-models')
/** 创建模型 */
export const createModel = (data: ModelForm) => post('/model-manage', data)
/** 编辑模型 */
export const updateModel = (id: string | number, data: ModelForm) =>
put(`/model-manage/${id}`, data)
/** 删除模型 */
export const deleteModel = (id: string | number) => del(`/model-manage/${id}`)
/** 删除已训练模型(合并权重) */
export const deleteTrainedModel = (id: string | number, type: 'merged' | 'lora' = 'merged') =>
del(`/model-manage/trained-models/${id}`, { type })
/** 更新模型用途 */
export const updateModelPurpose = (id: string | number, purpose: string) =>
put(`/model-manage/${id}/purpose`, { purpose })
/** 合并 LoRA 权重 */
export const mergeModel = (data: {
model_name: string
train_method: string
base_model_path: string
}) => post('/model-manage/merge', data)
/** 导出已训练模型权重 */
export const exportModelUrl = (modelName: string) =>
`/api/model-manage/trained-models/${encodeURIComponent(modelName)}/export`

View File

@@ -0,0 +1,36 @@
import { del, get, post, put } from '../request'
import type {
CreateUserPayload,
HealthMetrics,
LoginResponse,
SystemInfo,
SystemUser,
UpdateUserAccessPayload,
} from '@/types'
/** 系统信息CPU/内存/磁盘/GPU/网络/系统) */
export const getSystemInfo = () => get<SystemInfo>('/system-info')
/** 健康指标(顶部栏轻量指标) */
export const getHealth = () => get<HealthMetrics>('/health')
/** 登录 */
export const login = (username: string, password: string) =>
post<LoginResponse>('/login', { username, password })
/** 用户列表 */
export const getUsers = () => get<SystemUser[]>('/users')
/** 创建用户 */
export const createUser = (payload: CreateUserPayload) =>
post<SystemUser>('/users', payload)
/** 删除用户currentUsername 用于防止删除当前登录账号 */
export const deleteUser = (id: string, currentUsername: string) =>
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`, {
current_username: currentUsername,
})
/** 更新用户角色、状态及页面权限 */
export const updateUserAccess = (id: string, payload: UpdateUserAccessPayload) =>
put<SystemUser>(`/users/${encodeURIComponent(id)}`, payload)

View File

@@ -0,0 +1,73 @@
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
/**
* 后端统一响应格式
* code === 0 表示成功data 为业务数据
*/
export interface ApiResult<T = any> {
code: number
message?: string
data: T
}
const service: AxiosInstance = axios.create({
// 统一走相对路径,由 Vite 代理转发到 http://localhost:7861
baseURL: '/api',
timeout: 30000,
})
// 安装 mock adapter拦截所有 axios 请求返回 mock 数据,方便前端独立开发调试)
import { installMockAdapter } from '@/mock/adapter'
installMockAdapter(service)
// 请求拦截器
service.interceptors.request.use(
(config) => config,
(error) => Promise.reject(error),
)
// 响应拦截器:统一解包 { code, data, message }
service.interceptors.response.use(
(response) => {
const res = response.data as ApiResult
// 二进制流等非 JSON 响应直接返回
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
return response
}
if (res.code === 0) {
return res.data
}
// 业务错误
const message = res.message || '请求失败'
ElMessage.error(message)
return Promise.reject(new Error(message))
},
(error) => {
const message = error.response?.data?.message || error.message || '网络异常'
ElMessage.error(message)
return Promise.reject(error)
},
)
/** GET 请求,返回已解包的 data */
export function get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, { params, ...config }) as unknown as Promise<T>
}
/** POST 请求 */
export function post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config) as unknown as Promise<T>
}
/** PUT 请求 */
export function put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config) as unknown as Promise<T>
}
/** DELETE 请求 */
export function del<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, { params, ...config }) as unknown as Promise<T>
}
export default service

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,366 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, reactive, ref, useId, watch } from 'vue'
type ConfirmDialogTone = 'primary' | 'warning' | 'danger'
interface ConfirmDialogOptions {
title: string
message: string
confirmText?: string
cancelText?: string
tone?: ConfirmDialogTone
closeOnOverlay?: boolean
}
const visible = ref(false)
const dialogRef = ref<HTMLElement>()
const cancelButtonRef = ref<HTMLButtonElement>()
const titleId = `app-confirm-title-${useId()}`
const messageId = `app-confirm-message-${useId()}`
const options = reactive<Required<ConfirmDialogOptions>>({
title: '请确认操作',
message: '',
confirmText: '确定',
cancelText: '取消',
tone: 'warning',
closeOnOverlay: false,
})
let resolver: ((confirmed: boolean) => void) | null = null
let previousFocus: HTMLElement | null = null
function settle(confirmed: boolean) {
visible.value = false
const currentResolver = resolver
resolver = null
currentResolver?.(confirmed)
}
function open(nextOptions: ConfirmDialogOptions) {
if (resolver) settle(false)
Object.assign(options, {
confirmText: '确定',
cancelText: '取消',
tone: 'warning',
closeOnOverlay: false,
...nextOptions,
})
visible.value = true
return new Promise<boolean>((resolve) => {
resolver = resolve
})
}
function handleOverlayClick() {
if (options.closeOnOverlay) settle(false)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
settle(false)
return
}
if (event.key !== 'Tab') return
const focusableElements = Array.from(
dialogRef.value?.querySelectorAll<HTMLElement>('button:not([disabled])') ?? [],
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (!firstElement || !lastElement) return
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
watch(visible, async (isVisible) => {
if (isVisible) {
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
cancelButtonRef.value?.focus()
return
}
await nextTick()
previousFocus?.focus()
previousFocus = null
})
onBeforeUnmount(() => {
resolver?.(false)
resolver = null
})
defineExpose({ open })
</script>
<template>
<Teleport to="body">
<Transition name="app-confirm">
<div v-if="visible" class="app-confirm-overlay" @mousedown.self="handleOverlayClick">
<section
ref="dialogRef"
class="app-confirm-dialog"
:class="`is-${options.tone}`"
role="alertdialog"
:aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="messageId"
@keydown="handleKeydown"
>
<header class="app-confirm-header">
<div class="app-confirm-heading">
<span class="app-confirm-icon" aria-hidden="true">
<i :class="options.tone === 'primary' ? 'fa fa-question-circle' : 'fa fa-exclamation-triangle'" />
</span>
<h2 :id="titleId">{{ options.title }}</h2>
</div>
<button class="app-confirm-close" type="button" aria-label="关闭确认弹窗" @click="settle(false)">
<i class="fa fa-times" aria-hidden="true" />
</button>
</header>
<div class="app-confirm-body">
<p :id="messageId">{{ options.message }}</p>
</div>
<footer class="app-confirm-actions">
<button ref="cancelButtonRef" class="app-confirm-button is-cancel" type="button" @click="settle(false)">
{{ options.cancelText }}
</button>
<button class="app-confirm-button is-confirm" type="button" @click="settle(true)">
{{ options.confirmText }}
</button>
</footer>
</section>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
.app-confirm-overlay {
position: fixed;
z-index: 2000;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
box-sizing: border-box;
background: rgba(15, 23, 42, 0.44);
}
.app-confirm-dialog {
position: relative;
width: min(480px, 100%);
overflow: hidden;
background: #fff;
border: 1px solid #dfe3ea;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
}
.app-confirm-header {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 10px 0 20px;
border-bottom: 1px solid #e7eaf0;
}
.app-confirm-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
h2 {
margin: 0;
overflow: hidden;
color: #273142;
font-size: 15px;
font-weight: 650;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.app-confirm-close {
display: inline-grid;
width: 32px;
height: 32px;
flex: 0 0 32px;
place-items: center;
padding: 0;
color: #7b8495;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
transition: color 0.18s ease, background-color 0.18s ease;
&:hover {
color: #273142;
background: #f2f4f7;
}
&:focus-visible {
outline: 2px solid rgba(91, 80, 242, 0.45);
outline-offset: 1px;
}
}
.app-confirm-icon {
display: inline-grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
place-items: center;
color: #a15c07;
background: #fff8e6;
border: 1px solid #f3dfad;
border-radius: 6px;
font-size: 13px;
}
.app-confirm-dialog.is-danger .app-confirm-icon {
color: #c43232;
background: #fff1f1;
border-color: #f2c7c7;
}
.app-confirm-dialog.is-primary .app-confirm-icon {
color: #4f46e5;
background: #f3f2ff;
border-color: #d9d6ff;
}
.app-confirm-body {
padding: 16px 20px 18px;
p {
margin: 0;
color: #5f6878;
font-size: 13px;
line-height: 1.7;
}
}
.app-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
background: #f8f9fb;
border-top: 1px solid #e7eaf0;
}
.app-confirm-button {
height: 34px;
min-width: 72px;
padding: 0 13px;
color: #344054;
font-size: 13px;
font-weight: 500;
background: #fff;
border: 1px solid #cfd5df;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease;
&:hover {
color: #273142;
background: #f2f4f7;
border-color: #b9c1cd;
}
&:focus-visible {
outline: 2px solid rgba(91, 80, 242, 0.45);
outline-offset: 1px;
}
&.is-confirm {
color: #fff;
background: #a15c07;
border-color: #a15c07;
&:hover {
background: #844b06;
border-color: #844b06;
}
}
}
.app-confirm-dialog.is-danger .app-confirm-button.is-confirm {
background: #c43232;
border-color: #c43232;
&:hover {
background: #a92828;
border-color: #a92828;
}
}
.app-confirm-dialog.is-primary .app-confirm-button.is-confirm {
background: #4f46e5;
border-color: #4f46e5;
&:hover {
background: #4338ca;
border-color: #4338ca;
}
}
.app-confirm-enter-active,
.app-confirm-leave-active {
transition: opacity 0.18s ease;
.app-confirm-dialog {
transition: opacity 0.18s ease, transform 0.18s ease;
}
}
.app-confirm-enter-from,
.app-confirm-leave-to {
opacity: 0;
.app-confirm-dialog {
opacity: 0;
transform: translateY(4px);
}
}
@media (max-width: 520px) {
.app-confirm-overlay {
padding: 12px;
}
.app-confirm-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.app-confirm-button {
height: auto;
min-width: 0;
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.app-confirm-enter-active,
.app-confirm-leave-active,
.app-confirm-enter-active .app-confirm-dialog,
.app-confirm-leave-active .app-confirm-dialog {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '@/stores/system'
import { useRoute, useRouter } from 'vue-router'
const systemStore = useSystemStore()
const { metrics } = storeToRefs(systemStore)
const route = useRoute()
const router = useRouter()
const showBackButton = computed(() => {
return route.path.split('/').filter(Boolean).length > 1
})
function usageClass(val?: number) {
return val != null && val > 80 ? 'is-danger' : ''
}
const currentTime = ref('')
const currentDate = ref('')
let timer: ReturnType<typeof setInterval> | null = null
function updateTime() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
currentDate.value = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
}
const serverIp = ref(window.location.hostname)
onMounted(() => {
updateTime()
timer = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<header class="app-header">
<div class="header-left">
<el-button
v-if="showBackButton"
class="back-btn"
link
@click="router.back()"
>
<div class="back-icon-wrap">
<i class="fa fa-arrow-left" />
</div>
<span class="back-text">返回上一页</span>
</el-button>
</div>
<div class="header-right">
<!-- 系统监控指标 -->
<div class="metrics-dashboard" @click="router.push('/hardware')">
<el-tooltip content="CPU 使用率" placement="bottom">
<div class="metric-badge">
<i class="fa fa-microchip icon cpu" />
<span class="value" :class="usageClass(metrics.cpu_percent)">
{{ metrics.cpu_percent ?? '--' }}<small>%</small>
</span>
</div>
</el-tooltip>
<el-tooltip content="内存 使用率" placement="bottom">
<div class="metric-badge">
<i class="fa fa-database icon mem" />
<span class="value" :class="usageClass(metrics.memory_percent)">
{{ metrics.memory_percent ?? '--' }}<small>%</small>
</span>
</div>
</el-tooltip>
<el-tooltip content="磁盘 使用率" placement="bottom">
<div class="metric-badge">
<i class="fa fa-hdd-o icon disk" />
<span class="value" :class="usageClass(metrics.disk_percent)">
{{ metrics.disk_percent ?? '--' }}<small>%</small>
</span>
</div>
</el-tooltip>
</div>
<div class="divider"></div>
<!-- 时间日期与服务器IP -->
<div class="system-info">
<div class="info-item">
<i class="fa fa-server" />
<span>{{ serverIp }}</span>
</div>
<div class="info-item">
<i class="fa fa-calendar" />
<span>{{ currentDate }}</span>
</div>
<div class="info-item time-item">
<i class="fa fa-clock-o" />
<span>{{ currentTime }}</span>
</div>
</div>
</div>
</header>
</template>
<style scoped lang="scss">
.app-header {
height: var(--header-height);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.02);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 50;
}
.header-left {
.back-btn {
display: flex;
align-items: center;
color: #64748b;
font-weight: 500;
transition: all 0.2s;
.back-icon-wrap {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
border-radius: 8px;
margin-right: 8px;
transition: all 0.2s;
}
&:hover {
color: var(--primary-color);
.back-icon-wrap {
background: rgba(79, 70, 229, 0.1);
color: var(--primary-color);
}
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.metrics-dashboard {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 4px 6px;
border-radius: 12px;
transition: background 0.2s;
&:hover {
background: #f8fafc;
}
.metric-badge {
display: flex;
align-items: center;
background: #f1f5f9;
padding: 4px 10px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #475569;
border: 1px solid transparent;
transition: all 0.2s;
&:hover {
background: #fff;
border-color: #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.icon {
margin-right: 6px;
font-size: 14px;
&.cpu { color: var(--primary-color); }
&.mem { color: var(--success-color); }
&.disk { color: var(--warning-color); }
}
.value {
small {
font-size: 10px;
color: #94a3b8;
margin-left: 2px;
}
&.is-danger {
color: var(--danger-color);
animation: pulse 2s infinite;
}
}
}
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.divider {
width: 1px;
height: 24px;
background-color: #e2e8f0;
}
.system-info {
display: flex;
align-items: center;
gap: 16px;
color: #64748b;
font-size: 13px;
font-weight: 500;
background: #f8fafc;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #f1f5f9;
.info-item {
display: flex;
align-items: center;
gap: 6px;
i {
color: #94a3b8;
}
}
.time-item {
color: #475569;
font-weight: 600;
min-width: 80px;
font-variant-numeric: tabular-nums;
i {
color: var(--primary-color);
}
}
}
</style>

View File

@@ -0,0 +1,329 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import type { PermissionCode } from '@/types'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
/** 当前激活菜单 key取路由路径第一段如 /fine-tune/create → fine-tune */
const activeMenu = computed(() => {
const seg = route.path.split('/')[1] || 'dashboard'
// 训练日志归到模型调优
if (seg === 'training-log') return 'fine-tune'
// 维度管理归到模型评测
if (route.path.includes('model-eval/dimension')) return 'model-eval'
// 对比对话归到模型推理
if (route.path.startsWith('/model-compare/chat')) return 'model-inference'
// 合并权重归到模型管理
if (route.path === '/model-manage/merge') return 'model-manage'
// 模型管理编辑
if (route.path.match(/^\/model-manage\/\d+\/edit/)) return 'model-manage'
// 数据集编辑/预览
if (route.path.match(/^\/dataset\/(\d+)/)) return 'dataset'
// 工具编辑
if (route.path.match(/^\/tools\/[^/]+\/edit/)) return 'tools'
return seg
})
interface MenuItem {
key: string
label: string
icon: string
to: string
permission: PermissionCode
}
interface MenuGroup {
title?: string
showTitle?: boolean
items: MenuItem[]
}
const menuGroups: MenuGroup[] = [
{
// 服务看板:独立入口,不显示分类小标题
showTitle: false,
items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard', permission: 'dashboard' }],
},
{
title: '模型服务',
items: [
{ key: 'fine-tune', label: '模型训练', icon: 'fa-cogs', to: '/fine-tune', permission: 'fine-tune' },
{ key: 'model-eval', label: '模型评测', icon: 'fa-line-chart', to: '/model-eval', permission: 'model-eval' },
{ key: 'model-inference', label: '模型推理', icon: 'fa-server', to: '/model-inference', permission: 'model-inference' },
{ key: 'model-manage', label: '模型管理', icon: 'fa-cube', to: '/model-manage', permission: 'model-manage' },
],
},
{
title: '数据治理',
items: [
{ key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset', permission: 'dataset' },
{ key: 'data-process', label: '数据处理', icon: 'fa-filter', to: '/data-process', permission: 'data-process' },
],
},
{
title: '其他工具',
items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert', permission: 'data-convert' }],
},
{
title: '系统设置',
items: [
{ key: 'user-settings', label: '用户设置', icon: 'fa-users', to: '/user-settings', permission: 'user-settings' },
{ key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware', permission: 'hardware' },
{ key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs', permission: 'logs' },
],
},
]
const visibleMenuGroups = computed(() =>
menuGroups
.map((group) => ({
...group,
items: group.items.filter((item) => auth.hasPermission(item.permission)),
}))
.filter((group) => group.items.length > 0),
)
/**
* 传给 el-menu 的激活值。
*
* el-menu 在点击菜单项时会立即把内部高亮移到被点击项,再触发 @select。
* 若随后 router.push 被路由守卫拦截如未保存草稿确认route.path 不变,
* activeMenu 也不会变化,导致 el-menu 无法把高亮校正回当前路由对应的项。
* 这里用一个可写 ref 包裹,导航失败时主动触发一次「置空 → 还原」,
* 强制 el-menu 重新对齐到真实激活项。
*/
const menuActive = ref(activeMenu.value)
watch(activeMenu, (value) => {
menuActive.value = value
})
async function handleSelect(key: string) {
const all = visibleMenuGroups.value.flatMap((g) => g.items)
const target = all.find((i) => i.key === key)
if (!target) return
const failure = await router.push(target.to)
// 导航被守卫取消(如未保存修改确认)时,把高亮校正回当前路由
if (failure) {
menuActive.value = ''
await nextTick()
menuActive.value = activeMenu.value
}
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<aside class="app-sidebar">
<!-- 平台 LOGO -->
<div class="sidebar-logo">
<img src="/logo.png" alt="Logo" class="sidebar-logo-img" />
<span class="logo-text">远光软件微调平台</span>
</div>
<!-- 导航 -->
<el-scrollbar class="sidebar-nav" wrap-class="nav-scrollbar-wrap">
<el-menu
:default-active="menuActive"
class="sidebar-menu"
background-color="transparent"
text-color="var(--sidebar-text)"
active-text-color="var(--sidebar-active-text)"
@select="handleSelect"
>
<template v-for="group in visibleMenuGroups" :key="group.title || group.items[0].key">
<div v-if="group.title" class="sidebar-section-title">{{ group.title }}</div>
<el-menu-item
v-for="item in group.items"
:key="item.key"
:index="item.key"
>
<i class="fa" :class="item.icon" />
<span>{{ item.label }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
<!-- 底部信息 -->
<div class="sidebar-footer">
<div class="footer-user" v-if="auth.username">
<el-avatar :size="36" src="https://picsum.photos/id/1005/36/36" class="user-avatar" />
<div class="user-info">
<span class="user-name">{{ auth.displayName }}</span>
<span class="user-role">{{ auth.roleLabel }}</span>
</div>
<el-tooltip content="退出登录" placement="top">
<div class="logout-btn" @click="handleLogout">
<i class="fa fa-sign-out" />
</div>
</el-tooltip>
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
.app-sidebar {
width: 240px;
flex-shrink: 0;
height: 100vh;
background-color: var(--sidebar-bg);
display: flex;
flex-direction: column;
color: var(--sidebar-text);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.02);
z-index: 100;
border-right: 1px solid #e2e8f0;
}
.sidebar-logo {
display: flex;
align-items: center;
padding: 24px 20px;
margin-bottom: 8px;
.sidebar-logo-img {
width: 36px;
height: 36px;
object-fit: contain;
margin-right: 12px;
}
.logo-text {
color: #334155;
font-weight: 600;
font-size: 16px;
letter-spacing: 0.5px;
white-space: nowrap;
}
}
.sidebar-nav {
flex: 1;
min-height: 0;
padding: 0 12px;
}
:deep(.nav-scrollbar-wrap) {
overflow-x: hidden;
}
.sidebar-menu {
border-right: none;
background: transparent;
:deep(.el-menu-item) {
height: 46px;
line-height: 46px;
margin-bottom: 4px;
border-radius: 8px;
padding: 0 16px !important;
transition: all 0.3s ease;
.fa {
width: 20px;
text-align: center;
margin-right: 10px;
font-size: 16px;
transition: transform 0.2s;
}
&:hover {
background-color: rgba(0, 0, 0, 0.03) !important;
color: var(--sidebar-active-text) !important;
.fa {
transform: scale(1.1);
}
}
&.is-active {
background-color: var(--sidebar-active-bg) !important;
color: var(--sidebar-active-text) !important;
font-weight: 600;
.fa {
color: inherit;
}
}
}
}
.sidebar-section-title {
padding: 16px 16px 8px;
font-size: 11px;
color: #64748b;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.sidebar-footer {
padding: 20px;
background: transparent;
border-top: 1px solid #e2e8f0;
.footer-user {
display: flex;
align-items: center;
background: #ffffff;
padding: 10px;
border-radius: 12px;
border: 1px solid #e2e8f0;
.user-avatar {
border: 2px solid #e2e8f0;
}
.user-info {
flex: 1;
margin-left: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
.user-name {
color: #334155;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.user-role {
color: #64748b;
font-size: 11px;
margin-top: 2px;
}
}
.logout-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
}
}
}
}
</style>

View File

@@ -0,0 +1,380 @@
<script setup lang="ts" generic="T extends Record<string, any>">
import { ref, computed, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import type { TableInstance } from 'element-plus'
import { useRouter } from 'vue-router'
/**
* 通用列表页组件
* 封装原 renderTablePage 的能力:标题、搜索、创建按钮、多选、批量删除、操作列
*/
const props = withDefaults(
defineProps<{
title: string
data: T[]
loading?: boolean
/** 是否显示搜索框 */
searchable?: boolean
/** 搜索字段(默认取数据第一列) */
searchFields?: string[]
/** 是否支持多选 */
multiSelect?: boolean
/** 创建按钮文本,不传则不显示 */
createText?: string
/** 创建按钮跳转路径 */
createTo?: string
/** 删除函数(用于批量删除),返回 boolean 表示成功 */
deleteFn?: (row: any) => Promise<any>
/** 行 key 字段名 */
rowKey?: string
/** 空数据提示 */
emptyText?: string
/** 默认每页条数 */
pageSize?: number
/** 可选每页条数 */
pageSizes?: number[]
}>(),
{
loading: false,
searchable: false,
multiSelect: false,
rowKey: 'id',
emptyText: '暂无数据',
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
},
)
const emit = defineEmits<{
create: []
refresh: []
}>()
const router = useRouter()
const tableRef = ref<TableInstance>()
const selectedIds = ref<Set<string | number>>(new Set())
const keyword = ref('')
const currentPage = ref(1)
const currentPageSize = ref(props.pageSize)
/** 行唯一标识取值 */
function rowId(row: T): string | number {
return row[props.rowKey] ?? row['name'] ?? row['id']
}
/** 过滤后的数据 */
const filteredData = computed(() => {
if (!keyword.value.trim() || !props.searchFields?.length) return props.data
const kw = keyword.value.toLowerCase().trim()
return props.data.filter((row) =>
props.searchFields!.some((f) => String(row[f] ?? '').toLowerCase().includes(kw)),
)
})
/** 当前页数据 */
const pagedData = computed(() => {
const start = (currentPage.value - 1) * currentPageSize.value
return filteredData.value.slice(start, start + currentPageSize.value)
})
/** 处理多选变化 */
function handleSelectionChange(rows: T[]) {
selectedIds.value = new Set(rows.map(rowId))
}
/** 单个删除(子组件或操作列调用) */
async function handleDelete(row: any, confirmText = '确定要删除这条记录吗?') {
if (!props.deleteFn) return
try {
await ElMessageBox.confirm(confirmText, '确认删除', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
await props.deleteFn(row)
emit('refresh')
} catch {
// 用户取消或删除失败
}
}
/** 批量删除 */
async function handleBatchDelete() {
if (selectedIds.value.size === 0 || !props.deleteFn) return
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedIds.value.size} 条记录吗?`,
'批量删除',
{ type: 'warning' },
)
const rows = props.data.filter((row) => selectedIds.value.has(rowId(row)))
let ok = 0
let fail = 0
for (const row of rows) {
try {
await props.deleteFn(row)
ok++
} catch {
fail++
}
}
selectedIds.value.clear()
emit('refresh')
if (fail === 0) {
ElMessageBox.alert(`成功删除 ${ok} 条记录`, '成功', { type: 'success' })
} else {
ElMessageBox.alert(`成功 ${ok} 条,失败 ${fail}`, '部分失败', { type: 'warning' })
}
} catch {
// 取消
}
}
function clearSelection() {
selectedIds.value.clear()
tableRef.value?.clearSelection()
}
function handleCreate() {
if (props.createTo) {
router.push(props.createTo)
} else {
emit('create')
}
}
// 数据变化时清空选中
watch(
() => props.data,
() => {
selectedIds.value.clear()
currentPage.value = 1
tableRef.value?.clearSelection()
},
)
watch(
() => keyword.value,
() => {
currentPage.value = 1
},
)
defineExpose({ handleDelete, clearSelection })
</script>
<template>
<div class="data-table-page">
<el-card shadow="never" class="table-card">
<!-- 工具栏 -->
<div class="table-toolbar">
<slot name="title">
<h2 class="table-title">{{ title }}</h2>
</slot>
<div class="toolbar-actions">
<el-input
v-if="searchable"
v-model="keyword"
:placeholder="`搜索${title}...`"
clearable
class="search-input"
>
<template #prefix><i class="fa fa-search" /></template>
</el-input>
<el-button v-if="createText" type="primary" @click="handleCreate">
<i class="fa fa-plus" style="margin-right: 4px" />
{{ createText }}
</el-button>
<slot name="toolbar-extra" />
</div>
</div>
<!-- 批量操作栏 -->
<transition name="el-zoom-in-top">
<div v-if="multiSelect && selectedIds.size > 0" class="batch-bar">
<span class="batch-info">
已选择 <strong>{{ selectedIds.size }}</strong>
<el-button link type="primary" @click="clearSelection">取消选择</el-button>
</span>
<el-button type="danger" size="small" @click="handleBatchDelete">
<i class="fa fa-trash" style="margin-right: 4px" />
批量删除 ({{ selectedIds.size }})
</el-button>
</div>
</transition>
<!-- 表格 -->
<div class="table-body">
<el-table
ref="tableRef"
:data="pagedData"
v-loading="loading"
:row-key="rowKey as any"
height="100%"
style="width: 100%"
:empty-text="emptyText"
@selection-change="handleSelectionChange"
>
<el-table-column
v-if="multiSelect"
type="selection"
width="50"
align="center"
reserve-selection
fixed="left"
/>
<slot name="columns" />
<el-table-column
v-if="$slots.actions"
label="操作"
align="center"
width="220"
fixed="right"
>
<template #default="{ row }">
<slot name="actions" :row="row" :handleDelete="handleDelete" />
</template>
</el-table-column>
</el-table>
</div>
<div class="table-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
background
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="pageSizes"
:total="filteredData.length"
/>
</div>
</el-card>
</div>
</template>
<style scoped lang="scss">
.data-table-page {
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
.table-card {
margin-bottom: 0;
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 12px;
flex-wrap: wrap;
gap: 12px;
}
.table-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin: 0;
letter-spacing: -0.3px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.search-input {
width: 280px;
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #e2e8f0 inset;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset;
}
}
}
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(79, 70, 229, 0.05);
border: 1px solid rgba(79, 70, 229, 0.1);
border-radius: 8px;
padding: 10px 16px;
margin: 0 24px 16px;
.batch-info {
font-size: 13px;
color: var(--primary-color);
strong {
font-weight: 600;
margin: 0 4px;
}
}
}
.table-body {
flex: 1;
min-height: 0;
}
.table-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 24px;
border-top: 1px solid #f1f5f9;
:deep(.el-pagination) {
margin-top: 0;
}
}
:deep(.el-card__body) {
display: flex;
flex-direction: column;
padding: 12px 0 0;
flex: 1;
min-height: 0;
}
:deep(.el-table__inner-wrapper::before) {
display: none;
}
/* 表格单元格统一样式(各列表页共用,与模型调优保持一致) */
:deep(.el-table td.el-table__cell) {
padding: 18px 0 !important;
}
:deep(.el-table__cell .cell) {
line-height: 22px;
white-space: nowrap;
}
/* 操作列按钮组统一样式(各列表页共用) */
:deep(.el-table) .action-buttons {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
gap: 8px;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const props = defineProps<{
content: string
}>()
const html = computed(() => {
if (!props.content) return ''
try {
const raw = marked.parse(props.content, { async: false }) as string
return DOMPurify.sanitize(raw)
} catch {
return props.content
}
})
</script>
<template>
<div class="markdown-view" v-html="html" />
</template>
<style lang="scss">
.markdown-view {
line-height: 1.7;
word-break: break-word;
p {
margin: 0 0 8px;
}
h1,
h2,
h3,
h4 {
margin: 16px 0 8px;
font-weight: 600;
}
ul,
ol {
padding-left: 24px;
margin: 0 0 8px;
}
code {
background: rgba(0, 0, 0, 0.06);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.9em;
}
pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 0 0 8px;
code {
background: none;
padding: 0;
color: inherit;
}
}
table {
border-collapse: collapse;
margin: 0 0 8px;
th,
td {
border: 1px solid #dcdfe6;
padding: 6px 12px;
}
}
blockquote {
border-left: 4px solid #dcdfe6;
padding-left: 12px;
color: #909399;
margin: 0 0 8px;
}
a {
color: #1890ff;
}
}
</style>

View File

@@ -0,0 +1,506 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ModelItem } from '@/types'
const props = defineProps<{
modelValue: boolean
models: ModelItem[]
/** 当前已选模型 id打开弹窗时用于初始化草稿与 Tab */
currentModelId: string | number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', modelId: string | number): void
}>()
const activeModelTab = ref<'official' | 'mine'>('official')
const activeModelSeries = ref('')
const draftModelId = ref<string | number>('')
const modelSearchKeyword = ref('')
/** 根据模型名提取系列名(去掉参数量与日期后缀) */
function getModelSeries(name: string): string {
return name
.replace(/-\d+(?:\.\d+)?B.*$/i, '')
.replace(/-\d+(?:\.\d+)?b.*$/i, '')
.replace(/-\d{4}.*$/, '')
}
const dialogModels = computed(() => {
const keyword = modelSearchKeyword.value.trim().toLowerCase()
const tabModels = props.models.filter((model) => {
if (activeModelTab.value === 'official') return model.model_source !== 'api'
return model.model_source === 'api'
})
if (!keyword) return tabModels
return tabModels.filter((model) => {
return [model.name, model.description, model.path, model.online_model_name]
.filter(Boolean)
.some((text) => String(text).toLowerCase().includes(keyword))
})
})
const modelSeriesGroups = computed(() => {
const groupMap = new Map<string, ModelItem[]>()
dialogModels.value.forEach((model) => {
const series = getModelSeries(model.name)
const list = groupMap.get(series) || []
list.push(model)
groupMap.set(series, list)
})
return Array.from(groupMap.entries()).map(([name, list]) => ({
name,
count: list.length,
models: list,
}))
})
const currentSeriesModels = computed(() => {
return modelSeriesGroups.value.find((group) => group.name === activeModelSeries.value)?.models || []
})
const draftModel = computed(() => props.models.find((model) => model.id === draftModelId.value))
function syncActiveModelSeries() {
const firstSeries = modelSeriesGroups.value[0]?.name || ''
if (!activeModelSeries.value || !modelSeriesGroups.value.some((group) => group.name === activeModelSeries.value)) {
activeModelSeries.value = firstSeries
}
}
// 弹窗打开时初始化草稿沿用当前选中、Tab 按来源切换、系列定位到草稿所在组
watch(
() => props.modelValue,
(visible) => {
if (visible) {
const current = props.models.find((model) => model.id === props.currentModelId)
activeModelTab.value = current?.model_source === 'api' ? 'mine' : 'official'
draftModelId.value = props.currentModelId
syncActiveModelSeries()
}
},
)
watch([activeModelTab, modelSearchKeyword], () => {
syncActiveModelSeries()
})
function chooseModelSeries(series: string) {
activeModelSeries.value = series
}
function chooseDraftModel(model: ModelItem) {
draftModelId.value = model.id
}
function handleCancel() {
emit('update:modelValue', false)
}
function handleConfirm() {
if (draftModelId.value !== '' && draftModelId.value !== undefined && draftModelId.value !== null) {
emit('confirm', draftModelId.value)
}
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="model-select-dialog"
width="860px"
top="10vh"
:show-close="false"
append-to-body
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="model-dialog-header">
<div class="model-dialog-title">
<span>选择模型</span>
<em>仅可选 1 个模型</em>
</div>
<button class="model-dialog-close" type="button" @click="handleCancel">
<i class="fa fa-close" />
</button>
</div>
</template>
<div class="model-dialog-body">
<div class="model-dialog-toolbar">
<el-tabs v-model="activeModelTab" class="model-tabs">
<el-tab-pane label="官方模型" name="official" />
<el-tab-pane label="我的模型" name="mine" />
</el-tabs>
<el-input
v-model="modelSearchKeyword"
class="model-search"
placeholder="从全部模型中搜索"
clearable
>
<template #suffix>
<i class="fa fa-search" />
</template>
</el-input>
</div>
<div class="model-dialog-content">
<aside class="model-series-list">
<div class="column-title">模型系列</div>
<button
v-for="series in modelSeriesGroups"
:key="series.name"
class="model-series-item"
:class="{ active: activeModelSeries === series.name }"
type="button"
@click="chooseModelSeries(series.name)"
>
<i class="fa fa-star-o" />
<span>{{ series.name }}</span>
<em>{{ series.count }}</em>
</button>
<el-empty v-if="!modelSeriesGroups.length" description="暂无模型" :image-size="80" />
</aside>
<section class="model-version-list">
<div class="column-title">快照版本</div>
<button
v-for="model in currentSeriesModels"
:key="model.id"
class="model-version-item"
:class="{ active: draftModelId === model.id }"
type="button"
@click="chooseDraftModel(model)"
>
<div class="model-version-main">
<strong>{{ model.name }}</strong>
<span>{{ model.description || model.path || model.online_model_name || '暂无模型描述' }}</span>
</div>
<span class="model-radio-dot" />
</button>
<el-empty v-if="!currentSeriesModels.length" description="暂无快照版本" :image-size="80" />
</section>
</div>
</div>
<template #footer>
<div class="model-dialog-footer">
<div class="selected-model-summary">
<span>已选 {{ draftModel ? 1 : 0 }}/1</span>
<div v-if="draftModel" class="selected-model-pill">
<i class="fa fa-star-o" />
{{ draftModel.name }}
</div>
</div>
<div class="model-dialog-actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
:deep(.model-select-dialog) {
max-width: calc(100vw - 48px);
border-radius: 6px;
box-shadow: 0 18px 46px rgba(15, 23, 42, 0.18);
.el-dialog__header {
padding: 0;
margin: 0;
}
.el-dialog__body {
padding: 0;
}
.el-dialog__footer {
padding: 0;
}
}
.model-dialog-header {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eef0f5;
}
.model-dialog-title {
display: flex;
align-items: baseline;
gap: 12px;
span {
font-size: 16px;
font-weight: 700;
color: #1f2937;
}
em {
font-style: normal;
font-size: 13px;
color: #94a3b8;
}
}
.model-dialog-close {
width: 30px;
height: 30px;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font-size: 16px;
border-radius: 6px;
&:hover {
background: #f5f6fb;
}
}
.model-dialog-body {
height: 452px;
}
.model-dialog-toolbar {
height: 76px;
padding: 14px 20px 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
border-bottom: 1px solid #eef0f5;
}
.model-tabs {
min-width: 210px;
:deep(.el-tabs__item) {
height: 42px;
padding: 0 18px 0 0;
font-size: 14px;
font-weight: 600;
color: #475569;
}
:deep(.el-tabs__active-bar) {
height: 2px;
background: #2563eb;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
}
.model-search {
width: 280px;
:deep(.el-input__wrapper) {
border-radius: 6px;
min-height: 36px;
}
}
.model-dialog-content {
display: grid;
grid-template-columns: 280px 1fr;
height: 376px;
min-height: 0;
}
.model-series-list,
.model-version-list {
padding: 16px 20px;
overflow: auto;
}
.model-series-list {
border-right: 1px solid #eef0f5;
}
.column-title {
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 12px;
}
.model-series-item,
.model-version-item {
width: 100%;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: #1f2937;
&:hover {
border-color: #94a3b8;
background: #f8fafc;
}
&.active {
border-color: #2563eb;
background: #eff6ff;
box-shadow: inset 0 0 0 1px #2563eb;
}
}
.model-series-item {
height: 44px;
padding: 0 12px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
margin-bottom: 8px;
text-align: left;
i {
color: #2563eb;
}
span {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
em {
font-style: normal;
color: #777b92;
}
}
.model-version-item {
min-height: 64px;
padding: 12px 14px;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 14px;
margin-bottom: 8px;
text-align: left;
}
.model-version-main {
display: grid;
gap: 6px;
min-width: 0;
strong {
color: #1f2937;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
color: #64748b;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.model-radio-dot {
width: 16px;
height: 16px;
border: 1px solid #cbd5e1;
border-radius: 50%;
position: relative;
}
.model-version-item.active .model-radio-dot {
border-color: #2563eb;
&::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: #2563eb;
}
}
.model-dialog-footer {
height: 60px;
padding: 0 20px;
border-top: 1px solid #eef0f5;
display: flex;
align-items: center;
justify-content: space-between;
}
.selected-model-summary {
display: flex;
align-items: center;
gap: 10px;
color: #475569;
font-size: 13px;
}
.selected-model-pill {
max-width: 300px;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid #dbeafe;
border-radius: 4px;
background: #eff6ff;
color: #1f2937;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
i {
color: #2563eb;
}
}
.model-dialog-actions {
display: flex;
gap: 10px;
}
@media (max-width: 900px) {
.model-dialog-toolbar,
.model-dialog-footer {
height: auto;
align-items: flex-start;
flex-direction: column;
padding: 18px;
}
.model-search {
width: 100%;
}
.model-dialog-content {
grid-template-columns: 1fr;
}
.model-series-list {
border-right: 0;
border-bottom: 1px solid #eef0f5;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More