54 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
94 changed files with 15299 additions and 2864 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 与权重合并 |
| 数据集 | 数据集管理与预览 |
| 数据处理 | 数据处理任务向导 |
| 工具 | 辅助工具集 |
| 系统 | 硬件监控、日志、训练日志 |

View File

@@ -158,3 +158,272 @@ The `type-check` script now uses project-reference mode (`vue-tsc -b --noEmit`)
Design-QA final result: passed Design-QA final result: passed
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,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,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 数据通过针对性回归检查。

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

View File

@@ -8,9 +8,23 @@
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc -b --noEmit", "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: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: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: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" "test:page-surface": "node scripts/regression-page-surface.mjs"
}, },
"dependencies": { "dependencies": {

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

@@ -4,25 +4,66 @@ import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc' import { parse as parseSfc } from '@vue/compiler-sfc'
import ts from 'typescript'
const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue') const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue')
const createDir = path.resolve(scriptDir, '../src/views/data-process/create') 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 layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue')
const viewSource = await readFile(viewPath, 'utf8') const viewSource = await readFile(viewPath, 'utf8')
const layoutSource = await readFile(layoutPath, '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 = \[/, '向导步骤尚未改为固定常量') assert.match(viewSource, /const WIZARD_STEPS = \[/, '向导步骤尚未改为固定常量')
for (const title of ['创建任务', '数据预览', '开始生成', '结果编辑与保存']) { for (const title of ['创建任务', '大模型选择', '上传文件', '数据预览', '开始生成', '结果编辑与保存']) {
assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`) 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.doesNotMatch(viewSource, /steps\s*=\s*computed|all\.filter/, '步骤仍根据处理类型动态增减')
assert.match(viewSource, /localStorage\.setItem\(DRAFT_STORAGE_KEY/, '草稿没有持久化') assert.match(
assert.match(viewSource, /localStorage\.getItem\(DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取') 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.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
assert.ok(viewSource.split('\n').length < 800, 'DataProcessCreateView 拆分后仍超过 800 行')
const expectedComponents = [ const expectedComponents = [
'TaskSetupStep.vue', 'TaskSetupStep.vue',
'ModelSelectionStep.vue',
'SourceUploadStep.vue',
'PreviewCompareStep.vue', 'PreviewCompareStep.vue',
'GenerationStep.vue', 'GenerationStep.vue',
'ResultEditorStep.vue', 'ResultEditorStep.vue',
@@ -47,14 +88,15 @@ for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedConte
assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`) assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`)
} }
assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识') 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 buildPreviewItems/, '缺少切片来源映射生成函数')
assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数') assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数')
assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识') assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识')
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态') assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
assert.match( assert.match(
viewSource, viewSource,
/buildPreviewItems\(file\.content, processType\.value, String\(file\.uid\)\)/, /buildPreviewItems\([\s\S]*?file\.content,[\s\S]*?processType\.value,[\s\S]*?String\(file\.uid\),[\s\S]*?unstructuredOptions\.value/,
'预览没有按文件分别生成', '预览没有按文件分别生成或未传入非结构化切分配置',
) )
for (const marker of [ for (const marker of [
@@ -66,10 +108,10 @@ for (const marker of [
'preview-editor', 'preview-editor',
'scrollIntoView', 'scrollIntoView',
]) { ]) {
assert.ok(previewSource.includes(marker), `步缺少结构或行为:${marker}`) assert.ok(previewSource.includes(marker), `步缺少结构或行为:${marker}`)
} }
assert.match(previewSource, /sourceStart/, '第步未使用来源起始偏移') assert.match(previewSource, /sourceStart/, '第步未使用来源起始偏移')
assert.match(previewSource, /sourceEnd/, '第步未使用来源结束偏移') assert.match(previewSource, /sourceEnd/, '第步未使用来源结束偏移')
assert.match(previewSource, /filterable/, '文件选择器必须可搜索') assert.match(previewSource, /filterable/, '文件选择器必须可搜索')
assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器') assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器')
assert.doesNotMatch(previewSource, /located-badge|sync-label|已定位到/, '源文件栏不应显示冗余定位提示') assert.doesNotMatch(previewSource, /located-badge|sync-label|已定位到/, '源文件栏不应显示冗余定位提示')
@@ -94,10 +136,445 @@ assert.match(previewSource, />保存修改<\/el-button>/, '编辑器缺少保存
assert.match(previewSource, /\.editor-actions\s*\{[\s\S]*?justify-content:\s*flex-end/, '取消和保存按钮必须在编辑器右侧对齐') assert.match(previewSource, /\.editor-actions\s*\{[\s\S]*?justify-content:\s*flex-end/, '取消和保存按钮必须在编辑器右侧对齐')
assert.doesNotMatch(previewSource, /item-token|item-status|modifiedOnly|仅看已修改/, '切片列表不应再显示 Token 或修改状态') 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, /\.preview-editor\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?overflow-y:\s*auto/, '编辑模式必须占据右侧剩余区域并可滚动')
assert.match(previewSource, /@media \(max-width: 900px\)/, '第步缺少窄屏上下布局') assert.match(previewSource, /@media \(max-width: 900px\)/, '第步缺少窄屏上下布局')
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue') const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
const taskSetupSource = await readFile(taskSetupPath, 'utf8') 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) { function findNextStyleBlockStart(source, startIndex) {
let quote = null let quote = null
@@ -305,38 +782,39 @@ for (const marker of [
'已添加 {{ uploadedFiles.length }} 个文件', '已添加 {{ uploadedFiles.length }} 个文件',
':title="file.name"', ':title="file.name"',
]) { ]) {
assert.ok(taskSetupSource.includes(marker), `源数据文件列表缺少:${marker}`) assert.ok(sourceUploadSource.includes(marker), `源数据文件列表缺少:${marker}`)
} }
assert.match( assert.match(
taskSetupSource, sourceUploadSource,
/^const[ \t]+FILE_PAGE_SIZE[ \t]*=[ \t]*10[ \t]*;?[ \t]*$/m, /^const[ \t]+FILE_PAGE_SIZE[ \t]*=[ \t]*10[ \t]*;?[ \t]*$/m,
'文件分页大小必须固定为整数 10', '文件分页大小必须固定为整数 10',
) )
assert.match( assert.match(
taskSetupSource, 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*\}\)/, /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( assert.match(
taskSetupSource, 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]*?\}\)/, /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 = [...taskSetupSource.matchAll(/<el-pagination\b[\s\S]*?\/>/g)] const filePaginationTags = [...sourceUploadSource.matchAll(/<el-pagination\b[\s\S]*?\/>/g)]
assert.equal(filePaginationTags.length, 1, '文件列表必须只有一个分页器') assert.ok(filePaginationTags.length >= 1, '文件列表必须包含分页器')
const [filePaginationTag] = filePaginationTags for (const [filePaginationTag] of filePaginationTags) {
for (const attribute of [ for (const attribute of [
'v-if="uploadedFiles.length > FILE_PAGE_SIZE"', 'v-if="uploadedFiles.length > FILE_PAGE_SIZE"',
'v-model:current-page="currentFilePage"', 'v-model:current-page="currentFilePage"',
':page-size="FILE_PAGE_SIZE"', ':page-size="FILE_PAGE_SIZE"',
':total="uploadedFiles.length"', ':total="uploadedFiles.length"',
]) { ]) {
assert.ok(filePaginationTag[0].includes(attribute), `文件分页器缺少属性:${attribute}`) assert.ok(filePaginationTag.includes(attribute), `文件分页器缺少属性:${attribute}`)
}
} }
const { descriptor: taskSetupDescriptor } = parseSfc(taskSetupSource, { filename: taskSetupPath }) const { descriptor: sourceUploadDescriptor } = parseSfc(sourceUploadSource, { filename: sourceUploadPath })
const uploadedFileItemsStyles = taskSetupDescriptor.styles const uploadedFileItemsStyles = sourceUploadDescriptor.styles
.flatMap(({ content }) => collectStyleRules(content)) .flatMap(({ content }) => collectStyleRules(content))
.filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector)) .filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector))
@@ -348,59 +826,56 @@ for (const { declarations } of uploadedFileItemsStyles) {
) )
} }
assert.match( assert.match(
taskSetupSource, 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="已上传文件列表">/, /<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="已上传文件列表" 的语义列表容器', '有文件状态缺少带 aria-label="已上传文件列表" 的语义列表容器',
) )
assert.match( assert.match(
taskSetupSource, sourceUploadSource,
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>/, /<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>/,
'文件列表标题结构或文件数量文案缺失', '文件列表标题结构或文件数量文案缺失',
) )
assert.match( assert.match(
taskSetupSource, sourceUploadSource,
/<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">/, /<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">/,
'文件列表缺少分页后的文件行容器', '文件列表缺少分页后的文件行容器',
) )
assert.match( assert.match(
taskSetupSource, 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*>/, /<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( assert.match(
taskSetupSource, 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\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*\}\}/, /<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( assert.match(
taskSetupSource, 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*>\s*<el-button\s+size="small"\s+type="primary">继续上传<\/el-button>\s*<\/el-upload>\s*<\/div>\s*<\/div>/, /<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( assert.match(
taskSetupSource, sourceUploadSource,
/\.uploaded-file-list-header\s*\{[^}]*display:\s*flex[^}]*justify-content:\s*space-between/, /\.uploaded-file-list-header\s*\{[^}]*display:\s*flex[^}]*justify-content:\s*space-between/,
'文件列表标题未布局为右侧继续上传按钮', '文件列表标题未布局为右侧继续上传按钮',
) )
assert.match( assert.match(
taskSetupSource, sourceUploadSource,
/\.continue-upload\s+:deep\(\.el-upload\)\s*\{[^}]*width:\s*auto;?[^}]*margin-top:\s*0;?/, /\.continue-upload\s+:deep\(\.el-upload\)\s*\{[^}]*width:\s*auto;?[^}]*margin-top:\s*0;?/,
'继续上传未覆盖内层上传节点的宽度和顶部间距', '继续上传未覆盖内层上传节点的宽度和顶部间距',
) )
assert.match(taskSetupSource, /\.uploaded-file\s*\{[^}]*min-height:\s*48px/, '文件行没有保持 48px 最小高度') assert.match(sourceUploadSource, /\.uploaded-file\s*\{[^}]*min-height:\s*48px/, '文件行没有保持 48px 最小高度')
assert.match( assert.match(sourceUploadSource, /<span class="file-status"><i class="fa fa-check-circle"[^>]*\/> 校验通过<\/span>/, '文件行缺少校验成功状态')
taskSetupSource, assert.match(sourceUploadSource, /@click="emit\('remove-file', file\.uid\)"/, '文件行缺少 remove-file 删除动作')
/<section\s+v-else\s+class="uploaded-file-list"\s+aria-label="已上传文件列表">[\s\S]*?<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">[\s\S]*?<span\s+class="file-status"><i\s+class="fa fa-check-circle"\s*\/>\s*校验通过<\/span>\s*<el-button\s+link\s+type="danger"\s+@click="emit\('remove-file', file\.uid\)">删除<\/el-button>\s*<\/div>\s*<\/div>/,
'文件行没有将成功状态与对应 remove-file 删除按钮关联',
)
const { descriptor } = parseSfc(viewSource, { filename: viewPath }) const { descriptor } = parseSfc(viewSource, { filename: viewPath })
const template = descriptor.template?.content || '' const template = descriptor.template?.content || ''
assert.equal((template.match(/class="wizard-primary-action"/g) || []).length, 1, '页面必须只有一个主操作入口') assert.equal((template.match(/class="wizard-primary-action"/g) || []).length, 1, '页面必须只有一个主操作入口')
assert.match(viewSource, /onBeforeUnmount\(stopGenerationTimer\)/, '生成计时器没有在卸载时清理') assert.match(viewSource, /onBeforeUnmount\(\(\) => \{[\s\S]*?stopGenerationTimer\(\)[\s\S]*?clearTimeout\(connectionTimer\)[\s\S]*?clearTimeout\(pullTimer\)/, '生成与外部数据源计时器没有在卸载时清理')
assert.match(viewSource, /function scrollToStepTop/, '步骤切换后没有恢复页面顶部上下文') assert.match(viewSource, /function scrollToStepTop/, '步骤切换后没有恢复页面顶部上下文')
assert.match(viewSource, /nextTick\(scrollToStepTop\)/, '步骤切换没有触发页面滚动复位') assert.match(viewSource, /nextTick\(scrollToStepTop\)/, '步骤切换没有触发页面滚动复位')
assert.match(viewSource, /\.wizard-content\s*\{[\s\S]*min-height:\s*400px/, '第一步内容区必须保留足够高度以显示底部操作栏') assert.match(viewStyleSource, /\.wizard-content\s*\{[\s\S]*min-height:\s*400px/, '第一步内容区必须保留足够高度以显示底部操作栏')
assert.match( assert.match(
layoutSource, 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/, /&: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/,
@@ -408,4 +883,4 @@ assert.match(
) )
assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '对照预览高度不足以展示切片正文') assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '对照预览高度不足以展示切片正文')
console.log('数据处理步向导回归检查通过') 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

@@ -1,9 +1,12 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const root = resolve(fileURLToPath(new URL('..', import.meta.url))) const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
const source = readFileSync(resolve(root, 'src/views/fine-tune/FineTuneCreateView.vue'), 'utf8') 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) { function assert(condition, message) {
if (!condition) { if (!condition) {
@@ -18,10 +21,37 @@ assert(source.includes('gpu-usage-bar'), 'GPU cards should visualize usage with
assert(source.includes('is-busy'), 'GPU cards should have a distinct busy state for usage over 80%') 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('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(source.includes('modelDialogVisible'), 'Model selection should open a dialog instead of a plain select')
assert(!source.includes('width="78vw"'), 'Model dialog should not use the previous oversized 78vw width') assert(!modelDialogSource.includes('width="78vw"'), 'Model dialog should not use the previous oversized 78vw width')
assert(source.includes('width="860px"'), 'Model dialog should use a compact enterprise modal width') assert(modelDialogSource.includes('width="860px"'), 'Model dialog should use a compact enterprise modal width')
assert(source.includes('model-series-list'), 'Model dialog should include a model series list column') assert(modelDialogSource.includes('model-series-list'), 'Model dialog should include a model series list column')
assert(source.includes('model-version-list'), 'Model dialog should include a snapshot/version list column') assert(modelDialogSource.includes('model-version-list'), 'Model dialog should include a snapshot/version list column')
assert(source.includes('confirmModelSelection'), 'Model dialog should confirm the selected model before updating the form') 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') 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

@@ -6,11 +6,12 @@ import { parse as parseTemplate } from '@vue/compiler-dom'
import { parse as parseSfc } from '@vue/compiler-sfc' import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const [globalStyles, routerSource, mainLayoutSource, trainingLogSource, fineTuneCreateSource] = await Promise.all([ const [globalStyles, routerSource, mainLayoutSource, trainingLogSource, trainingOverviewSource, fineTuneCreateSource] = await Promise.all([
readFile(path.resolve(scriptDir, '../src/styles/index.scss'), 'utf8'), readFile(path.resolve(scriptDir, '../src/styles/index.scss'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'), readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), '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/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'), readFile(path.resolve(scriptDir, '../src/views/fine-tune/FineTuneCreateView.vue'), 'utf8'),
]) ])
@@ -70,9 +71,12 @@ const selfSurfaceRoutes = [
'fine-tune', 'fine-tune',
'model-eval', 'model-eval',
'model-inference', 'model-inference',
'model-inference/chat/:id',
'model-manage', 'model-manage',
'data-process', 'data-process',
'data-process/create',
'dataset', 'dataset',
'user-settings',
] ]
for (const routePath of selfSurfaceRoutes) { for (const routePath of selfSurfaceRoutes) {
const routeBlock = extractRouteBlock(routerSource, routePath) const routeBlock = extractRouteBlock(routerSource, routePath)
@@ -138,8 +142,8 @@ assert.doesNotMatch(
const layoutContentBlock = extractCssBlock(mainLayoutStyle, '.layout-content') const layoutContentBlock = extractCssBlock(mainLayoutStyle, '.layout-content')
assert.match(layoutContentBlock, /background-color:\s*var\(--app-shell-bg\);/, '主内容区未使用灰色外层背景') assert.match(layoutContentBlock, /background-color:\s*var\(--app-shell-bg\);/, '主内容区未使用灰色外层背景')
const pageCanvasBlock = extractCssBlock(mainLayoutStyle, '.page-canvas') const pageCanvasBlock = extractCssBlock(mainLayoutStyle, '\n.page-canvas {')
assert.match(pageCanvasBlock, /min-height:\s*100%;/, '白色页面画布没有铺满可用高度') assert.match(pageCanvasBlock, /flex:\s*1 0 auto;/, '白色页面画布没有铺满可用高度')
assert.match(pageCanvasBlock, /padding:\s*24px;/, '白色页面画布缺少统一内容内边距') assert.match(pageCanvasBlock, /padding:\s*24px;/, '白色页面画布缺少统一内容内边距')
assert.match(pageCanvasBlock, /border-radius:\s*16px;/, '白色页面画布圆角与参考不一致') assert.match(pageCanvasBlock, /border-radius:\s*16px;/, '白色页面画布圆角与参考不一致')
assert.match(pageCanvasBlock, /background-color:\s*var\(--app-page-bg\);/, '全局页面画布未使用白色背景') assert.match(pageCanvasBlock, /background-color:\s*var\(--app-page-bg\);/, '全局页面画布未使用白色背景')
@@ -174,7 +178,9 @@ assert.match(rootPageCardBlock, /background-color:\s*transparent;/, '页面根
assert.match(rootPageCardBlock, /border-radius:\s*0\s*!important;/, '页面根卡片仍形成第二层圆角边界') assert.match(rootPageCardBlock, /border-radius:\s*0\s*!important;/, '页面根卡片仍形成第二层圆角边界')
assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根卡片仍形成重复卡片层级') assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根卡片仍形成重复卡片层级')
const trainingLogStyle = parseSfc(trainingLogSource).descriptor.styles.map((item) => item.content).join('\n') 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') const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.profile-section,\n.runtime-panel')
assert.match( assert.match(
businessSurfaceBlock, businessSurfaceBlock,

View File

@@ -2,17 +2,35 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import ts from 'typescript'
import { parse as parseTemplate } from '@vue/compiler-dom' import { parse as parseTemplate } from '@vue/compiler-dom'
import { parse as parseSfc } from '@vue/compiler-sfc' import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue') const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue')
const source = await readFile(viewPath, 'utf8') 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 } = parseSfc(source, { filename: viewPath })
const template = descriptor.template?.content || '' const { descriptor: overviewDescriptor } = parseSfc(overviewSource, { filename: overviewPath })
const style = descriptor.styles.map((item) => item.content).join('\n') 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 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 = []) { function findElements(node, predicate, result = []) {
if (node?.type === 1 && predicate(node)) result.push(node) if (node?.type === 1 && predicate(node)) result.push(node)
for (const child of node?.children || []) findElements(child, predicate, result) for (const child of node?.children || []) findElements(child, predicate, result)
@@ -50,41 +68,71 @@ function extractCssBlock(css, marker) {
assert.fail(`样式规则缺少右花括号:${marker}`) assert.fail(`样式规则缺少右花括号:${marker}`)
} }
function relativeLuminance(hex) { const logFiles = [
const channels = hex { file: 'first_pid111.log', name: 'first-task', size: '1 KB', pid: 111 },
.replace('#', '') { file: 'target_pid222.log', name: 'target-task', size: '1 KB', pid: 222 },
.match(/.{2}/g) ]
.map((channel) => Number.parseInt(channel, 16) / 255) assert.equal(
.map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)) resolveTrainingLogFile(logFiles, { process_id: 222, name: 'target-task' })?.file,
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] '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,
'任务无匹配日志时不得静默退回第一份日志',
)
function contrastRatio(foreground, background) { const parsed = parseTrainingLog([
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background)) "INFO {'epoch': .5, 'learning_rate': 1.2E-5, 'grad_norm': -2.5e+0, 'loss': +3.25}",
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background)) '***** train metrics *****',
return (lighter + 0.05) / (darker + 0.05) '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( const overview = findElements(
templateAst, templateAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'), (node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
) )
assert.equal(overview.length, 1, '双栏任务档案容器必须且只能存在一个') assert.equal(overview.length, 1, '标准任务概况容器必须且只能存在一个')
assert.doesNotMatch(overviewSource, /<aside\b/, '任务概况仍保留左右侧栏结构')
for (const expectedClass of ['task-profile', 'dataset-profile', 'runtime-panel', 'parameter-groups']) { assert.doesNotMatch(overviewSource, /<i class="fa\b/, '任务概况仍包含过多装饰性图标')
const matched = findElements(
templateAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes(expectedClass),
)
assert.equal(matched.length, 1, `缺少或重复布局结构:${expectedClass}`)
}
const toggleButtons = findElements( const toggleButtons = findElements(
templateAst, templateAst,
(node) => node.tag === 'button' (node) => node.tag === 'el-button'
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'), && staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
) )
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的原生 button') assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的标准按钮')
const toggleButton = toggleButtons[0] const toggleButton = toggleButtons[0]
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded') assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
@@ -96,33 +144,80 @@ const controlledRegions = findElements(
) )
assert.equal(controlledRegions.length, 1, 'aria-controls 指向的参数内容区域不存在或重复') 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('<!-- 训练曲线 -->') const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围') assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
assert.equal( assert.ok(template.includes('training-progress-panel'), '训练概览缺少独立的主进度区域')
template.slice(0, firstChartIndex).includes('<el-descriptions'), assert.ok(template.includes('training-metric-grid'), '训练概览缺少次级训练指标摘要')
false, assert.ok(template.includes('gpu-device-list'), '训练概览缺少紧凑的 GPU 设备列表')
'任务概览、数据集和训练参数仍使用带表格感的 el-descriptions', 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?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确') assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
const media1100 = extractCssBlock(style, '@media (max-width: 1100px)') const overviewLayoutBlock = extractCssBlock(overviewStyle, '.overview-layout')
const overviewAt1100 = extractCssBlock(media1100, '.overview-layout') assert.match(overviewLayoutBlock, /width:\s*100%/, '任务概况没有使用全宽单列布局')
assert.match(overviewAt1100, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '1100px 断点未将双栏改为单栏') assert.doesNotMatch(overviewLayoutBlock, /grid-template-columns/, '任务档案仍保留左右分栏规则')
const media700 = extractCssBlock(style, '@media (max-width: 700px)') const overviewMedia700 = extractCssBlock(overviewStyle, '@media (max-width: 700px)')
for (const selector of ['.dataset-metrics', '.runtime-list', '.parameter-grid']) { assert.ok(overviewMedia700.includes('.task-descriptions'), '700px 断点缺少任务详情表单列规则')
assert.ok(media700.includes(selector), `700px 断点缺少单列规则:${selector}`) assert.match(overviewMedia700, /display:\s*block/, '移动端任务详情表没有转换为单列')
} const viewMedia600 = extractCssBlock(viewStyle, '@media (max-width: 600px)')
assert.match(media700, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '700px 断点未设置单列网格') assert.ok(viewMedia600.includes('.parameter-descriptions'), '600px 断点缺少训练参数表单列规则')
assert.match(viewMedia600, /display:\s*block/, '移动端训练参数表没有转换为单列')
const mutedBlock = extractCssBlock(style, '.is-muted')
const mutedColor = mutedBlock.match(/color:\s*(#[0-9a-f]{6})/i)?.[1]
assert.ok(mutedColor, '未配置文本缺少明确颜色')
assert.ok(
contrastRatio(mutedColor, '#ffffff') >= 4.5,
`未配置文本颜色 ${mutedColor} 与白色背景对比度不足 4.5:1`,
)
console.log('训练日志详情布局回归检查通过') 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} 个前端回归脚本全部通过`)

View File

@@ -1,5 +1,5 @@
import { get, post, put, del } from '../request' import { get, post, put, del } from '../request'
import type { DatasetItem } from '@/types' import type { DatasetItem, DatasetVersion, DatasetVersionList } from '@/types'
/** 数据集列表 */ /** 数据集列表 */
export const getDatasetList = () => get<DatasetItem[]>('/dataset-manage') export const getDatasetList = () => get<DatasetItem[]>('/dataset-manage')
@@ -29,11 +29,69 @@ export const uploadDatasetFiles = (datasetId: string | number, files: File[]) =>
/** 预览数据集文件内容 */ /** 预览数据集文件内容 */
export const previewDatasetFile = (fileId: string | number) => export const previewDatasetFile = (fileId: string | number) =>
get<{ content: string }>(`/dataset-manage/preview/${fileId}`) 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 */ /** 下载文件 URL */
export const downloadFileUrl = (datasetId: string | number, fileId: string | number) => export const downloadFileUrl = (
`/api/dataset-manage/download/${datasetId}/${fileId}` 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 */ /** 打包下载数据集 URL */
export const downloadDatasetUrl = (datasetId: string | number) => export const downloadDatasetUrl = (datasetId: string | number) =>

View File

@@ -1,14 +1,17 @@
import { get, post, put, del } from '../request' import { get, post, put, del } from '../request'
import type { EvalTask, Dimension } from '@/types' import type { EvalTask, EvalTaskDetail, Dimension, StartEvalPayload } from '@/types'
/** 评测任务列表 */ /** 评测任务列表 */
export const getEvalList = () => get<EvalTask[]>('/model-eval') 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 deleteEval = (id: string | number) => del(`/model-eval/${id}`)
/** 启动评测 */ /** 启动评测 */
export const startEval = (data: any) => post('/model-eval/start', data) export const startEval = (data: StartEvalPayload) => post('/model-eval/start', data)
/** 评测维度列表 */ /** 评测维度列表 */
export const getDimensionList = () => get<Dimension[]>('/dimension') export const getDimensionList = () => get<Dimension[]>('/dimension')
@@ -17,7 +20,8 @@ export const getDimensionList = () => get<Dimension[]>('/dimension')
export const getDimension = (id: string | number) => get<Dimension>(`/dimension/${id}`) export const getDimension = (id: string | number) => get<Dimension>(`/dimension/${id}`)
/** 创建维度 */ /** 创建维度 */
export const createDimension = (data: Partial<Dimension>) => post('/dimension', data) export const createDimension = (data: Partial<Dimension>) =>
post<{ id: string | number }>('/dimension', data)
/** 编辑维度 */ /** 编辑维度 */
export const updateDimension = (id: string | number, data: Partial<Dimension>) => export const updateDimension = (id: string | number, data: Partial<Dimension>) =>

View File

@@ -1,5 +1,5 @@
import { get, post, put, del } from '../request' import { get, post, put, del } from '../request'
import type { FineTuneTask, TrainingProgress } from '@/types' import type { FineTuneStartPayload, FineTuneTask, TrainingProgress } from '@/types'
/** 训练任务列表 */ /** 训练任务列表 */
export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune') export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune')
@@ -16,7 +16,7 @@ export const createFineTune = (data: Partial<FineTuneTask>) =>
post<{ id: string | number }>('/fine-tune', data) post<{ id: string | number }>('/fine-tune', data)
/** 启动训练(第二步) */ /** 启动训练(第二步) */
export const startFineTune = (data: any) => post('/fine-tune/start', data) export const startFineTune = (data: FineTuneStartPayload) => post('/fine-tune/start', data)
/** 更新训练任务 */ /** 更新训练任务 */
export const updateFineTune = (id: string | number, data: Partial<FineTuneTask>) => export const updateFineTune = (id: string | number, data: Partial<FineTuneTask>) =>

View File

@@ -1,5 +1,12 @@
import { get, post } from '../request' import { del, get, post, put } from '../request'
import type { SystemInfo, HealthMetrics } from '@/types' import type {
CreateUserPayload,
HealthMetrics,
LoginResponse,
SystemInfo,
SystemUser,
UpdateUserAccessPayload,
} from '@/types'
/** 系统信息CPU/内存/磁盘/GPU/网络/系统) */ /** 系统信息CPU/内存/磁盘/GPU/网络/系统) */
export const getSystemInfo = () => get<SystemInfo>('/system-info') export const getSystemInfo = () => get<SystemInfo>('/system-info')
@@ -9,4 +16,21 @@ export const getHealth = () => get<HealthMetrics>('/health')
/** 登录 */ /** 登录 */
export const login = (username: string, password: string) => export const login = (username: string, password: string) =>
post('/login', { username, password }) 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,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

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { PermissionCode } from '@/types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -9,7 +10,7 @@ const auth = useAuthStore()
/** 当前激活菜单 key取路由路径第一段如 /fine-tune/create → fine-tune */ /** 当前激活菜单 key取路由路径第一段如 /fine-tune/create → fine-tune */
const activeMenu = computed(() => { const activeMenu = computed(() => {
const seg = route.path.split('/')[1] || 'fine-tune' const seg = route.path.split('/')[1] || 'dashboard'
// 训练日志归到模型调优 // 训练日志归到模型调优
if (seg === 'training-log') return 'fine-tune' if (seg === 'training-log') return 'fine-tune'
// 维度管理归到模型评测 // 维度管理归到模型评测
@@ -27,45 +28,92 @@ const activeMenu = computed(() => {
return seg return seg
}) })
const menuGroups = [ 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, showTitle: false,
items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard' }], items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard', permission: 'dashboard' }],
}, },
{ {
title: '模型服务', title: '模型服务',
items: [ items: [
{ key: 'fine-tune', label: '模型微调', icon: 'fa-cogs', to: '/fine-tune' }, { key: 'fine-tune', label: '模型训练', icon: 'fa-cogs', to: '/fine-tune', permission: 'fine-tune' },
{ key: 'model-eval', label: '模型评测', icon: 'fa-line-chart', to: '/model-eval' }, { key: 'model-eval', label: '模型评测', icon: 'fa-line-chart', to: '/model-eval', permission: 'model-eval' },
{ key: 'model-inference', label: '模型推理', icon: 'fa-server', to: '/model-inference' }, { key: 'model-inference', label: '模型推理', icon: 'fa-server', to: '/model-inference', permission: 'model-inference' },
{ key: 'model-manage', label: '模型管理', icon: 'fa-cube', to: '/model-manage' }, { key: 'model-manage', label: '模型管理', icon: 'fa-cube', to: '/model-manage', permission: 'model-manage' },
], ],
}, },
{ {
title: '数据治理', title: '数据治理',
items: [ items: [
{ key: 'data-process', label: '数据理', icon: 'fa-filter', to: '/data-process' }, { key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset', permission: 'dataset' },
{ key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset' }, { key: 'data-process', label: '数据理', icon: 'fa-filter', to: '/data-process', permission: 'data-process' },
], ],
}, },
{ {
title: '其他工具', title: '其他工具',
items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert' }], items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert', permission: 'data-convert' }],
}, },
{ {
title: '系统设置', title: '系统设置',
items: [ items: [
{ key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware' }, { key: 'user-settings', label: '用户设置', icon: 'fa-users', to: '/user-settings', permission: 'user-settings' },
{ key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs' }, { key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware', permission: 'hardware' },
{ key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs', permission: 'logs' },
], ],
}, },
] ]
function handleSelect(key: string) { const visibleMenuGroups = computed(() =>
const all = menuGroups.flatMap((g) => g.items) 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) const target = all.find((i) => i.key === key)
if (target) router.push(target.to) if (!target) return
const failure = await router.push(target.to)
// 导航被守卫取消(如未保存修改确认)时,把高亮校正回当前路由
if (failure) {
menuActive.value = ''
await nextTick()
menuActive.value = activeMenu.value
}
} }
function handleLogout() { function handleLogout() {
@@ -85,14 +133,14 @@ function handleLogout() {
<!-- 导航 --> <!-- 导航 -->
<el-scrollbar class="sidebar-nav" wrap-class="nav-scrollbar-wrap"> <el-scrollbar class="sidebar-nav" wrap-class="nav-scrollbar-wrap">
<el-menu <el-menu
:default-active="activeMenu" :default-active="menuActive"
class="sidebar-menu" class="sidebar-menu"
background-color="transparent" background-color="transparent"
text-color="var(--sidebar-text)" text-color="var(--sidebar-text)"
active-text-color="var(--sidebar-active-text)" active-text-color="var(--sidebar-active-text)"
@select="handleSelect" @select="handleSelect"
> >
<template v-for="group in menuGroups" :key="group.title || group.items[0].key"> <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> <div v-if="group.title" class="sidebar-section-title">{{ group.title }}</div>
<el-menu-item <el-menu-item
v-for="item in group.items" v-for="item in group.items"
@@ -111,8 +159,8 @@ function handleLogout() {
<div class="footer-user" v-if="auth.username"> <div class="footer-user" v-if="auth.username">
<el-avatar :size="36" src="https://picsum.photos/id/1005/36/36" class="user-avatar" /> <el-avatar :size="36" src="https://picsum.photos/id/1005/36/36" class="user-avatar" />
<div class="user-info"> <div class="user-info">
<span class="user-name">{{ auth.username }}</span> <span class="user-name">{{ auth.displayName }}</span>
<span class="user-role">Administrator</span> <span class="user-role">{{ auth.roleLabel }}</span>
</div> </div>
<el-tooltip content="退出登录" placement="top"> <el-tooltip content="退出登录" placement="top">
<div class="logout-btn" @click="handleLogout"> <div class="logout-btn" @click="handleLogout">

View File

@@ -76,6 +76,29 @@ export const LR_SCHEDULER_OPTIONS = [
{ label: 'constant', value: 'constant' }, { label: 'constant', value: 'constant' },
] ]
/** 训练时量化位数QLoRA */
export const QUANTIZATION_BIT_OPTIONS = [
{ label: '不量化', value: 0 },
{ label: '8 bit', value: 8 },
{ label: '4 bit (QLoRA)', value: 4 },
]
/** 训练后导出量化方法 */
export const QUANT_METHOD_OPTIONS = [
{ label: 'bitsandbytes', value: 'bnb' },
{ label: 'GPTQ', value: 'gptq' },
{ label: 'AWQ', value: 'awq' },
{ label: 'GGUF (llama.cpp)', value: 'gguf' },
]
/** GGUF 量化格式 */
export const GGUF_FORMAT_OPTIONS = [
{ label: 'Q4_K_M推荐', value: 'Q4_K_M' },
{ label: 'Q5_K_M', value: 'Q5_K_M' },
{ label: 'Q8_0', value: 'Q8_0' },
{ label: 'F16不量化', value: 'F16' },
]
/** 训练模板分组(按模型系列) */ /** 训练模板分组(按模型系列) */
export const TEMPLATE_GROUPS: { label: string; options: { label: string; value: string }[] }[] = [ export const TEMPLATE_GROUPS: { label: string; options: { label: string; value: string }[] }[] = [
{ {

View File

@@ -79,6 +79,20 @@ onUnmounted(() => {
min-height: 0; min-height: 0;
} }
} }
}
@media (min-width: 1181px) {
.layout-content:has(.dashboard-view) {
overflow-y: auto;
.page-canvas {
flex: 1 0 auto;
min-height: 0;
padding: 16px;
overflow: visible;
}
}
} }
.page-canvas { .page-canvas {

View File

@@ -3,23 +3,43 @@
* 拦截所有 API 请求并返回 mock 数据 * 拦截所有 API 请求并返回 mock 数据
* 通过 URL + method 路由到对应的 mock 响应 * 通过 URL + method 路由到对应的 mock 响应
*/ */
import type { AxiosInstance, AxiosRequestConfig } from 'axios' import type { AxiosAdapter, AxiosInstance, AxiosRequestConfig } from 'axios'
import { import {
mockLoginOk,
mockHealth, mockHealth,
mockSystemInfo, mockSystemInfo,
mockModels, mockModels,
mockTrainedModels, mockTrainedModels,
mockLocalModels, mockLocalModels,
mockDatasets, mockDatasets,
mockDatasetPreviews,
mockFineTuneList, mockFineTuneList,
mockCompareList, mockCompareList,
mockEvalList, mockEvalList,
mockEvalDetails,
mockDimensions, mockDimensions,
mockLogFiles, mockLogFiles,
mockTrainingLogFiles, mockTrainingLogFiles,
mockLogContent, mockLogContent,
mockTrainingLogContents,
} from './data' } from './data'
import {
activateDatasetVersion,
appendDatasetVersion,
createInitialVersionState,
deleteDatasetVersion,
DatasetVersionMutationError,
getActiveDatasetVersion,
normalizeDatasetVersionState,
} from './datasetVersions'
import type { StoredDatasetVersion, StoredDatasetVersionState } from './datasetVersions'
import {
authenticateMockUser,
createMockUser,
deleteMockUser,
listMockUsers,
updateMockUserAccess,
UserMutationError,
} from './users'
/** 模拟网络延迟 */ /** 模拟网络延迟 */
function delay(ms = 200): Promise<void> { function delay(ms = 200): Promise<void> {
@@ -55,6 +75,55 @@ function stripBaseURL(url: string): string {
return path return path
} }
function datasetFallbackContent(fileId: string) {
const fallbackKey = fileId.endsWith('-readme') ? 'mock-readme' : 'mock-jsonl'
return mockDatasetPreviews[fileId] ?? mockDatasetPreviews[fallbackKey] ?? ''
}
function versionStorageKey(fileId: string) {
return `mock:dataset-versions:${fileId}`
}
function persistVersionState(fileId: string, state: StoredDatasetVersionState) {
localStorage.setItem(versionStorageKey(fileId), JSON.stringify(state))
}
function getVersionState(fileId: string): StoredDatasetVersionState {
const persisted = localStorage.getItem(versionStorageKey(fileId))
if (persisted) {
try {
const state = JSON.parse(persisted) as StoredDatasetVersionState
if (state.versions?.length && state.active_version_id) {
const normalizedState = normalizeDatasetVersionState(state)
if (state.next_version_number !== normalizedState.next_version_number) {
persistVersionState(fileId, normalizedState)
}
return normalizedState
}
} catch {
// 版本数据损坏时回退到初始版本
}
}
const legacyContent = localStorage.getItem(`mock:dataset-file:${fileId}`)
const state = createInitialVersionState(legacyContent ?? datasetFallbackContent(fileId))
persistVersionState(fileId, state)
return state
}
function versionMetadata(version: StoredDatasetVersion) {
const { content: _content, ...metadata } = version
return metadata
}
function versionListResponse(state: StoredDatasetVersionState) {
return {
versions: state.versions.map(versionMetadata).sort((a, b) => b.version - a.version),
active_version_id: state.active_version_id,
next_version_number: state.next_version_number,
}
}
/** 通过路径 + method 匹配 mock 响应 */ /** 通过路径 + method 匹配 mock 响应 */
async function handleMock(config: AxiosRequestConfig) { async function handleMock(config: AxiosRequestConfig) {
await delay(150) // 模拟网络延迟 await delay(150) // 模拟网络延迟
@@ -66,10 +135,12 @@ async function handleMock(config: AxiosRequestConfig) {
// ==================== 认证 ==================== // ==================== 认证 ====================
if (url === '/login' && method === 'post') { if (url === '/login' && method === 'post') {
if (body.username === 'admin' && body.password === 'admin') { try {
return ok(mockLoginOk.data) return ok(authenticateMockUser(String(body.username || ''), String(body.password || '')))
} catch (error) {
if (error instanceof UserMutationError) return fail(error.message, error.status)
return fail('登录失败', 500)
} }
return fail('账号或密码错误', 401)
} }
if (url === '/web-log' && method === 'post') { if (url === '/web-log' && method === 'post') {
return ok({ received: true }) return ok({ received: true })
@@ -79,6 +150,37 @@ async function handleMock(config: AxiosRequestConfig) {
if (url === '/health' && method === 'get') return ok(mockHealth) if (url === '/health' && method === 'get') return ok(mockHealth)
if (url === '/system-info' && method === 'get') return ok(mockSystemInfo) if (url === '/system-info' && method === 'get') return ok(mockSystemInfo)
// ==================== 用户设置 ====================
if (url === '/users' && method === 'get') return ok(listMockUsers())
if (url === '/users' && method === 'post') {
try {
return ok(createMockUser(body))
} catch (error) {
if (error instanceof UserMutationError) return fail(error.message, error.status)
return fail('创建用户失败', 500)
}
}
let userMatch = url.match(/^\/users\/([^/]+)$/)
if (userMatch && method === 'put') {
try {
return ok(updateMockUserAccess(decodeURIComponent(userMatch[1]), body))
} catch (error) {
if (error instanceof UserMutationError) return fail(error.message, error.status)
return fail('更新用户失败', 500)
}
}
if (userMatch && method === 'delete') {
try {
return ok(deleteMockUser(
decodeURIComponent(userMatch[1]),
String(params.current_username || ''),
))
} catch (error) {
if (error instanceof UserMutationError) return fail(error.message, error.status)
return fail('删除用户失败', 500)
}
}
// ==================== 模型管理 ==================== // ==================== 模型管理 ====================
if (url === '/model-manage' && method === 'get') return ok(mockModels) if (url === '/model-manage' && method === 'get') return ok(mockModels)
if (url === '/model-manage/local-models' && method === 'get') return ok(mockLocalModels) if (url === '/model-manage/local-models' && method === 'get') return ok(mockLocalModels)
@@ -120,7 +222,8 @@ async function handleMock(config: AxiosRequestConfig) {
} }
m = url.match(/^\/dataset-manage\/([^/]+)$/) m = url.match(/^\/dataset-manage\/([^/]+)$/)
if (m && method === 'get') { if (m && method === 'get') {
const found = mockDatasets.find((x) => String(x.id) === m[1]) const datasetId = m[1]
const found = mockDatasets.find((x) => String(x.id) === datasetId)
return found ? ok(found) : fail('数据集不存在', 404) return found ? ok(found) : fail('数据集不存在', 404)
} }
if (m && (method === 'put' || method === 'delete')) { if (m && (method === 'put' || method === 'delete')) {
@@ -129,7 +232,101 @@ async function handleMock(config: AxiosRequestConfig) {
m = url.match(/^\/dataset-manage\/upload\/([^/]+)$/) m = url.match(/^\/dataset-manage\/upload\/([^/]+)$/)
if (m && method === 'post') return ok({ uploaded: true }) if (m && method === 'post') return ok({ uploaded: true })
m = url.match(/^\/dataset-manage\/preview\/([^/]+)$/) m = url.match(/^\/dataset-manage\/preview\/([^/]+)$/)
if (m && method === 'get') return ok(mockLogContent) if (m && method === 'get') {
const fileId = decodeURIComponent(m[1])
return ok({ content: getActiveDatasetVersion(getVersionState(fileId)).content })
}
if (m && method === 'put') return fail('历史版本不可覆盖,请创建新版本', 405)
m = url.match(/^\/dataset-manage\/versions\/([^/]+)$/)
if (m && method === 'get') {
const fileId = decodeURIComponent(m[1])
const state = getVersionState(fileId)
return ok(versionListResponse(state))
}
if (m && method === 'post') {
const fileId = decodeURIComponent(m[1])
if (typeof body.content !== 'string') return fail('文件内容格式不正确', 400)
const currentState = getVersionState(fileId)
if (body.expected_current_version_id !== currentState.active_version_id) {
return fail('当前版本已被其他用户更新,请刷新后重试', 409)
}
if (body.base_version_id !== currentState.active_version_id) {
return fail('只能基于当前版本创建新版本', 409)
}
if (body.content === getActiveDatasetVersion(currentState).content) {
return fail('数据内容没有变化,无需创建新版本', 400)
}
const nextState = appendDatasetVersion(
currentState,
body.content,
new Date().toISOString(),
typeof body.description === 'string' ? body.description : '在线编辑',
)
try {
persistVersionState(fileId, nextState)
} catch {
return fail('文件内容过大,浏览器 Mock 存储空间不足', 413)
}
const version = getActiveDatasetVersion(nextState)
return ok({ version: versionMetadata(version), content: version.content })
}
m = url.match(/^\/dataset-manage\/versions\/([^/]+)\/active$/)
if (m && method === 'put') {
const fileId = decodeURIComponent(m[1])
const currentState = getVersionState(fileId)
if (body.expected_current_version_id !== currentState.active_version_id) {
return fail('当前版本已被其他用户更新,请刷新后重试', 409)
}
let nextState: StoredDatasetVersionState
try {
nextState = activateDatasetVersion(currentState, String(body.version_id || ''))
} catch (error) {
return fail(error instanceof Error ? error.message : '版本不存在', 404)
}
try {
persistVersionState(fileId, nextState)
} catch {
return fail('浏览器 Mock 存储空间不足', 413)
}
const version = getActiveDatasetVersion(nextState)
return ok({ version: versionMetadata(version), content: version.content })
}
m = url.match(/^\/dataset-manage\/versions\/([^/]+)\/([^/]+)$/)
if (m && method === 'get') {
const fileId = decodeURIComponent(m[1])
const versionId = decodeURIComponent(m[2])
const version = getVersionState(fileId).versions.find((item) => item.id === versionId)
return version
? ok({ version: versionMetadata(version), content: version.content })
: fail('数据集版本不存在', 404)
}
if (m && method === 'delete') {
const fileId = decodeURIComponent(m[1])
const versionId = decodeURIComponent(m[2])
const currentState = getVersionState(fileId)
if (params.expected_current_version_id !== currentState.active_version_id) {
return fail('当前版本已被其他用户更新,请刷新后重试', 409)
}
let nextState: StoredDatasetVersionState
try {
nextState = deleteDatasetVersion(currentState, versionId)
} catch (error) {
if (error instanceof DatasetVersionMutationError) {
return fail(error.message, error.status)
}
return fail('删除版本失败', 500)
}
try {
persistVersionState(fileId, nextState)
} catch {
return fail('浏览器 Mock 存储空间不足', 413)
}
return ok(versionListResponse(nextState))
}
// ==================== 训练任务 ==================== // ==================== 训练任务 ====================
if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList) if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)
@@ -144,7 +341,8 @@ async function handleMock(config: AxiosRequestConfig) {
} }
m = url.match(/^\/fine-tune\/progress\/([^/]+)$/) m = url.match(/^\/fine-tune\/progress\/([^/]+)$/)
if (m && method === 'get') { if (m && method === 'get') {
const task = mockFineTuneList.find((t) => String(t.id) === m[1]) const taskId = m[1]
const task = mockFineTuneList.find((t) => String(t.id) === taskId)
if (!task) return fail('任务不存在', 404) if (!task) return fail('任务不存在', 404)
if (task.status === 'running') { if (task.status === 'running') {
return ok({ return ok({
@@ -159,7 +357,8 @@ async function handleMock(config: AxiosRequestConfig) {
} }
m = url.match(/^\/fine-tune\/([^/]+)$/) m = url.match(/^\/fine-tune\/([^/]+)$/)
if (m && method === 'get') { if (m && method === 'get') {
const found = mockFineTuneList.find((x) => String(x.id) === m[1]) const taskId = m[1]
const found = mockFineTuneList.find((x) => String(x.id) === taskId)
return found ? ok(found) : fail('任务不存在', 404) return found ? ok(found) : fail('任务不存在', 404)
} }
m = url.match(/^\/fine-tune\/stop\/([^/]+)$/) m = url.match(/^\/fine-tune\/stop\/([^/]+)$/)
@@ -179,7 +378,8 @@ async function handleMock(config: AxiosRequestConfig) {
} }
m = url.match(/^\/model-compare\/([^/]+)$/) m = url.match(/^\/model-compare\/([^/]+)$/)
if (m && method === 'get') { if (m && method === 'get') {
const found = mockCompareList.find((x) => String(x.id) === m[1]) const compareId = m[1]
const found = mockCompareList.find((x) => String(x.id) === compareId)
return found ? ok(found) : fail('任务不存在', 404) return found ? ok(found) : fail('任务不存在', 404)
} }
if (m && method === 'delete') return ok({ deleted: m[1] }) if (m && method === 'delete') return ok({ deleted: m[1] })
@@ -223,6 +423,12 @@ async function handleMock(config: AxiosRequestConfig) {
if (url === '/model-eval' && method === 'get') return ok(mockEvalList) if (url === '/model-eval' && method === 'get') return ok(mockEvalList)
if (url === '/model-eval' && method === 'delete') return ok({ deleted: true }) if (url === '/model-eval' && method === 'delete') return ok({ deleted: true })
if (url === '/model-eval/start' && method === 'post') return ok({ task_id: Math.floor(Math.random() * 1000) + 1 }) if (url === '/model-eval/start' && method === 'post') return ok({ task_id: Math.floor(Math.random() * 1000) + 1 })
m = url.match(/^\/model-eval\/([^/]+)$/)
const evalTaskId = m?.[1]
if (evalTaskId && method === 'get') {
const found = mockEvalDetails.find((item) => String(item.id) === evalTaskId)
return found ? ok(found) : fail('评测任务不存在', 404)
}
if (url === '/dimension' && method === 'get') return ok(mockDimensions) if (url === '/dimension' && method === 'get') return ok(mockDimensions)
if (url === '/dimension' && method === 'post') { if (url === '/dimension' && method === 'post') {
return ok({ id: Math.floor(Math.random() * 1000) + 100 }) return ok({ id: Math.floor(Math.random() * 1000) + 100 })
@@ -240,7 +446,14 @@ async function handleMock(config: AxiosRequestConfig) {
if (url === '/log-files' && method === 'get') return ok(mockLogFiles) if (url === '/log-files' && method === 'get') return ok(mockLogFiles)
if (url === '/log-content' && method === 'get') return ok(mockLogContent) if (url === '/log-content' && method === 'get') return ok(mockLogContent)
if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles) if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles)
if (url === '/training-log-content' && method === 'get') return ok(mockLogContent) if (url === '/training-log-content' && method === 'get') {
const file = String(params.file || '')
return ok(mockTrainingLogContents[file] || {
file,
size: '0 KB',
content: `[Mock] 未找到训练日志内容:${file}`,
})
}
// 未匹配的请求 → 兜底返回空成功(避免阻断 UI // 未匹配的请求 → 兜底返回空成功(避免阻断 UI
console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params) console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params)
@@ -257,12 +470,13 @@ function safeJSON(str: string) {
/** 给 axios instance 安装 mock adapter */ /** 给 axios instance 安装 mock adapter */
export function installMockAdapter(instance: AxiosInstance) { export function installMockAdapter(instance: AxiosInstance) {
instance.defaults.adapter = async (config: AxiosRequestConfig) => { const adapter = async (config: AxiosRequestConfig) => {
try { try {
const response = await handleMock(config) const response = await handleMock(config)
return response return response
} catch (e: any) { } catch (error: unknown) {
return fail(e.message || 'Mock 错误', 500, config) return fail(error instanceof Error ? error.message : 'Mock 错误', 500, config)
} }
} }
instance.defaults.adapter = adapter as AxiosAdapter
} }

View File

@@ -10,6 +10,7 @@ import type {
DatasetItem, DatasetItem,
CompareTask, CompareTask,
EvalTask, EvalTask,
EvalTaskDetail,
Dimension, Dimension,
SystemInfo, SystemInfo,
HealthMetrics, HealthMetrics,
@@ -29,10 +30,14 @@ export const mockHealth: HealthMetrics = {
} }
export const mockSystemInfo: SystemInfo = { export const mockSystemInfo: SystemInfo = {
timestamp: '2026-07-12T10:30:00+08:00',
cpu: { cpu: {
percent: 32, percent: 32,
cores: 8, cores: 8,
percents: [25, 38, 42, 30, 28, 36, 40, 18], percents: [25, 38, 42, 30, 28, 36, 40, 18],
model: 'Intel Xeon Gold 6330',
frequency_mhz: 2500,
load_1m: 2.56,
}, },
memory: { memory: {
used_gb: 9.2, used_gb: 9.2,
@@ -45,20 +50,169 @@ export const mockSystemInfo: SystemInfo = {
used_gb: 230, used_gb: 230,
total_gb: 512, total_gb: 512,
percent: 45, percent: 45,
read_mb_s: 86.4,
write_mb_s: 34.8,
}, },
gpu: [ gpu: [
{ name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 32, power_w: 38, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' }, {
{ name: 'NVIDIA A800', gpu_percent: 28, memory_used_gb: 22.5, memory_total_gb: 80, temperature: 52, power_w: 165, fan_speed: 32, clock_mhz: 1410, driver_version: '535.86.10' }, id: 0,
{ name: 'NVIDIA A800', gpu_percent: 73, memory_used_gb: 58.7, memory_total_gb: 80, temperature: 68, power_w: 320, fan_speed: 58, clock_mhz: 1410, driver_version: '535.86.10' }, uuid: 'GPU-MOCK-A800-00',
{ name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 34, power_w: 42, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' }, name: 'NVIDIA A800',
{ name: 'NVIDIA A800', gpu_percent: 46, memory_used_gb: 36.8, memory_total_gb: 80, temperature: 60, power_w: 210, fan_speed: 42, clock_mhz: 1410, driver_version: '535.86.10' }, status: 'busy',
{ name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 33, power_w: 40, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' }, gpu_percent: 74,
{ name: 'NVIDIA A800', gpu_percent: 85, memory_used_gb: 68.2, memory_total_gb: 80, temperature: 73, power_w: 365, fan_speed: 62, clock_mhz: 1410, driver_version: '535.86.10' }, memory_used_gb: 41.8,
{ name: 'NVIDIA A800', gpu_percent: 15, memory_used_gb: 12.0, memory_total_gb: 80, temperature: 45, power_w: 95, fan_speed: 22, clock_mhz: 1410, driver_version: '535.86.10' }, memory_total_gb: 80,
memory_percent: 52.3,
temperature: 63,
power_w: 286,
power_limit_w: 400,
fan_speed: 51,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 28741, name: 'python', task_name: 'medical-cpt-001 / rank 0', user: 'trainer', memory_used_gb: 39.6 },
{ pid: 28768, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 },
],
},
{
id: 1,
uuid: 'GPU-MOCK-A800-01',
name: 'NVIDIA A800',
status: 'busy',
gpu_percent: 71,
memory_used_gb: 42.1,
memory_total_gb: 80,
memory_percent: 52.6,
temperature: 62,
power_w: 279,
power_limit_w: 400,
fan_speed: 49,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 28742, name: 'python', task_name: 'medical-cpt-001 / rank 1', user: 'trainer', memory_used_gb: 39.9 },
{ pid: 28769, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 },
],
},
{
id: 2,
uuid: 'GPU-MOCK-A800-02',
name: 'NVIDIA A800',
status: 'busy',
gpu_percent: 73,
memory_used_gb: 58.7,
memory_total_gb: 80,
memory_percent: 73.4,
temperature: 68,
power_w: 320,
power_limit_w: 400,
fan_speed: 58,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 20118, name: 'python', task_name: '大模型推理服务', user: 'inference', memory_used_gb: 56.4 },
{ pid: 20207, name: 'python', task_name: '推理指标采集', user: 'inference', memory_used_gb: 2.3 },
],
},
{
id: 3,
uuid: 'GPU-MOCK-A800-03',
name: 'NVIDIA A800',
status: 'idle',
gpu_percent: 0,
memory_used_gb: 0,
memory_total_gb: 80,
memory_percent: 0,
temperature: 34,
power_w: 42,
power_limit_w: 400,
fan_speed: 0,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [],
},
{
id: 4,
uuid: 'GPU-MOCK-A800-04',
name: 'NVIDIA A800',
status: 'busy',
gpu_percent: 46,
memory_used_gb: 36.8,
memory_total_gb: 80,
memory_percent: 46,
temperature: 60,
power_w: 210,
power_limit_w: 400,
fan_speed: 42,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 22680, name: 'python', task_name: '批量评测任务', user: 'evaluation', memory_used_gb: 32 },
{ pid: 22814, name: 'python', task_name: '模型裁判服务', user: 'evaluation', memory_used_gb: 4.8 },
],
},
{
id: 5,
uuid: 'GPU-MOCK-A800-05',
name: 'NVIDIA A800',
status: 'idle',
gpu_percent: 0,
memory_used_gb: 0,
memory_total_gb: 80,
memory_percent: 0,
temperature: 33,
power_w: 40,
power_limit_w: 400,
fan_speed: 0,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [],
},
{
id: 6,
uuid: 'GPU-MOCK-A800-06',
name: 'NVIDIA A800',
status: 'warning',
gpu_percent: 85,
memory_used_gb: 68.2,
memory_total_gb: 80,
memory_percent: 85.3,
temperature: 73,
power_w: 365,
power_limit_w: 400,
fan_speed: 62,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 24129, name: 'python', task_name: '全参数训练任务', user: 'trainer', memory_used_gb: 63.5 },
{ pid: 24211, name: 'python', task_name: '训练检查点保存', user: 'trainer', memory_used_gb: 4.7 },
],
},
{
id: 7,
uuid: 'GPU-MOCK-A800-07',
name: 'NVIDIA A800',
status: 'busy',
gpu_percent: 15,
memory_used_gb: 12,
memory_total_gb: 80,
memory_percent: 15,
temperature: 45,
power_w: 95,
power_limit_w: 400,
fan_speed: 22,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 25367, name: 'python', task_name: '在线推理服务', user: 'inference', memory_used_gb: 12 },
],
},
], ],
network: { network: {
download_mb: 1024, download_mb: 1024,
upload_mb: 256, upload_mb: 256,
download_mb_s: 42.6,
upload_mb_s: 12.4,
}, },
system: { system: {
uptime_seconds: 86400 * 3 + 3600 * 7 + 1800, uptime_seconds: 86400 * 3 + 3600 * 7 + 1800,
@@ -98,23 +252,271 @@ export const mockLocalModels = {
} }
// ============ 数据集 ============ // ============ 数据集 ============
export const mockDatasets: DatasetItem[] = [ export const mockDatasets: DatasetItem[] = ([
{ id: 1, name: '金融问答-训练集', type: 'train', storage_type: 'local', size: '128 MB', count: 8560, description: '金融领域问答对', create_time: '2025-12-20T08:00:00Z' }, {
{ id: 2, name: '法律文书-训练集', type: 'train', storage_type: 'local', size: '256 MB', count: 15230, description: '法律文书数据集', create_time: '2025-12-25T10:30:00Z' }, id: 1,
{ id: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' }, name: '金融问答-训练集',
{ id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' }, type: 'train',
{ id: 5, name: '通用能力评测', type: 'eval', storage_type: 'local', size: '64 MB', count: 3500, description: '通用能力评测数据集', create_time: '2026-01-12T11:30:00Z' }, storage_type: 'local',
{ id: 6, name: '医疗问答-训练集', type: 'train', storage_type: 'local', size: '180 MB', count: 9800, description: '医疗问答对', create_time: '2026-02-01T15:00:00Z' }, source: 'upload',
] size: '128 MB',
count: 8560,
description: '面向金融领域问答场景的高质量指令微调数据集',
create_time: '2025-12-20T08:00:00Z',
files: [
{ id: 'finance-train-jsonl', name: 'finance_train.jsonl', size: '86.4 MB' },
],
},
{ 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', task_id: 183921, size: '96 MB', count: 18240, description: '由客服问答数据清洗任务生成', create_time: '2026-07-08T06:28:00Z' },
{ id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', task_id: 492015, size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' },
{ id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', task_id: 731948, size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' },
{ id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', task_id: 582012, size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' },
] satisfies DatasetItem[]).map((dataset) => ({
...dataset,
files: dataset.files?.length
? dataset.files
: [{ id: `dataset-${dataset.id}-samples`, name: 'dataset_samples.jsonl', size: dataset.size || '12.8 MB' }],
}))
export const mockDatasetPreviews: Record<string, string> = {
'finance-train-jsonl': [
'{"instruction":"什么是净资产收益率?","input":"","output":"净资产收益率ROE用于衡量企业运用自有资本获得收益的能力。"}',
'{"instruction":"解释市盈率的含义","input":"某公司股价为 36 元,每股收益为 3 元。","output":"市盈率为股价除以每股收益,本例中市盈率为 12 倍。"}',
'{"instruction":"央行降准通常会带来什么影响?","input":"","output":"降准通常会释放银行体系流动性,降低资金成本,并增强信贷投放能力。"}',
'{"instruction":"如何理解债券久期?","input":"","output":"久期衡量债券价格对利率变化的敏感程度,久期越长,价格波动通常越大。"}',
'{"instruction":"基金定投适合怎样的投资者?","input":"","output":"基金定投适合希望分散择时风险、进行中长期纪律性投资的投资者。"}',
'{"instruction":"资产负债率过高意味着什么?","input":"","output":"可能意味着企业偿债压力较大、财务风险较高,但仍需结合行业特点判断。"}',
'{"instruction":"通货膨胀如何影响实际收益率?","input":"","output":"实际收益率约等于名义收益率减去通货膨胀率。"}',
'{"instruction":"什么是流动比率?","input":"","output":"流动比率等于流动资产除以流动负债,用于衡量短期偿债能力。"}',
'{"instruction":"股票分红是否等于投资者获得额外收益?","input":"","output":"不完全等同,除息后股价通常会相应调整,应结合总回报判断。"}',
'{"instruction":"如何区分系统性风险与非系统性风险?","input":"","output":"系统性风险影响整个市场,非系统性风险主要与单个公司或行业有关。"}',
'{"instruction":"什么是复利?","input":"","output":"复利是将本金和此前产生的收益一并计入下一期收益计算。"}',
'{"instruction":"现金流量表主要反映什么?","input":"","output":"现金流量表反映企业经营、投资和筹资活动产生的现金流入与流出。"}',
].join('\n'),
'finance-validation-jsonl': [
'{"instruction":"计算简单收益率","input":"买入价 100 元,卖出价 108 元,不考虑费用。","output":"简单收益率为 8%。"}',
'{"instruction":"什么是信用利差?","input":"","output":"信用利差是信用债收益率相对无风险基准收益率的差额。"}',
'{"instruction":"解释最大回撤","input":"","output":"最大回撤描述资产净值从历史高点到随后低点的最大跌幅。"}',
'{"instruction":"什么是风险溢价?","input":"","output":"风险溢价是投资者因承担额外风险而要求的超额预期收益。"}',
].join('\n'),
'dataset-readme': [
'# 金融问答训练集',
'',
'本数据集用于金融领域指令微调与基础能力评测。',
'',
'## 文件说明',
'',
'- `finance_train.jsonl`:训练样本',
'- `finance_validation.jsonl`:验证样本',
'',
'文本编码UTF-8',
].join('\n'),
'mock-jsonl': [
'{"instruction":"请概括以下文本的核心观点","input":"人工智能正在提升企业的数据处理效率。","output":"人工智能能够帮助企业提升数据处理效率。"}',
'{"instruction":"将用户问题改写为更清晰的表达","input":"这个功能咋用?","output":"请说明该功能的具体使用步骤。"}',
'{"instruction":"判断文本情感倾向","input":"这次服务响应很及时,问题也解决了。","output":"正向"}',
'{"instruction":"提取文本中的关键实体","input":"远光软件于周一发布了新的模型管理平台。","output":["远光软件","周一","模型管理平台"]}',
'{"instruction":"生成简短回复","input":"您好,我想了解数据集上传支持哪些格式?","output":"您好,目前支持 JSON、JSONL、CSV、TXT 等常见格式。"}',
'{"instruction":"对以下内容进行分类","input":"如何重置账户密码?","output":"账户与安全"}',
'{"instruction":"找出句子中的时间信息","input":"系统将在 7 月 15 日凌晨 2 点进行升级。","output":"7 月 15 日凌晨 2 点"}',
'{"instruction":"补全客服回复","input":"用户反馈页面加载缓慢。","output":"已收到您的反馈,我们正在排查页面加载问题,请稍后重试。"}',
].join('\n'),
'mock-readme': [
'# 数据集说明',
'',
'当前内容为前端 Mock 数据,用于预览页面布局与交互效果。',
'',
'## 文件结构',
'',
'- `dataset_samples.jsonl`:模拟的数据样本',
'- `README.md`:数据集使用说明',
'',
'文本编码UTF-8',
].join('\n'),
}
// ============ 训练任务 ============ // ============ 训练任务 ============
export const mockFineTuneList: FineTuneTask[] = [ export const mockFineTuneList: FineTuneTask[] = [
{ id: 1, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' }, {
{ id: 2, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' }, id: 103942,
{ id: 3, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' }, name: 'finance-sft-001',
{ id: 4, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' }, description: '金融领域 SFT 训练',
{ id: 5, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' }, status: 'completed',
{ id: 6, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' }, train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 1,
output_model_name: 'qwen2.5-7b-finance-sft-v1',
auto_merge: true,
gpus: [0],
batch_size: 8,
learning_rate: 0.00002,
n_epochs: 3,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 12345,
progress: 100,
train_duration: '2小时18分钟',
create_time: '2026-01-15T08:00:00Z',
},
{
id: 349102,
name: 'legal-sft-002',
description: '法律文书 SFT',
status: 'completed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 2,
output_model_name: 'qwen2.5-7b-legal-sft-v2',
auto_merge: true,
gpus: [1],
batch_size: 4,
learning_rate: 0.000015,
n_epochs: 4,
save_steps: 120,
lr_scheduler_type: 'linear',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.01,
lora_rank: 32,
lora_alpha: 64,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 12350,
progress: 100,
train_duration: '1小时46分钟',
create_time: '2026-01-18T10:00:00Z',
},
{
id: 849301,
name: 'medical-cpt-001',
description: '医疗领域继续预训练',
status: 'running',
train_type: 'CPT',
train_method: 'lora',
template: 'qwen2_5',
base_model: 2,
train_dataset_id: 6,
output_model_name: 'qwen2.5-14b-medical-cpt-v1',
auto_merge: true,
gpus: [0, 1],
batch_size: 2,
learning_rate: 0.0001,
n_epochs: 3,
save_steps: 200,
lr_scheduler_type: 'cosine',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 28741,
progress: 64,
train_duration: '36分钟',
create_time: '2026-07-13T06:40:00Z',
},
{
id: 593021,
name: 'service-dpo-001',
description: '客服对话偏好训练',
status: 'pending',
train_type: 'DPO',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 3,
output_model_name: 'qwen2.5-7b-service-dpo-v1',
auto_merge: true,
gpus: [2],
batch_size: 4,
learning_rate: 0.000005,
n_epochs: 2,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.1,
weight_decay: 0,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.1,
quantization_bit: 0,
progress: 0,
train_duration: '等待调度',
create_time: '2026-02-08T14:00:00Z',
},
{
id: 201948,
name: 'finance-sft-002',
description: '金融领域二轮微调',
status: 'failed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 1,
output_model_name: 'qwen2.5-7b-finance-sft-v2',
auto_merge: false,
gpus: [3],
batch_size: 8,
learning_rate: 0.00002,
n_epochs: 3,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 27654,
progress: 32,
train_duration: '18分钟',
create_time: '2026-02-10T11:00:00Z',
},
{
id: 940212,
name: 'general-sft-001',
description: '通用能力全参数微调',
status: 'completed',
train_type: 'SFT',
train_method: 'full',
template: 'llama3',
base_model: 3,
train_dataset_id: 3,
output_model_name: 'llama3-8b-general-sft-v1',
auto_merge: false,
gpus: [0, 2],
batch_size: 2,
learning_rate: 0.00001,
n_epochs: 2,
save_steps: 250,
lr_scheduler_type: 'cosine',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.1,
process_id: 26318,
progress: 100,
train_duration: '3小时05分钟',
create_time: '2026-02-12T13:00:00Z',
},
] ]
// ============ 模型推理/对比 ============ // ============ 模型推理/对比 ============
@@ -170,6 +572,139 @@ export const mockEvalList: EvalTask[] = [
{ id: 3, eval_task_name: '基线对比评测', model_name: 'Qwen2.5-7B-Instruct', dataset: '金融评测集', metric: 'accuracy', score: 72.3, status: 'running', create_time: '2026-02-10T09:00:00Z' }, { id: 3, eval_task_name: '基线对比评测', model_name: 'Qwen2.5-7B-Instruct', dataset: '金融评测集', metric: 'accuracy', score: 72.3, status: 'running', create_time: '2026-02-10T09:00:00Z' },
] ]
export const mockEvalDetails: EvalTaskDetail[] = [
{
...mockEvalList[0],
evaluator_model: 'GPT-4o',
sample_count: 3,
completed_count: 3,
passed_count: 3,
completed_time: '2026-02-01T10:18:00Z',
overall_score: 87.5,
overall_score_max: 100,
overall_evaluation: '模型在金融基础知识与常见计算题上表现稳定,答案整体准确、直接。',
improvement_suggestions: ['补充期限匹配、再平衡等资产配置知识', '在建议类回答中明确假设与风险边界', '增加复杂金融场景和反事实样本训练'],
dimension_summary: [
{ name: '核心事实正确性', score: 91, max_score: 100, pass_rate: 100 },
{ name: '信息完整性', score: 84, max_score: 100, pass_rate: 100 },
{ name: '无幻觉', score: 90, max_score: 100, pass_rate: 100 },
{ name: '格式合规性', score: 85, max_score: 100, pass_rate: 100 },
],
samples: [
{
id: 'finance-1', index: 1, status: 'completed', score: 1, max_score: 1, passed: true, judgement: '正确',
input: '某公司流动资产为 800 万元,流动负债为 500 万元,流动比率是多少?',
reference_answer: '流动比率为 1.6。',
model_output: '流动比率 = 流动资产 ÷ 流动负债 = 800 ÷ 500 = 1.6。',
evaluation_reason: '计算过程与结论均正确,并给出了必要公式。',
dimension_scores: [{ name: '准确性', score: 1, max_score: 1 }, { name: '清晰度', score: 1, max_score: 1 }],
},
{
id: 'finance-2', index: 2, status: 'completed', score: 1, max_score: 1, passed: true, judgement: '正确',
input: '简述央行提高存款准备金率对市场流动性的影响。',
reference_answer: '商业银行可贷资金减少,市场流动性通常收紧。',
model_output: '提高存款准备金率会减少银行可用于放贷的资金,通常使市场流动性收紧。',
evaluation_reason: '准确覆盖了传导路径,表述简洁。',
dimension_scores: [{ name: '准确性', score: 1, max_score: 1 }, { name: '完整性', score: 0.9, max_score: 1 }],
},
{
id: 'finance-3', index: 3, status: 'completed', score: 0.8, max_score: 1, passed: true, judgement: '部分正确',
input: '投资者风险承受能力较低时,资产配置应注意什么?',
reference_answer: '降低高波动资产比例,重视分散配置、流动性与本金安全。',
model_output: '应以低波动资产为主,分散投资并预留流动资金,同时控制权益类资产比例。',
evaluation_reason: '建议方向正确,但可进一步说明期限匹配与再平衡机制。',
dimension_scores: [{ name: '准确性', score: 0.9, max_score: 1 }, { name: '完整性', score: 0.7, max_score: 1 }],
},
],
},
{
...mockEvalList[1],
evaluator_model: 'Claude-3.5-Sonnet',
sample_count: 3,
completed_count: 3,
passed_count: 2,
completed_time: '2026-02-05T11:21:00Z',
overall_score: 0.82,
overall_score_max: 1,
overall_evaluation: '模型具备较好的客服语气和基础问题处理能力,但对订单状态约束的识别仍不稳定。',
improvement_suggestions: ['增加已发货、已出库等边界状态样本', '要求模型在承诺操作前先核验业务规则', '为每类客服场景补充标准处理步骤'],
dimension_summary: [
{ name: '核心事实正确性', score: 0.86, max_score: 1, pass_rate: 67 },
{ name: '信息完整性', score: 0.78, max_score: 1, pass_rate: 67 },
{ name: '无幻觉', score: 0.7, max_score: 1, pass_rate: 67 },
{ name: '格式合规性', score: 0.94, max_score: 1, pass_rate: 100 },
],
samples: [
{
id: 'service-1', index: 1, status: 'completed', score: 0.91, max_score: 1, passed: true, judgement: '正确',
input: '用户反馈收到的商品外包装破损,应如何回复?',
reference_answer: '表达歉意,核实订单及破损情况,并提供补发或退款方案。',
model_output: '很抱歉给您带来不便。请提供订单号和破损照片,我们核实后可为您安排补发或退款。',
evaluation_reason: '关键信息覆盖完整,语气符合客服场景。',
dimension_scores: [{ name: '相关性', score: 0.95, max_score: 1 }, { name: '服务语气', score: 0.96, max_score: 1 }],
},
{
id: 'service-2', index: 2, status: 'completed', score: 0.84, max_score: 1, passed: true, judgement: '部分正确',
input: '用户询问订单为何仍未发货。',
reference_answer: '致歉并核查订单状态,告知预计发货时间和后续查询方式。',
model_output: '抱歉让您久等了,我可以帮您核查订单状态,并尽快反馈预计发货时间。',
evaluation_reason: '态度友好,但未主动说明后续查询方式。',
dimension_scores: [{ name: '服务语气', score: 0.95, max_score: 1 }, { name: '完整性', score: 0.72, max_score: 1 }],
},
{
id: 'service-3', index: 3, status: 'completed', score: 0.69, max_score: 1, passed: false, judgement: '错误', error_type: '其他',
input: '用户要求立即取消已出库的订单。',
reference_answer: '说明已出库订单可能无法直接取消,协助拦截或引导拒收、退货。',
model_output: '好的,我现在为您取消订单,请稍候。',
evaluation_reason: '未说明已出库限制,承诺了可能无法完成的操作。',
dimension_scores: [{ name: '相关性', score: 0.7, max_score: 1 }, { name: '规则遵循', score: 0.35, max_score: 1 }],
},
],
},
{
...mockEvalList[2],
evaluator_model: 'GPT-4o',
sample_count: 3,
completed_count: 2,
passed_count: 1,
overall_score: 72.3,
overall_score_max: 100,
overall_evaluation: '当前为阶段性评价:模型基础计算能力正常,但逻辑推理表现波动,任务仍在运行。',
improvement_suggestions: ['补充形式逻辑与多步推理样本', '任务完成后再依据完整结果复核综合结论'],
dimension_summary: [
{ name: '核心事实正确性', score: 66.7, max_score: 100, pass_rate: 50 },
{ name: '信息完整性', score: 58, max_score: 100, pass_rate: 50 },
{ name: '无幻觉', score: 88, max_score: 100, pass_rate: 100 },
{ name: '格式合规性', score: 76, max_score: 100, pass_rate: 50 },
],
samples: [
{
id: 'baseline-1', index: 1, status: 'completed', score: 1, max_score: 1, passed: true, judgement: '正确',
input: '计算 15% 的 240。',
reference_answer: '36。',
model_output: '240 × 15% = 36。',
evaluation_reason: '计算与结果均正确。',
dimension_scores: [{ name: '准确性', score: 1, max_score: 1 }, { name: '清晰度', score: 1, max_score: 1 }],
},
{
id: 'baseline-2', index: 2, status: 'completed', score: 0, max_score: 1, passed: false, judgement: '错误', error_type: '其他',
input: '若所有 A 都是 B且某个 C 是 A可以推出什么',
reference_answer: '该 C 是 B。',
model_output: '无法确定 C 是否属于 B。',
evaluation_reason: '未正确应用包含关系的传递性。',
dimension_scores: [{ name: '准确性', score: 0, max_score: 1 }, { name: '逻辑性', score: 0, max_score: 1 }],
},
{
id: 'baseline-3', index: 3, status: 'pending', score: null, max_score: 1,
input: '用一句话解释什么是机会成本。',
reference_answer: '机会成本是选择某方案时放弃的最佳替代方案的价值。',
model_output: '',
evaluation_reason: '等待模型生成与评分。',
},
],
},
]
export const mockDimensions: Dimension[] = [ export const mockDimensions: Dimension[] = [
{ id: 1, name: '回答准确性', type: 'classification', description: '评估模型回答是否准确', eval_model: 'GPT-4o', eval_method: 'standard', eval_prompt: '# 角色\n你是专业的评估专家...', is_active: true, is_default: true, create_time: '2025-12-01T08:00:00Z' }, { id: 1, name: '回答准确性', type: 'classification', description: '评估模型回答是否准确', eval_model: 'GPT-4o', eval_method: 'standard', eval_prompt: '# 角色\n你是专业的评估专家...', is_active: true, is_default: true, create_time: '2025-12-01T08:00:00Z' },
{ id: 2, name: '综合评分', type: 'metric', description: '0-5 分综合评分', eval_model: 'Claude-3.5-Sonnet', eval_method: 'metric_standard', eval_prompt: '# 角色\n你是专业评分专家...', is_active: true, is_default: false, score_min: 0, score_max: 5, pass_threshold: 3.5, create_time: '2025-12-05T09:00:00Z' }, { id: 2, name: '综合评分', type: 'metric', description: '0-5 分综合评分', eval_model: 'Claude-3.5-Sonnet', eval_method: 'metric_standard', eval_prompt: '# 角色\n你是专业评分专家...', is_active: true, is_default: false, score_min: 0, score_max: 5, pass_threshold: 3.5, create_time: '2025-12-05T09:00:00Z' },
@@ -186,11 +721,48 @@ export const mockLogFiles: LogFile[] = [
] ]
export const mockTrainingLogFiles: TrainingLogFile[] = [ export const mockTrainingLogFiles: TrainingLogFile[] = [
{ file: 'medical-cpt-001_pid28741.log', name: 'medical-cpt-001', size: '6.8 MB', pid: 28741, date: '2026-07-13' },
{ file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' }, { file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' },
{ file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' }, { file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' },
{ file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' }, { file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' },
] ]
const medicalTrainingLogLines = [
'[2026-07-13 14:40:01] INFO: Launching distributed training with torchrun --nproc_per_node=2',
'[2026-07-13 14:40:02] INFO: Process rank: 0, world size: 2, device: cuda:0, distributed training: True',
'[2026-07-13 14:40:04] INFO: Loading tokenizer from /data/models/qwen2.5-14b-instruct',
'[2026-07-13 14:40:16] INFO: Loading checkpoint shards: 100% | 8/8 | 00:12',
'[2026-07-13 14:40:18] INFO: Loading dataset 医疗问答-训练集 (9,800 samples)',
'[2026-07-13 14:40:27] INFO: Tokenizing dataset: 100% | 9,800/9,800 | 00:09',
'[2026-07-13 14:40:28] INFO: LoRA config: rank=16, alpha=32, dropout=0.05, target_modules=q_proj,k_proj,v_proj,o_proj',
'[2026-07-13 14:40:29] INFO: Trainable params: 83,886,080 / 14,787,584,000 (0.5673%)',
'[2026-07-13 14:40:30] INFO: ***** Running training *****',
'[2026-07-13 14:40:30] INFO: Num examples = 9,800',
'[2026-07-13 14:40:30] INFO: Num Epochs = 3',
'[2026-07-13 14:40:30] INFO: Instantaneous batch size per device = 2',
'[2026-07-13 14:40:30] INFO: Total train batch size = 32',
'[2026-07-13 14:40:30] INFO: Gradient Accumulation steps = 8',
'[2026-07-13 14:40:30] INFO: Total optimization steps = 921',
'[2026-07-13 14:42:18] INFO: step=40 {\'loss\': 2.684, \'grad_norm\': 1.184, \'learning_rate\': 9.82e-05, \'epoch\': 0.13}',
'[2026-07-13 14:44:26] INFO: step=80 {\'loss\': 2.312, \'grad_norm\': 1.092, \'learning_rate\': 9.68e-05, \'epoch\': 0.26}',
'[2026-07-13 14:46:34] INFO: step=120 {\'loss\': 2.084, \'grad_norm\': 1.037, \'learning_rate\': 9.43e-05, \'epoch\': 0.39}',
'[2026-07-13 14:48:42] INFO: step=160 {\'loss\': 1.932, \'grad_norm\': 0.986, \'learning_rate\': 9.08e-05, \'epoch\': 0.52}',
'[2026-07-13 14:50:49] INFO: step=200 {\'loss\': 1.801, \'grad_norm\': 0.944, \'learning_rate\': 8.64e-05, \'epoch\': 0.65}',
'[2026-07-13 14:50:54] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-200',
'[2026-07-13 14:52:58] INFO: step=240 {\'loss\': 1.696, \'grad_norm\': 0.913, \'learning_rate\': 8.15e-05, \'epoch\': 0.78}',
'[2026-07-13 14:55:06] INFO: step=280 {\'loss\': 1.611, \'grad_norm\': 0.887, \'learning_rate\': 7.61e-05, \'epoch\': 0.91}',
'[2026-07-13 14:57:13] INFO: step=320 {\'loss\': 1.532, \'grad_norm\': 0.852, \'learning_rate\': 7.06e-05, \'epoch\': 1.04}',
'[2026-07-13 14:59:21] INFO: step=360 {\'loss\': 1.461, \'grad_norm\': 0.829, \'learning_rate\': 6.49e-05, \'epoch\': 1.17}',
'[2026-07-13 15:01:29] INFO: step=400 {\'loss\': 1.396, \'grad_norm\': 0.811, \'learning_rate\': 5.91e-05, \'epoch\': 1.30}',
'[2026-07-13 15:01:34] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-400',
'[2026-07-13 15:03:37] INFO: step=440 {\'loss\': 1.337, \'grad_norm\': 0.795, \'learning_rate\': 5.35e-05, \'epoch\': 1.43}',
'[2026-07-13 15:05:45] INFO: step=480 {\'loss\': 1.286, \'grad_norm\': 0.776, \'learning_rate\': 4.80e-05, \'epoch\': 1.56}',
'[2026-07-13 15:07:53] INFO: step=520 {\'loss\': 1.232, \'grad_norm\': 0.758, \'learning_rate\': 4.28e-05, \'epoch\': 1.69}',
'[2026-07-13 15:11:59] INFO: step=560 {\'loss\': 1.184, \'grad_norm\': 0.741, \'learning_rate\': 3.79e-05, \'epoch\': 1.82}',
'[2026-07-13 15:16:05] INFO: step=590 {\'loss\': 1.146, \'grad_norm\': 0.728, \'learning_rate\': 3.44e-05, \'epoch\': 1.92}',
'[2026-07-13 15:16:06] INFO: Training is running normally, estimated remaining time: 00:20:15',
]
const fakeLogLines = [ const fakeLogLines = [
"[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b", "[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b",
"[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)", "[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)",
@@ -228,3 +800,16 @@ export const mockLogContent: LogContent = {
size: '2.3 MB', size: '2.3 MB',
content: fakeLogLines.join('\n'), content: fakeLogLines.join('\n'),
} }
export const mockTrainingLogContents: Record<string, LogContent> = {
'medical-cpt-001_pid28741.log': {
file: 'medical-cpt-001_pid28741.log',
size: '6.8 MB',
content: medicalTrainingLogLines.join('\n'),
},
'qwen-ft-finance-001_pid12345.log': {
file: 'qwen-ft-finance-001_pid12345.log',
size: '4.5 MB',
content: fakeLogLines.join('\n'),
},
}

View File

@@ -0,0 +1,119 @@
export interface StoredDatasetVersion {
id: string
version: number
create_time: string
description?: string
content: string
}
export interface StoredDatasetVersionState {
versions: StoredDatasetVersion[]
active_version_id: string
/** 下次创建版本时使用的序号,确保已删除的版本号不会被复用。 */
next_version_number: number
}
export class DatasetVersionMutationError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
this.name = 'DatasetVersionMutationError'
}
}
export function createInitialVersionState(
content: string,
createTime = '2026-07-13T00:00:00Z',
): StoredDatasetVersionState {
const version: StoredDatasetVersion = {
id: 'v1',
version: 1,
create_time: createTime,
description: '初始版本',
content,
}
return { versions: [version], active_version_id: version.id, next_version_number: 2 }
}
/** 为旧版 localStorage 数据补齐单调递增的版本号游标。 */
export function normalizeDatasetVersionState(
state: Omit<StoredDatasetVersionState, 'next_version_number'> & {
next_version_number?: number
},
): StoredDatasetVersionState {
const minimumNextNumber = Math.max(0, ...state.versions.map((item) => item.version)) + 1
const nextVersionNumber = Number.isInteger(state.next_version_number)
? Math.max(minimumNextNumber, state.next_version_number as number)
: minimumNextNumber
return {
...state,
next_version_number: nextVersionNumber,
}
}
/** 追加不可变版本快照,并自动把新版本设为当前版本。 */
export function appendDatasetVersion(
state: StoredDatasetVersionState,
content: string,
createTime: string,
description = '在线编辑',
): StoredDatasetVersionState {
const nextNumber = state.next_version_number
const version: StoredDatasetVersion = {
id: `v${nextNumber}`,
version: nextNumber,
create_time: createTime,
description,
content,
}
return {
versions: [...state.versions, version],
active_version_id: version.id,
next_version_number: nextNumber + 1,
}
}
/** 删除非当前、非初始的历史版本,并保留版本号游标。 */
export function deleteDatasetVersion(
state: StoredDatasetVersionState,
versionId: string,
): StoredDatasetVersionState {
const version = state.versions.find((item) => item.id === versionId)
if (!version) {
throw new DatasetVersionMutationError('数据集版本不存在', 404)
}
if (state.versions.length === 1) {
throw new DatasetVersionMutationError('唯一版本不可删除', 400)
}
if (version.id === state.active_version_id) {
throw new DatasetVersionMutationError('当前版本不可删除,请先切换到其他版本', 409)
}
if (version.version === 1) {
throw new DatasetVersionMutationError('初始版本不可删除', 400)
}
return {
...state,
versions: state.versions.filter((item) => item.id !== versionId),
}
}
/** 仅移动当前版本指针,不修改任何历史快照。 */
export function activateDatasetVersion(
state: StoredDatasetVersionState,
versionId: string,
): StoredDatasetVersionState {
if (!state.versions.some((item) => item.id === versionId)) {
throw new Error('数据集版本不存在')
}
return { ...state, active_version_id: versionId }
}
export function getActiveDatasetVersion(state: StoredDatasetVersionState) {
const version = state.versions.find((item) => item.id === state.active_version_id)
if (!version) throw new Error('当前数据集版本不存在')
return version
}

View File

@@ -4,7 +4,7 @@
*/ */
import { use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts' import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { import {
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
@@ -15,7 +15,9 @@ import {
use([ use([
CanvasRenderer, CanvasRenderer,
BarChart,
LineChart, LineChart,
PieChart,
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
LegendComponent, LegendComponent,

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { PermissionCode } from '@/types'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -11,13 +12,13 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
component: () => import('@/layouts/MainLayout.vue'), component: () => import('@/layouts/MainLayout.vue'),
redirect: '/fine-tune', redirect: '/dashboard',
children: [ children: [
// 服务看板 // 服务看板
{ {
path: 'dashboard', path: 'dashboard',
name: 'dashboard', name: 'dashboard',
component: () => import('@/components/PlaceholderView.vue'), component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: '服务看板' }, meta: { title: '服务看板' },
}, },
// 模型调优 // 模型调优
@@ -25,7 +26,7 @@ const routes: RouteRecordRaw[] = [
path: 'fine-tune', path: 'fine-tune',
name: 'fine-tune', name: 'fine-tune',
component: () => import('@/views/fine-tune/FineTuneListView.vue'), component: () => import('@/views/fine-tune/FineTuneListView.vue'),
meta: { title: '模型微调', pageSurface: 'self' }, meta: { title: '模型训练', pageSurface: 'self' },
}, },
{ {
path: 'fine-tune/create', path: 'fine-tune/create',
@@ -46,11 +47,16 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/eval/EvalCreateView.vue'), component: () => import('@/views/eval/EvalCreateView.vue'),
meta: { title: '新建评测' }, meta: { title: '新建评测' },
}, },
{
path: 'model-eval/:id',
name: 'model-eval-detail',
component: () => import('@/views/eval/EvalDetailView.vue'),
meta: { title: '评测详情' },
},
{ {
path: 'model-eval/dimension/create', path: 'model-eval/dimension/create',
name: 'dimension-create', name: 'dimension-create',
component: () => import('@/views/eval/DimensionCreateView.vue'), redirect: '/model-eval/create',
meta: { title: '添加评测维度' },
}, },
{ {
path: 'model-eval/dimension/:id/edit', path: 'model-eval/dimension/:id/edit',
@@ -128,6 +134,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/data-process/DataProcessCreateView.vue'), component: () => import('@/views/data-process/DataProcessCreateView.vue'),
meta: { title: '新建数据处理任务', pageSurface: 'self' }, meta: { title: '新建数据处理任务', pageSurface: 'self' },
}, },
{
path: 'data-process/:id',
name: 'data-process-detail',
component: () => import('@/views/data-process/DataProcessDetailView.vue'),
meta: { title: '数据处理详情' },
},
// 数据集管理 // 数据集管理
{ {
path: 'dataset', path: 'dataset',
@@ -176,10 +188,16 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'data-convert', path: 'data-convert',
name: 'data-convert', name: 'data-convert',
component: () => import('@/components/PlaceholderView.vue'), component: () => import('@/views/data-convert/DataConvertView.vue'),
meta: { title: '数据类型转换' }, meta: { title: '数据类型转换' },
}, },
// 系统设置 // 系统设置
{
path: 'permission-denied',
name: 'permission-denied',
component: () => import('@/views/system/PermissionDeniedView.vue'),
meta: { title: '无权访问', skipPermission: true },
},
{ {
path: 'hardware', path: 'hardware',
name: 'hardware', name: 'hardware',
@@ -192,6 +210,24 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/LogsView.vue'), component: () => import('@/views/system/LogsView.vue'),
meta: { title: '查看日志' }, meta: { title: '查看日志' },
}, },
{
path: 'user-settings',
name: 'user-settings',
component: () => import('@/views/system/UserSettingsView.vue'),
meta: { title: '用户设置', pageSurface: 'self', permission: 'user-settings' },
},
{
path: 'user-settings/create',
name: 'user-create',
component: () => import('@/views/system/UserCreateView.vue'),
meta: { title: '创建用户', permission: 'user-settings' },
},
{
path: 'user-settings/:id/permission',
name: 'user-permission',
component: () => import('@/views/system/UserPermissionView.vue'),
meta: { title: '权限设置', permission: 'user-settings' },
},
{ {
path: 'training-log/:id', path: 'training-log/:id',
name: 'training-log', name: 'training-log',
@@ -202,7 +238,7 @@ const routes: RouteRecordRaw[] = [
}, },
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
redirect: '/fine-tune', redirect: '/dashboard',
}, },
] ]
@@ -211,6 +247,29 @@ const router = createRouter({
routes, routes,
}) })
const permissionBySegment: Record<string, PermissionCode> = {
dashboard: 'dashboard',
'fine-tune': 'fine-tune',
'training-log': 'fine-tune',
'model-eval': 'model-eval',
'model-inference': 'model-inference',
'model-compare': 'model-inference',
'model-manage': 'model-manage',
dataset: 'dataset',
'data-process': 'data-process',
'data-convert': 'data-convert',
tools: 'data-convert',
hardware: 'hardware',
logs: 'logs',
'user-settings': 'user-settings',
}
function requiredPermission(path: string, explicit?: unknown) {
if (explicit) return explicit as PermissionCode
const segment = path.split('/')[1]
return permissionBySegment[segment]
}
// 全局守卫:登录校验 + 会话超时 // 全局守卫:登录校验 + 会话超时
router.beforeEach((to, _from, next) => { router.beforeEach((to, _from, next) => {
const auth = useAuthStore() const auth = useAuthStore()
@@ -219,7 +278,7 @@ router.beforeEach((to, _from, next) => {
if (to.meta.public) { if (to.meta.public) {
// 已登录访问登录页则跳转主页 // 已登录访问登录页则跳转主页
if (to.name === 'login' && auth.isLoggedIn) { if (to.name === 'login' && auth.isLoggedIn) {
next('/fine-tune') next('/dashboard')
return return
} }
next() next()
@@ -231,6 +290,14 @@ router.beforeEach((to, _from, next) => {
return return
} }
if (!to.meta.skipPermission) {
const permission = requiredPermission(to.path, to.meta.permission)
if (permission && !auth.hasPermission(permission)) {
next({ name: 'permission-denied', replace: true })
return
}
}
// 续期会话 // 续期会话
auth.refresh() auth.refresh()
next() next()

View File

@@ -2,13 +2,63 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { login as loginApi } from '@/api/modules/system' import { login as loginApi } from '@/api/modules/system'
import { SESSION_TIMEOUT } from '@/constants' import { SESSION_TIMEOUT } from '@/constants'
import type { PermissionCode, SystemUser } from '@/types'
const USER_STORAGE_KEY = 'currentUser'
const allPermissions: PermissionCode[] = [
'dashboard',
'fine-tune',
'model-eval',
'model-inference',
'model-manage',
'dataset',
'data-process',
'data-convert',
'hardware',
'logs',
'user-settings',
]
function restoreUser(): SystemUser | null {
const persisted = localStorage.getItem(USER_STORAGE_KEY)
if (persisted) {
try {
return JSON.parse(persisted) as SystemUser
} catch {
localStorage.removeItem(USER_STORAGE_KEY)
}
}
// 兼容改造前已经登录的 admin 会话。
if (localStorage.getItem('username') === 'admin') {
return {
id: 'USR-0001',
username: 'admin',
display_name: '系统管理员',
role: 'admin',
status: 'active',
permissions: allPermissions,
create_time: '2026-01-01T08:00:00+08:00',
protected: true,
}
}
return null
}
/** /**
* 认证 store * 认证 store
* 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制 * 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制
*/ */
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const username = ref<string>(localStorage.getItem('username') || '') const currentUser = ref<SystemUser | null>(restoreUser())
const username = computed(() => currentUser.value?.username || '')
const displayName = computed(() => currentUser.value?.display_name || username.value)
const roleLabel = computed(() => {
if (currentUser.value?.role === 'admin') return '超级管理员'
if (currentUser.value?.role === 'operator') return '操作员'
return '观察员'
})
const loginTime = ref<number>(parseInt(localStorage.getItem('loginTime') || '0', 10) || 0) const loginTime = ref<number>(parseInt(localStorage.getItem('loginTime') || '0', 10) || 0)
const isLoggedIn = computed(() => { const isLoggedIn = computed(() => {
@@ -18,13 +68,20 @@ export const useAuthStore = defineStore('auth', () => {
/** 登录 */ /** 登录 */
async function login(user: string, password: string) { async function login(user: string, password: string) {
await loginApi(user, password) const response = await loginApi(user, password)
username.value = user currentUser.value = response.user
loginTime.value = Date.now() loginTime.value = Date.now()
localStorage.setItem('username', user) localStorage.setItem('username', response.user.username)
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
localStorage.setItem('loginTime', String(loginTime.value)) localStorage.setItem('loginTime', String(loginTime.value))
} }
/** 检查当前账号是否拥有指定模块权限。 */
function hasPermission(permission?: PermissionCode) {
if (!permission) return true
return currentUser.value?.permissions.includes(permission) ?? false
}
/** 续期会话(活跃时刷新) */ /** 续期会话(活跃时刷新) */
function refresh() { function refresh() {
if (isLoggedIn.value) { if (isLoggedIn.value) {
@@ -35,11 +92,23 @@ export const useAuthStore = defineStore('auth', () => {
/** 退出 */ /** 退出 */
function logout() { function logout() {
username.value = '' currentUser.value = null
loginTime.value = 0 loginTime.value = 0
localStorage.removeItem('username') localStorage.removeItem('username')
localStorage.removeItem(USER_STORAGE_KEY)
localStorage.removeItem('loginTime') localStorage.removeItem('loginTime')
} }
return { username, loginTime, isLoggedIn, login, refresh, logout } return {
currentUser,
username,
displayName,
roleLabel,
loginTime,
isLoggedIn,
hasPermission,
login,
refresh,
logout,
}
}) })

View File

@@ -32,7 +32,7 @@
--success-color: var(--el-color-success); --success-color: var(--el-color-success);
--sidebar-bg: #f9fafb; /* Light Gray */ --sidebar-bg: #f9fafb; /* Light Gray */
--sidebar-text: #606266; /* Regular Text */ --sidebar-text: #606266; /* Regular Text */
--sidebar-active-bg: #f1f5f9; /* Active Item Bg */ --sidebar-active-bg: #e2e8f0; /* Active Item Bg */
--sidebar-active-text: #303133; /* Darker Text for Active */ --sidebar-active-text: #303133; /* Darker Text for Active */
--card-shadow: 0 4px 24px rgba(0, 0, 0, 0.04); --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.04);
--header-height: 60px; --header-height: 60px;
@@ -124,10 +124,18 @@ body {
.el-button:active { .el-button:active {
transform: translateY(0); transform: translateY(0);
} }
.el-button.is-link:hover { .el-button.is-link:hover,
.el-button.is-text:hover {
transform: none; transform: none;
box-shadow: none; box-shadow: none;
} }
.el-button.is-link:focus,
.el-button.is-text:focus {
background-color: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
outline: none !important;
}
/* 4. 标签 (el-tag) 圆润化 */ /* 4. 标签 (el-tag) 圆润化 */
.el-tag { .el-tag {

View File

@@ -57,6 +57,7 @@ export interface ModelForm {
// ============ 数据集 ============ // ============ 数据集 ============
export type DatasetType = 'train' | 'test' | 'eval' | 'val' | 'other' export type DatasetType = 'train' | 'test' | 'eval' | 'val' | 'other'
export type DatasetStorage = 'local' | 'cloud' | 'minio' export type DatasetStorage = 'local' | 'cloud' | 'minio'
export type DatasetSource = 'upload' | 'task'
export interface DatasetFile { export interface DatasetFile {
id?: string id?: string
@@ -70,6 +71,8 @@ export interface DatasetItem {
name: string name: string
type: DatasetType | string type: DatasetType | string
storage_type: DatasetStorage | string storage_type: DatasetStorage | string
source?: DatasetSource
task_id?: string | number
size?: string | number size?: string | number
count?: number count?: number
description?: string description?: string
@@ -77,6 +80,19 @@ export interface DatasetItem {
files?: DatasetFile[] files?: DatasetFile[]
} }
export interface DatasetVersion {
id: string
version: number
create_time: string
description?: string
}
export interface DatasetVersionList {
versions: DatasetVersion[]
active_version_id: string
next_version_number?: number
}
// ============ 训练任务 ============ // ============ 训练任务 ============
export type TrainType = 'SFT' | 'DPO' | 'CPT' export type TrainType = 'SFT' | 'DPO' | 'CPT'
export type TrainMethodType = 'lora' | 'full' export type TrainMethodType = 'lora' | 'full'
@@ -92,6 +108,7 @@ export interface FineTuneTask {
template?: string template?: string
base_model: number | string base_model: number | string
train_dataset_id?: number | string train_dataset_id?: number | string
auto_merge?: boolean
output_model_name?: string output_model_name?: string
gpus?: number[] gpus?: number[]
batch_size?: number batch_size?: number
@@ -105,12 +122,55 @@ export interface FineTuneTask {
lora_alpha?: number lora_alpha?: number
lora_dropout?: number lora_dropout?: number
lora_rank?: number lora_rank?: number
/** 训练时量化位数QLoRA0=不量化 / 8 / 4 */
quantization_bit?: number
/** 训练完成后是否导出量化模型 */
export_quantized?: boolean
/** 导出量化方法bnb / gptq / awq / gguf */
quant_method?: 'bnb' | 'gptq' | 'awq' | 'gguf' | string
/** 导出量化位数 */
quant_bits?: number
/** 分组大小GPTQ / AWQ */
quant_group_size?: number
/** GGUF 导出格式 */
export_format?: string
progress?: number progress?: number
process_id?: number process_id?: number
train_duration?: string train_duration?: string
create_time?: string create_time?: string
} }
export type FineTuneStartPayload = Omit<
FineTuneTask,
'id' | 'status' | 'progress' | 'process_id' | 'train_duration' | 'create_time'
> & {
task_id: string | number
description: string
template: string
train_method: TrainMethodType
train_dataset_id: string | number
auto_merge: boolean
output_model_name: string
gpus: number[]
batch_size: number
learning_rate: number
n_epochs: number
save_steps: number
lr_scheduler_type: string
max_length: number
warmup_ratio: number
weight_decay: number
lora_alpha: number
lora_dropout: number
lora_rank: number
quantization_bit: number
export_quantized: boolean
quant_method: string
quant_bits: number
quant_group_size: number
export_format: string
}
export interface TrainingProgress { export interface TrainingProgress {
status?: string status?: string
progress?: number progress?: number
@@ -151,6 +211,7 @@ export interface CompareModelRef {
// ============ 模型评测 ============ // ============ 模型评测 ============
export type EvalType = 'custom' | 'baseline' export type EvalType = 'custom' | 'baseline'
export type DimensionType = 'classification' | 'metric' | 'text_similarity' export type DimensionType = 'classification' | 'metric' | 'text_similarity'
export type EvalErrorType = '混淆' | '不完整' | '格式偏差' | '幻觉' | '其他'
export interface EvalTask { export interface EvalTask {
id: number | string id: number | string
@@ -164,6 +225,75 @@ export interface EvalTask {
create_time?: string create_time?: string
} }
/** 启动评测时提交的可选基础指标配置。 */
export interface BasicEvalMetricsConfig {
bleu: {
enabled: boolean
ngram: number
}
rouge: {
enabled: boolean
methods: string[]
}
cosine: {
enabled: boolean
}
output_precision: number
}
export interface StartEvalPayload {
eval_task_name: string
eval_type: EvalType
model_id: string | number
gpu_id: string | number
dataset_id: string | number
dimension_id: string | number
data_source: 'dataset' | 'inference'
leaderboard: boolean
basic_metrics: BasicEvalMetricsConfig
}
/** 单个测试样本的评测结果 */
export interface EvalSampleResult {
id: number | string
index: number
input: string
reference_answer?: string
model_output: string
score: number | null
max_score?: number
passed?: boolean
status: 'completed' | 'pending' | 'failed'
judgement?: '正确' | '部分正确' | '错误'
evaluation_reason?: string
error_type?: EvalErrorType
dimension_scores?: Array<{
name: string
score: number
max_score: number
}>
}
/** 评测任务详情,包含逐样本结果和综合评价 */
export interface EvalTaskDetail extends EvalTask {
evaluator_model?: string
sample_count: number
completed_count: number
passed_count?: number
completed_time?: string
overall_score: number
overall_score_max: number
overall_evaluation: string
improvement_suggestions: string[]
dimension_summary: Array<{
name: string
score: number
max_score: number
pass_rate: number
}>
samples: EvalSampleResult[]
}
export interface Dimension { export interface Dimension {
id: number | string id: number | string
name: string name: string
@@ -183,6 +313,14 @@ export interface Dimension {
} }
// ============ 系统监控 ============ // ============ 系统监控 ============
export interface GpuProcess {
pid: number
name: string
memory_used_gb: number
task_name?: string
user?: string
}
export interface GpuInfo { export interface GpuInfo {
name: string name: string
gpu_percent: number gpu_percent: number
@@ -190,16 +328,26 @@ export interface GpuInfo {
memory_total_gb: number memory_total_gb: number
temperature: number temperature: number
power_w: number power_w: number
id?: number
uuid?: string
status?: 'idle' | 'busy' | 'warning' | 'offline'
memory_percent?: number
power_limit_w?: number
processes?: GpuProcess[]
fan_speed?: number fan_speed?: number
clock_mhz?: number clock_mhz?: number
driver_version?: string driver_version?: string
} }
export interface SystemInfo { export interface SystemInfo {
timestamp?: string
cpu?: { cpu?: {
percent: number percent: number
cores: number cores: number
percents?: number[] percents?: number[]
model?: string
frequency_mhz?: number
load_1m?: number
} }
memory?: { memory?: {
used_gb: number used_gb: number
@@ -212,11 +360,15 @@ export interface SystemInfo {
used_gb: number used_gb: number
total_gb: number total_gb: number
percent: number percent: number
read_mb_s?: number
write_mb_s?: number
} }
gpu?: GpuInfo[] gpu?: GpuInfo[]
network?: { network?: {
download_mb?: number download_mb?: number
upload_mb?: number upload_mb?: number
download_mb_s?: number
upload_mb_s?: number
} }
system?: { system?: {
uptime_seconds?: number uptime_seconds?: number
@@ -231,6 +383,57 @@ export interface HealthMetrics {
disk_percent?: number disk_percent?: number
} }
// ============ 用户与权限 ============
/** 页面级权限编码,与路由模块保持一一对应 */
export type PermissionCode =
| 'dashboard'
| 'fine-tune'
| 'model-eval'
| 'model-inference'
| 'model-manage'
| 'dataset'
| 'data-process'
| 'data-convert'
| 'hardware'
| 'logs'
| 'user-settings'
export type UserRole = 'admin' | 'operator' | 'viewer'
export type UserStatus = 'active' | 'disabled'
export interface SystemUser {
id: string
username: string
display_name: string
role: UserRole
status: UserStatus
permissions: PermissionCode[]
create_time: string
last_login?: string
protected?: boolean
}
export interface LoginResponse {
token: string
user: SystemUser
}
export interface CreateUserPayload {
username: string
display_name: string
password: string
role: UserRole
status?: UserStatus
permissions?: PermissionCode[]
}
export interface UpdateUserAccessPayload {
role?: UserRole
status?: UserStatus
permissions?: PermissionCode[]
}
// ============ 日志 ============ // ============ 日志 ============
export interface LogFile { export interface LogFile {
file: string file: string

View File

@@ -0,0 +1,975 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { EChartsOption } from 'echarts'
type ServiceState = 'normal' | 'busy' | 'error'
type TaskState = 'running' | 'pending' | 'completed' | 'failed'
interface ServiceStatus {
name: string
icon: string
state: ServiceState
instances: string
}
interface DashboardTask {
id: number
name: string
state: TaskState
trainType: string
trainMethod: string
baseModel: string
progress: number
accuracy: number | null
startedAt: string
}
interface LoginDurationStat {
id: number
username: string
duration: number
}
interface RecentLoginUser {
id: number
username: string
role: string
lastLogin: string
}
const router = useRouter()
const period = ref('7d')
const serviceStatuses: ServiceStatus[] = [
{ name: '模型推理', icon: 'fa-cube', state: 'normal', instances: '6 / 6' },
{ name: '模型微调', icon: 'fa-sliders', state: 'busy', instances: '4 / 6' },
{ name: '模型评测', icon: 'fa-bar-chart', state: 'normal', instances: '3 / 3' },
{ name: '数据处理', icon: 'fa-filter', state: 'error', instances: '1 / 3' },
]
const trainingTasks: DashboardTask[] = [
{
id: 103942,
name: 'finance-sft-003',
state: 'running',
trainType: 'SFT',
trainMethod: 'LoRA',
baseModel: 'Qwen2.5-7B-Instruct',
progress: 68,
accuracy: 89.2,
startedAt: '今天 09:18',
},
{
id: 593021,
name: 'legal-eval-008',
state: 'pending',
trainType: 'DPO',
trainMethod: 'LoRA',
baseModel: 'Qwen2.5-7B-Instruct',
progress: 0,
accuracy: null,
startedAt: '今天 08:55',
},
{
id: 849301,
name: 'medical-cpt-002',
state: 'completed',
trainType: 'CPT',
trainMethod: 'Full',
baseModel: 'Qwen2.5-14B-Instruct',
progress: 100,
accuracy: 91.6,
startedAt: '07/10 16:20',
},
{
id: 201948,
name: 'finance-sft-002',
state: 'failed',
trainType: 'SFT',
trainMethod: 'LoRA',
baseModel: 'Qwen2.5-7B-Instruct',
progress: 42,
accuracy: null,
startedAt: '07/10 11:08',
},
]
const serviceStateMeta: Record<ServiceState, { label: string; className: string }> = {
normal: { label: '正常', className: 'is-normal' },
busy: { label: '繁忙', className: 'is-busy' },
error: { label: '异常', className: 'is-error' },
}
const taskStateMeta: Record<TaskState, { label: string; className: string }> = {
running: { label: '训练中', className: 'is-running' },
pending: { label: '等待中', className: 'is-pending' },
completed: { label: '已完成', className: 'is-completed' },
failed: { label: '训练失败', className: 'is-failed' },
}
const progressColor: Record<TaskState, string> = {
running: '#4f46e5',
pending: '#cbd5e1',
completed: '#10b981',
failed: '#ef4444',
}
const chartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
color: ['#4f46e5', '#10b981', '#f59e0b'],
grid: { top: 50, right: 50, bottom: 38, left: 46, containLabel: false },
legend: {
top: 4,
left: 0,
itemWidth: 12,
itemHeight: 12,
itemGap: 22,
textStyle: { color: '#64748b', fontSize: 11 },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
padding: [10, 12],
textStyle: { color: '#ffffff', fontSize: 12 },
valueFormatter: (value) => `${value}`,
},
xAxis: {
type: 'category',
data: ['07/05', '07/06', '07/07', '07/08', '07/09', '07/10', '07/11\n今天'],
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisTick: { show: false },
axisLabel: { color: '#64748b', fontSize: 11, lineHeight: 16, margin: 12 },
},
yAxis: [
{
type: 'value',
name: '次数 / GPU 数',
min: 0,
max: 20,
interval: 5,
nameTextStyle: { color: '#64748b', fontSize: 11, align: 'left' },
axisLabel: { color: '#64748b', fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#eef2f7' } },
},
{
type: 'value',
name: '准确率',
min: 0,
max: 100,
interval: 25,
nameTextStyle: { color: '#64748b', fontSize: 11, align: 'right' },
axisLabel: { color: '#64748b', fontSize: 11, formatter: '{value}%' },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
],
series: [
{
name: '训练次数(次)',
type: 'bar',
data: [8, 12, 10, 15, 13, 18, 11],
barMaxWidth: 16,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
},
{
name: 'GPU 使用数(个)',
type: 'bar',
data: [3, 4, 4, 6, 5, 7, 5],
barMaxWidth: 16,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
},
{
name: '平均准确率(%',
type: 'bar',
yAxisIndex: 1,
data: [82, 85, 84, 88, 87, 91, 89],
barMaxWidth: 16,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: { show: true, position: 'top', color: '#d97706', fontSize: 10 },
},
],
}))
const operationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
tooltip: { trigger: 'item' },
color: ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899'],
series: [
{
name: '操作分类',
type: 'pie',
radius: ['40%', '64%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
position: 'outside',
formatter: '{b}',
color: '#475569',
fontSize: 11,
lineHeight: 16,
width: 70,
overflow: 'truncate',
},
emphasis: {
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' }
},
labelLine: {
show: true,
length: 10,
length2: 8,
lineStyle: { color: '#94a3b8', width: 1 },
},
data: [
{ value: 1048, name: '模型训练' },
{ value: 735, name: '数据处理' },
{ value: 580, name: '模型评测' },
{ value: 484, name: '模型推理' },
{ value: 300, name: '系统设置' }
]
}
]
}))
const loginDurationStats: LoginDurationStat[] = [
{ id: 1, username: 'admin', duration: 124 },
{ id: 2, username: 'zhangsan', duration: 86 },
{ id: 3, username: 'lisi', duration: 42 },
{ id: 4, username: 'wangwu', duration: 18 },
]
const loginDurationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
grid: { top: 8, right: 40, bottom: 6, left: 8, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (value) => `${value} 小时`,
},
xAxis: {
type: 'value',
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) / 10) * 10,
splitNumber: 4,
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#eef2f7' } },
},
yAxis: {
type: 'category',
inverse: true,
data: loginDurationStats.map((user) => user.username),
axisLabel: { color: '#475569', fontSize: 12 },
axisLine: { show: false },
axisTick: { show: false },
},
series: [
{
name: '登录时长',
type: 'bar',
data: loginDurationStats.map((user) => user.duration),
barMaxWidth: 18,
barCategoryGap: '34%',
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', distance: 8, color: '#64748b', fontSize: 11, formatter: '{c} 小时' },
},
],
}))
const recentLoginUsers: RecentLoginUser[] = [
{ id: 1, username: 'admin', role: '超级管理员', lastLogin: '10 分钟前' },
{ id: 2, username: 'zhangsan', role: '操作员', lastLogin: '2 小时前' },
{ id: 5, username: 'zhaoliu', role: '观察员', lastLogin: '5 小时前' },
{ id: 3, username: 'lisi', role: '操作员', lastLogin: '昨天 15:30' },
]
const roleTagType: Record<string, 'danger' | 'primary' | 'info'> = {
'超级管理员': 'danger',
'操作员': 'primary',
'观察员': 'info',
}
function viewAllTasks() {
router.push('/fine-tune')
}
function viewTask(task: DashboardTask) {
router.push(`/training-log/${task.id}`)
}
</script>
<template>
<div class="dashboard-view">
<section class="overview-section" aria-labelledby="overview-title">
<h2 id="overview-title" class="section-title">平台运行状态</h2>
<div class="overview-content">
<div class="health-summary">
<div class="health-icon" aria-hidden="true"><i class="fa fa-check" /></div>
<div>
<strong>整体运行正常</strong>
<p>平台运行良好各项服务稳定</p>
</div>
</div>
<div class="overview-metrics">
<div class="overview-metric">
<span>在线服务</span>
<strong>12</strong>
<small>全部在线</small>
</div>
<div class="overview-metric">
<span>运行中任务</span>
<strong>5</strong>
<small>较昨日 +1</small>
</div>
<div class="overview-metric is-alert">
<span>待处理告警</span>
<strong>2</strong>
<small>较昨日 -1</small>
</div>
</div>
</div>
</section>
<div class="dashboard-middle">
<section class="chart-section" aria-labelledby="chart-title">
<div class="section-heading-row">
<h2 id="chart-title" class="section-title"> 7 天训练统计</h2>
<el-select v-model="period" class="period-select" aria-label="统计周期">
<el-option label="近7天" value="7d" />
</el-select>
</div>
<VChart class="training-chart" :option="chartOption" autoresize />
</section>
<section class="service-section" aria-labelledby="service-title">
<h2 id="service-title" class="section-title">服务状态</h2>
<div class="service-table" role="table" aria-label="服务状态">
<div class="service-row service-header" role="row">
<span role="columnheader">服务名称</span>
<span role="columnheader">状态</span>
<span role="columnheader">实例数</span>
</div>
<div v-for="service in serviceStatuses" :key="service.name" class="service-row" role="row">
<span class="service-name" role="cell">
<i class="fa" :class="service.icon" aria-hidden="true" />
{{ service.name }}
</span>
<span role="cell">
<span class="state-pill" :class="serviceStateMeta[service.state].className">
{{ serviceStateMeta[service.state].label }}
</span>
</span>
<span class="instance-count" role="cell">{{ service.instances }}</span>
</div>
</div>
</section>
</div>
<div class="user-stats-row">
<section class="stat-card" aria-labelledby="op-dist-title">
<h2 id="op-dist-title" class="section-title">用户操作分布</h2>
<div class="chart-container">
<VChart class="pie-chart" :option="operationChartOption" autoresize />
</div>
</section>
<section class="stat-card" aria-labelledby="login-dur-title">
<h2 id="login-dur-title" class="section-title">登录时长排行 (本月)</h2>
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
</section>
<section class="stat-card" aria-labelledby="recent-login-title">
<h2 id="recent-login-title" class="section-title">最近登录用户</h2>
<div class="list-container">
<div v-for="user in recentLoginUsers" :key="user.id" class="list-item recent-user-item">
<div class="user-info">
<el-avatar :size="32" class="user-avatar" :style="{ backgroundColor: '#e2e8f0', color: '#475569' }">
{{ user.username.slice(0, 1).toUpperCase() }}
</el-avatar>
<div class="user-meta">
<span class="username">{{ user.username }}</span>
<span class="login-time">{{ user.lastLogin }}</span>
</div>
</div>
<el-tag :type="roleTagType[user.role]" size="small">{{ user.role }}</el-tag>
</div>
</div>
</section>
</div>
<section class="tasks-section" aria-labelledby="tasks-title">
<div class="section-heading-row tasks-heading">
<h2 id="tasks-title" class="section-title">训练任务</h2>
<el-button link type="primary" @click="viewAllTasks">查看全部任务</el-button>
</div>
<div class="tasks-table-wrap">
<table class="tasks-table">
<thead>
<tr>
<th>任务名称</th>
<th>任务状态</th>
<th>训练方式</th>
<th>基座模型</th>
<th>训练进度</th>
<th>准确率</th>
<th>开始时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="task in trainingTasks" :key="task.id">
<td class="task-name">{{ task.name }}</td>
<td>
<span class="task-state" :class="taskStateMeta[task.state].className">
{{ taskStateMeta[task.state].label }}
</span>
</td>
<td>{{ task.trainType }} / {{ task.trainMethod }}</td>
<td>{{ task.baseModel }}</td>
<td>
<div class="progress-cell">
<el-progress
:percentage="task.progress"
:stroke-width="6"
:show-text="false"
:color="progressColor[task.state]"
/>
<span>{{ task.progress }}%</span>
</div>
</td>
<td class="numeric-value">{{ task.accuracy == null ? '--' : `${task.accuracy}%` }}</td>
<td>{{ task.startedAt }}</td>
<td>
<el-button link type="primary" @click="viewTask(task)">查看详情</el-button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
.dashboard-view {
display: flex;
flex-direction: column;
min-height: 100%;
gap: 16px;
min-width: 0;
color: #1e293b;
}
.overview-section,
.chart-section,
.service-section,
.stat-card,
.tasks-section {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
}
.user-stats-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.stat-card {
display: flex;
flex-direction: column;
padding: 16px 18px;
min-height: 284px;
min-width: 0;
}
.chart-container {
flex: 1 1 auto;
min-height: 224px;
margin-top: 10px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.pie-chart {
width: 100%;
height: 224px;
}
.duration-chart {
width: 100%;
height: 224px;
margin-top: 10px;
}
.list-container {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
font-size: 14px;
font-weight: 600;
}
.username {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.user-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.login-time {
font-size: 11px;
color: #94a3b8;
}
.recent-user-item {
padding: 2px 0;
}
.section-title {
margin: 0;
font-size: 15px;
line-height: 1.4;
font-weight: 600;
color: #1e293b;
}
.overview-section {
padding: 12px 14px 13px;
}
.overview-content {
display: grid;
grid-template-columns: minmax(300px, 1.05fr) minmax(540px, 1.75fr);
align-items: stretch;
margin-top: 10px;
}
.health-summary {
display: flex;
align-items: center;
gap: 14px;
min-height: 86px;
padding: 6px 18px 6px 12px;
border-right: 1px solid #e2e8f0;
strong {
display: block;
color: #10b981;
font-size: 20px;
line-height: 1.3;
font-weight: 700;
}
p {
margin: 5px 0 0;
color: #64748b;
font-size: 13px;
}
}
.health-icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
flex: 0 0 56px;
border-radius: 50%;
background: #10b981;
color: #ffffff;
font-size: 26px;
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.14);
}
.overview-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.overview-metric {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 0;
border-right: 1px solid #e2e8f0;
&:last-child {
border-right: none;
}
span,
small {
color: #64748b;
font-size: 12px;
}
strong {
font-size: 26px;
line-height: 1;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
&.is-alert strong {
color: #ef4444;
}
}
.dashboard-middle {
display: grid;
grid-template-columns: minmax(0, 1.9fr) minmax(300px, 0.82fr);
flex: 0 0 auto;
gap: 16px;
min-height: 0;
}
.chart-section,
.service-section {
display: flex;
flex-direction: column;
min-height: 360px;
padding: 16px 18px;
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.period-select {
width: 94px;
:deep(.el-select__wrapper) {
min-height: 30px;
}
}
.training-chart {
flex: 1 1 auto;
width: 100%;
height: 300px;
min-height: 300px;
}
.service-table {
display: grid;
grid-template-rows: 36px repeat(4, minmax(48px, 1fr));
flex: 1 1 auto;
margin-top: 12px;
min-height: 0;
}
.service-row {
display: grid;
grid-template-columns: minmax(130px, 1.35fr) minmax(72px, 0.7fr) 72px;
align-items: center;
min-height: 48px;
border-top: 1px solid #eef2f7;
color: #475569;
font-size: 13px;
}
.service-header {
min-height: 36px;
border-top: none;
color: #64748b;
font-size: 12px;
font-weight: 600;
}
.service-name {
display: flex;
align-items: center;
gap: 10px;
color: #334155;
font-weight: 500;
.fa {
width: 18px;
color: #64748b;
text-align: center;
}
}
.state-pill,
.task-state {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.is-normal,
.is-completed {
color: #059669;
background: #ecfdf5;
}
.is-busy,
.is-pending {
color: #d97706;
background: #fffbeb;
}
.is-error,
.is-failed {
color: #dc2626;
background: #fef2f2;
}
.is-running {
color: #4f46e5;
background: #eef2ff;
}
.instance-count,
.numeric-value {
font-variant-numeric: tabular-nums;
}
.tasks-section {
padding: 12px 0 0;
overflow: hidden;
}
.tasks-heading {
min-height: 32px;
padding: 0 14px 10px;
}
.tasks-table-wrap {
overflow-x: auto;
}
.tasks-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
color: #475569;
font-size: 12px;
th,
td {
height: 42px;
padding: 6px 10px;
border-top: 1px solid #eef2f7;
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
th {
height: 36px;
background: #f8fafc;
color: #475569;
font-weight: 600;
}
th:nth-child(1) { width: 14%; }
th:nth-child(2) { width: 10%; }
th:nth-child(3) { width: 11%; }
th:nth-child(4) { width: 17%; }
th:nth-child(5) { width: 17%; }
th:nth-child(6) { width: 9%; }
th:nth-child(7) { width: 12%; }
th:nth-child(8) { width: 10%; }
}
.task-name {
color: #334155;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-cell {
display: grid;
grid-template-columns: minmax(72px, 1fr) 38px;
align-items: center;
gap: 8px;
font-variant-numeric: tabular-nums;
:deep(.el-progress-bar__outer) {
background: #e2e8f0;
}
}
@media (max-height: 900px) and (min-width: 1181px) {
.section-title {
font-size: 14px;
}
.overview-section {
padding: 8px 12px 9px;
}
.overview-content {
margin-top: 6px;
}
.health-summary {
gap: 10px;
min-height: 64px;
padding: 4px 14px 4px 8px;
strong {
font-size: 18px;
}
p {
margin-top: 3px;
font-size: 12px;
}
}
.health-icon {
width: 44px;
height: 44px;
flex-basis: 44px;
font-size: 20px;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.12);
}
.overview-metric {
gap: 2px;
span,
small {
font-size: 11px;
}
strong {
font-size: 23px;
}
}
.service-table {
grid-template-rows: 32px repeat(4, minmax(40px, 1fr));
margin-top: 8px;
}
.service-row {
min-height: 40px;
font-size: 12px;
}
.service-header {
min-height: 32px;
font-size: 11px;
}
.state-pill,
.task-state {
min-height: 20px;
padding-inline: 7px;
font-size: 11px;
}
.tasks-section {
padding-top: 8px;
}
.tasks-heading {
min-height: 28px;
padding: 0 12px 8px;
}
.tasks-table {
th,
td {
height: 36px;
padding: 4px 8px;
}
th {
height: 32px;
}
}
}
@media (max-width: 1180px) {
.overview-content {
grid-template-columns: 1fr;
}
.health-summary {
border-right: none;
border-bottom: 1px solid #e2e8f0;
}
.overview-metrics {
min-height: 94px;
}
.dashboard-middle {
grid-template-columns: 1fr;
}
.user-stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.user-stats-row {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-view * {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
</style>

View File

@@ -0,0 +1,305 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
const settings = reactive({
outputName: 'converted-data',
encoding: 'UTF-8',
})
function showPrototypeNotice() {
ElMessage.info('当前仅完成界面设计,转换功能将在后续接入')
}
function resetSettings() {
Object.assign(settings, {
outputName: 'converted-data',
encoding: 'UTF-8',
})
}
</script>
<template>
<PageCard
class="data-convert-page"
title="数据类型转换"
subtitle="将 JSON 文件转换为便于训练和评测使用的 JSONL 格式"
>
<div class="converter-panel">
<div class="panel-header">
<div class="tool-icon" aria-hidden="true">
<i class="fa fa-exchange" />
</div>
<div>
<h3>JSON JSONL</h3>
<p>每条 JSON 数据将输出为 JSONL 文件中的一行记录</p>
</div>
</div>
<el-form class="converter-form" label-position="top">
<el-form-item label="转换类型">
<div class="format-field" aria-label="JSON JSONL">
<span>JSON</span>
<i class="fa fa-long-arrow-right" aria-hidden="true" />
<span>JSONL</span>
</div>
</el-form-item>
<el-form-item label="源文件" required>
<button class="upload-zone" type="button" @click="showPrototypeNotice">
<i class="fa fa-cloud-upload" aria-hidden="true" />
<span class="upload-content">
<strong>点击选择或拖拽 JSON 文件到此处</strong>
<small>仅支持 .json 格式单文件不超过 200 MB</small>
</span>
<span class="select-button">选择文件</span>
</button>
</el-form-item>
<div class="form-row">
<el-form-item label="输出文件名">
<el-input v-model="settings.outputName">
<template #append>.jsonl</template>
</el-input>
</el-form-item>
<el-form-item label="字符编码">
<el-select v-model="settings.encoding" style="width: 100%">
<el-option label="UTF-8" value="UTF-8" />
</el-select>
</el-form-item>
</div>
<div class="format-tip">
<i class="fa fa-info-circle" aria-hidden="true" />
<span>支持由 JSON 数组转换为 JSONL每个数组元素输出为一行</span>
</div>
</el-form>
<div class="panel-footer">
<span class="prototype-label">当前为 UI 原型暂不执行实际转换</span>
<div class="actions">
<el-button @click="resetSettings">重置</el-button>
<el-tooltip content="转换功能将在后续开发中接入" placement="top">
<span><el-button type="primary" disabled>开始转换</el-button></span>
</el-tooltip>
</div>
</div>
</div>
</PageCard>
</template>
<style scoped lang="scss">
.converter-panel {
width: 100%;
min-height: calc(100vh - 220px);
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
display: flex;
flex-direction: column;
}
.panel-header {
min-height: 72px;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
background: #fafafa;
display: flex;
align-items: center;
gap: 12px;
box-sizing: border-box;
.tool-icon {
width: 38px;
height: 38px;
flex: 0 0 auto;
border-radius: 6px;
background: var(--el-color-primary-light-9);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
}
h3 {
margin: 0;
color: #303133;
font-size: 15px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: #909399;
font-size: 12px;
}
}
.converter-form {
flex: 1;
padding: 22px 24px 6px;
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item__label) {
padding-bottom: 8px;
color: #606266;
font-size: 13px;
}
}
.format-field {
width: 100%;
min-height: 40px;
padding: 0 14px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f5f7fa;
color: #303133;
display: flex;
align-items: center;
gap: 14px;
box-sizing: border-box;
font-size: 13px;
font-weight: 500;
i {
color: #909399;
}
}
.upload-zone {
width: 100%;
min-height: 112px;
padding: 20px;
border: 1px dashed #b8c4d1;
border-radius: 6px;
background: #fafcff;
color: #606266;
cursor: pointer;
font: inherit;
display: flex;
align-items: center;
gap: 14px;
text-align: left;
transition: border-color 0.2s ease, background 0.2s ease;
&:hover,
&:focus-visible {
border-color: var(--primary-color);
background: var(--el-color-primary-light-9);
outline: none;
}
> i {
color: var(--primary-color);
font-size: 24px;
}
.upload-content {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
}
strong {
color: #303133;
font-size: 13px;
font-weight: 500;
}
small {
color: #909399;
font-size: 12px;
}
.select-button {
min-height: 32px;
padding: 0 14px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
color: #606266;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
}
.form-row {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr);
gap: 16px;
}
.format-tip {
min-height: 38px;
padding: 9px 12px;
border-radius: 4px;
background: var(--el-color-primary-light-9);
color: #606266;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
font-size: 12px;
i {
color: var(--primary-color);
}
}
.panel-footer {
min-height: 64px;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #fafafa;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
box-sizing: border-box;
.prototype-label {
color: #909399;
font-size: 12px;
}
.actions {
display: flex;
gap: 10px;
}
}
@media (max-width: 640px) {
.converter-form {
padding: 18px 16px 4px;
}
.form-row {
grid-template-columns: 1fr;
gap: 0;
}
.upload-zone {
align-items: flex-start;
flex-wrap: wrap;
.select-button {
margin-left: 38px;
}
}
.panel-footer {
padding: 12px 16px;
align-items: flex-end;
flex-direction: column;
}
}
</style>

View File

@@ -1,67 +1,123 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus' import { ElMessage, type UploadFile } from 'element-plus'
import { storeToRefs } from 'pinia'
import AppConfirmDialog from '@/components/AppConfirmDialog.vue'
import TaskSetupStep from './create/TaskSetupStep.vue' import TaskSetupStep from './create/TaskSetupStep.vue'
import ModelSelectionStep from './create/ModelSelectionStep.vue'
import SourceUploadStep from './create/SourceUploadStep.vue'
import PreviewCompareStep from './create/PreviewCompareStep.vue' import PreviewCompareStep from './create/PreviewCompareStep.vue'
import GenerationStep from './create/GenerationStep.vue' import GenerationStep from './create/GenerationStep.vue'
import ResultEditorStep from './create/ResultEditorStep.vue' import ResultEditorStep from './create/ResultEditorStep.vue'
import { buildPreviewItems, createResults, DEFAULT_SOURCE_TEXT } from './create/previewModel' import { buildPreviewItems, DEFAULT_SOURCE_TEXT } from './create/previewModel'
import type { GenerationState, PreviewItem, ProcessType, ResultItem, StepId } from './create/types' import {
createDefaultStructuredOptions,
createDefaultUnstructuredOptions,
} from './create/dataProcessCreateState'
import {
DATA_PROCESS_DRAFT_STORAGE_KEY,
useDataProcessDraft,
} from './create/useDataProcessDraft'
import { useDataProcessGeneration } from './create/useDataProcessGeneration'
import { useModelsStore } from '@/stores/models'
import type {
ExternalDataSource,
GenerationControlOptions,
PreviewItem,
ProcessType,
StepId,
StructuredProcessOptions,
UnstructuredProcessOptions,
UploadedDataFile,
} from './create/types'
const router = useRouter() const router = useRouter()
const modelsStore = useModelsStore()
const { list: modelList } = storeToRefs(modelsStore)
const generationModels = computed(() => modelList.value.filter((model) => model.type === 'LLM'))
const taskSetupRef = ref<InstanceType<typeof TaskSetupStep>>() const taskSetupRef = ref<InstanceType<typeof TaskSetupStep>>()
const DRAFT_STORAGE_KEY = 'yg-data-process-create-draft' const modelSelectionRef = ref<InstanceType<typeof ModelSelectionStep>>()
const confirmDialogRef = ref<InstanceType<typeof AppConfirmDialog>>()
const PREVIEW_MODEL_VERSION = 'document-chunk-v2'
const WIZARD_STEPS = [ const WIZARD_STEPS = [
{ id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' }, { id: 'create', title: '创建任务', desc: '填写任务信息与处理配置' },
{ id: 'model', title: '大模型选择', desc: '选择生成模型并设置输出要求' },
{ id: 'upload', title: '上传文件', desc: '上传或接入待处理的源数据' },
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' }, { id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' }, { id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' }, { id: 'results', title: '编辑与保存', desc: '检查、修改并保存结果' },
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }> ] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
const currentStep = ref(0) const currentStep = ref(0)
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create') const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')
const task = reactive({ name: '', description: '' }) const task = reactive({ name: '', description: '' })
const processType = ref<ProcessType>('unstructured') const processType = ref<ProcessType>('structured')
interface UploadedDataFile { const structuredOptions = ref<StructuredProcessOptions>(createDefaultStructuredOptions())
uid: number | string const unstructuredOptions = ref<UnstructuredProcessOptions>(createDefaultUnstructuredOptions())
name: string const modelSelectionOptions = computed<GenerationControlOptions>(() => (
size: number processType.value === 'unstructured' ? unstructuredOptions.value : structuredOptions.value
count: number ))
content: string
}
const uploadedFiles = ref<UploadedDataFile[]>([]) const uploadedFiles = ref<UploadedDataFile[]>([])
const externalSource = reactive<ExternalDataSource>({
type: 'mysql',
url: '',
authMode: 'none',
username: '',
password: '',
token: '',
limit: 1000,
})
const externalPulling = ref(false)
const externalConnected = ref(false)
let connectionTimer: ReturnType<typeof setTimeout> | null = null
let pullTimer: ReturnType<typeof setTimeout> | null = null
const fileName = computed(() => uploadedFiles.value.map(f => f.name).join(', ')) const fileName = computed(() => uploadedFiles.value.map(f => f.name).join(', '))
const fileSize = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.size, 0))
const fileCount = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.count, 0))
const previewSignature = ref('') const previewSignature = ref('')
const previewItems = ref<PreviewItem[]>([]) const previewItems = ref<PreviewItem[]>([])
const selectedPreviewFileId = ref<string | null>(null) const selectedPreviewFileId = ref<string | null>(null)
const selectedPreviewId = ref<string | null>(null) const selectedPreviewId = ref<string | null>(null)
const selectedPreviewIdsByFile = ref<Record<string, string>>({}) const selectedPreviewIdsByFile = ref<Record<string, string>>({})
const results = ref<ResultItem[]>([])
const selectedResultId = ref<string | null>(null)
const dirty = ref(false) const dirty = ref(false)
const restoringDraft = ref(false) const restoringDraft = ref(false)
let allowLeave = false let allowLeave = false
const generation = reactive<GenerationState>({ const {
status: 'idle', generation,
progress: 0, results,
message: '确认摘要后即可开始生成,过程中可查看实时进度。', selectedResultId,
resetDownstream,
restoreResult,
startGeneration,
stopGeneration,
stopGenerationTimer,
updateResultField,
validateResults,
} = useDataProcessGeneration({
previewItems,
processType,
structuredOptions,
unstructuredOptions,
dirty,
}) })
let generationTimer: ReturnType<typeof setInterval> | null = null
const modifiedPreviewCount = computed(() => previewItems.value.filter((item) => item.status !== 'original').length) const modifiedPreviewCount = computed(() => previewItems.value.filter((item) => item.status !== 'original').length)
const previewItemsByFile = computed(() => {
const grouped = new Map<string, PreviewItem[]>()
for (const item of previewItems.value) {
const items = grouped.get(item.sourceFileId) || []
items.push(item)
grouped.set(item.sourceFileId, items)
}
return grouped
})
const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value)) const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value))
const activePreviewItems = computed(() => previewItems.value.filter((item) => item.sourceFileId === selectedPreviewFileId.value)) const activePreviewItems = computed(() => previewItemsByFile.value.get(selectedPreviewFileId.value || '') || [])
const activeSourceText = computed(() => activePreviewFile.value?.content ?? '') const activeSourceText = computed(() => activePreviewFile.value?.content ?? '')
const previewFiles = computed(() => uploadedFiles.value.map((file) => { const previewFiles = computed(() => uploadedFiles.value.map((file) => {
const items = previewItems.value.filter((item) => item.sourceFileId === String(file.uid)) const items = previewItemsByFile.value.get(String(file.uid)) || []
return { return {
id: String(file.uid), id: String(file.uid),
name: file.name, name: file.name,
@@ -70,7 +126,9 @@ const previewFiles = computed(() => uploadedFiles.value.map((file) => {
} }
})) }))
const primaryActionLabel = computed(() => { const primaryActionLabel = computed(() => {
if (currentStepId.value === 'create') return '继续:数据预览' if (currentStepId.value === 'create') return '继续:选择大模型'
if (currentStepId.value === 'model') return '继续:上传文件'
if (currentStepId.value === 'upload') return '继续:数据预览'
if (currentStepId.value === 'preview') return '确认预览并继续' if (currentStepId.value === 'preview') return '确认预览并继续'
if (currentStepId.value === 'results') return '保存任务' if (currentStepId.value === 'results') return '保存任务'
if (generation.status === 'running') return '正在生成' if (generation.status === 'running') return '正在生成'
@@ -89,98 +147,140 @@ const previousStepLabel = computed(() => currentStep.value > 0
? WIZARD_STEPS[currentStep.value - 1].title ? WIZARD_STEPS[currentStep.value - 1].title
: '') : '')
function draftSnapshot() { function goToStep(stepId: StepId) {
return { const nextStepIndex = WIZARD_STEPS.findIndex((step) => step.id === stepId)
currentStep: currentStep.value, if (nextStepIndex >= 0) currentStep.value = nextStepIndex
task: { ...task }, }
processType: processType.value,
uploadedFiles: uploadedFiles.value, function updateModelSelectionOptions(value: GenerationControlOptions) {
previewSignature: previewSignature.value, if (processType.value === 'unstructured') {
previewItems: previewItems.value, unstructuredOptions.value = { ...unstructuredOptions.value, ...value }
selectedPreviewFileId: selectedPreviewFileId.value, return
selectedPreviewId: selectedPreviewId.value,
selectedPreviewIdsByFile: selectedPreviewIdsByFile.value,
results: results.value,
selectedResultId: selectedResultId.value,
generation: { ...generation },
} }
structuredOptions.value = { ...structuredOptions.value, ...value }
} }
function persistDraft() { const { persistDraft, restoreDraft } = useDataProcessDraft({
if (restoringDraft.value) return currentStepId,
try { task,
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draftSnapshot())) processType,
} catch { structuredOptions,
ElMessage.warning('草稿保存失败,请检查浏览器存储空间') unstructuredOptions,
} externalSource,
} restoringDraft,
dirty,
goToStep,
})
type DraftSnapshot = ReturnType<typeof draftSnapshot> & { function previewAffectingOptions() {
fileName?: string if (processType.value === 'structured') {
fileSize?: number return {
fileCount?: number preprocessOptions: structuredOptions.value.preprocessOptions,
sourceText?: string
}
function restoreDraft() {
try {
const raw = localStorage.getItem(DRAFT_STORAGE_KEY)
if (!raw) return
const snapshot = JSON.parse(raw) as DraftSnapshot
if (!snapshot?.uploadedFiles && (!snapshot?.fileName || !snapshot?.sourceText)) return
restoringDraft.value = true
currentStep.value = Math.min(Math.max(Number(snapshot.currentStep) || 0, 0), WIZARD_STEPS.length - 1)
task.name = snapshot.task?.name || ''
task.description = snapshot.task?.description || ''
processType.value = snapshot.processType === 'structured' ? 'structured' : 'unstructured'
if (snapshot.uploadedFiles) {
uploadedFiles.value = Array.isArray(snapshot.uploadedFiles) ? snapshot.uploadedFiles : []
} else if (snapshot.fileName && snapshot.sourceText) {
// Migrate old draft
uploadedFiles.value = [{
uid: 'migrated-draft',
name: snapshot.fileName,
size: Number(snapshot.fileSize) || 0,
count: Number(snapshot.fileCount) || 0,
content: snapshot.sourceText
}]
} }
previewSignature.value = snapshot.previewSignature || ''
const defaultSourceFileId = String(uploadedFiles.value[0]?.uid ?? '')
previewItems.value = Array.isArray(snapshot.previewItems)
? snapshot.previewItems.map((item) => ({ ...item, sourceFileId: item.sourceFileId ?? defaultSourceFileId }))
: []
selectedPreviewFileId.value = snapshot.selectedPreviewFileId || defaultSourceFileId || null
selectedPreviewIdsByFile.value = snapshot.selectedPreviewIdsByFile || {}
selectedPreviewId.value = snapshot.selectedPreviewId
|| selectedPreviewIdsByFile.value[selectedPreviewFileId.value ?? '']
|| activePreviewItems.value[0]?.id
|| null
results.value = Array.isArray(snapshot.results) ? snapshot.results : []
selectedResultId.value = snapshot.selectedResultId || results.value[0]?.id || null
Object.assign(generation, snapshot.generation || {})
dirty.value = false
nextTick(() => { restoringDraft.value = false })
ElMessage.info('已恢复上次保存的草稿')
} catch {
localStorage.removeItem(DRAFT_STORAGE_KEY)
} }
if (processType.value === 'unstructured') {
const {
preprocessOptions,
chunkMethod,
chunkSize,
chunkOverlap,
minChunkSize,
customDelimiter,
preserveTables,
preserveCodeBlocks,
preserveLists,
} = unstructuredOptions.value
return {
preprocessOptions,
chunkMethod,
chunkSize,
chunkOverlap,
minChunkSize,
customDelimiter: chunkMethod === 'custom' ? customDelimiter : '',
preserveTables,
preserveCodeBlocks,
preserveLists,
}
}
return null
}
function generationAffectingOptions() {
if (processType.value === 'structured') {
const {
semanticEnrichment, qaPairsPerRow, datasetSplit, generationModelId, generationPrompt,
temperature, maxTokens, jsonMode,
qualityFilterEnabled, filterLowQuality, filterShortContent, minOutputLength,
} = structuredOptions.value
return {
semanticEnrichment, qaPairsPerRow, datasetSplit, generationModelId, generationPrompt,
temperature, maxTokens, jsonMode,
qualityFilterEnabled, filterLowQuality, filterShortContent, minOutputLength,
}
}
if (processType.value === 'unstructured') {
const {
semanticEnrichment,
qaPairsPerChunk,
datasetSplit,
generationModelId,
generationPrompt,
temperature,
maxTokens,
jsonMode,
qualityFilterEnabled,
filterLowQuality,
filterShortContent,
minOutputLength,
} = unstructuredOptions.value
return {
semanticEnrichment,
qaPairsPerChunk,
datasetSplit,
generationModelId,
generationPrompt,
temperature,
maxTokens,
jsonMode,
qualityFilterEnabled,
filterLowQuality,
filterShortContent,
minOutputLength,
}
}
return null
}
const generationOptionsSignature = computed(() => JSON.stringify(generationAffectingOptions()))
function buildPreviewSignature() {
const filesSignature = uploadedFiles.value
.map((file) => `${file.uid}:${file.name}:${file.size}:${file.count}`)
.join('|')
return `${PREVIEW_MODEL_VERSION}:${processType.value}:${JSON.stringify(previewAffectingOptions())}:${filesSignature}`
} }
watch( watch(
[() => task.name, () => task.description, processType], [() => task.name, () => task.description, processType, structuredOptions, unstructuredOptions, externalSource],
() => { () => {
if (!restoringDraft.value) dirty.value = true if (!restoringDraft.value) dirty.value = true
}, },
{ deep: true },
) )
watch(processType, (nextType, previousType) => {
if (restoringDraft.value || nextType === previousType || uploadedFiles.value.length === 0) return
resetSourceDataForProcessTypeChange()
ElMessage.info('处理类型已变更,请重新上传或拉取匹配的源数据')
})
watch(generationOptionsSignature, (currentSignature, previousSignature) => {
if (restoringDraft.value || currentSignature === previousSignature) return
resetDownstream()
})
watch( watch(
[currentStep, task, processType, uploadedFiles, previewSignature, [task, processType, structuredOptions, unstructuredOptions, externalSource],
previewItems, selectedPreviewFileId, selectedPreviewId, selectedPreviewIdsByFile,
results, selectedResultId, generation],
persistDraft, persistDraft,
{ deep: true }, { deep: true },
) )
@@ -235,11 +335,54 @@ function useSampleFile() {
count: DEFAULT_SOURCE_TEXT.split('\n').filter((line) => line.trim()).length, count: DEFAULT_SOURCE_TEXT.split('\n').filter((line) => line.trim()).length,
content: DEFAULT_SOURCE_TEXT content: DEFAULT_SOURCE_TEXT
}] }]
if (!task.name) task.name = '金融问答清洗任务'
if (!task.description) task.description = '清洗金融领域问答数据,统一格式并生成高质量训练数据。'
dirty.value = true dirty.value = true
} }
function updateExternalSource(value: ExternalDataSource) {
Object.assign(externalSource, value)
externalConnected.value = false
}
function handleTestConnection() {
if (!externalSource.url.trim()) {
ElMessage.warning('请先填写数据源地址')
return
}
if (connectionTimer) clearTimeout(connectionTimer)
externalPulling.value = true
connectionTimer = setTimeout(() => {
connectionTimer = null
externalPulling.value = false
externalConnected.value = true
ElMessage.success('数据源连接测试成功')
}, 1500)
}
function handlePullData() {
if (!externalSource.url.trim()) {
ElMessage.warning('请先填写数据源地址')
return
}
if (pullTimer) clearTimeout(pullTimer)
externalPulling.value = true
pullTimer = setTimeout(() => {
pullTimer = null
externalPulling.value = false
externalConnected.value = true
const typeName = externalSource.type.toUpperCase()
const id = `external-${Date.now()}`
uploadedFiles.value.push({
uid: id,
name: `${typeName} 拉取数据 ${new Date().toLocaleString('zh-CN')}`,
size: Math.min(externalSource.limit, 5000) * 64,
count: Math.min(externalSource.limit, DEFAULT_SOURCE_TEXT.split('\n').filter((line) => line.trim()).length),
content: DEFAULT_SOURCE_TEXT,
})
dirty.value = true
ElMessage.success(`已成功拉取 ${uploadedFiles.value[uploadedFiles.value.length - 1].count.toLocaleString()} 条数据`)
}, 2000)
}
function handleRemoveFile(uid: string | number) { function handleRemoveFile(uid: string | number) {
const index = uploadedFiles.value.findIndex(f => f.uid === uid) const index = uploadedFiles.value.findIndex(f => f.uid === uid)
if (index > -1) { if (index > -1) {
@@ -252,27 +395,44 @@ function handleRemoveFile(uid: string | number) {
} }
} }
function resetDownstream() { function resetSourceDataForProcessTypeChange() {
stopGenerationTimer() uploadedFiles.value = []
generation.status = 'idle' previewSignature.value = ''
generation.progress = 0 previewItems.value = []
generation.message = '确认摘要后即可开始生成,过程中可查看实时进度。' selectedPreviewFileId.value = null
results.value = [] selectedPreviewId.value = null
selectedResultId.value = null selectedPreviewIdsByFile.value = {}
externalConnected.value = false
resetDownstream()
} }
async function nextFromCreate() { async function nextFromCreate() {
const valid = await taskSetupRef.value?.validate() const valid = await taskSetupRef.value?.validate()
if (!valid) return if (!valid) return
goToStep('model')
}
async function nextFromModel() {
const valid = await modelSelectionRef.value?.validate()
if (!valid) return
goToStep('upload')
}
function nextFromUpload() {
if (uploadedFiles.value.length === 0) { if (uploadedFiles.value.length === 0) {
ElMessage.warning('请上传至少一个源数据文件') ElMessage.warning(processType.value === 'external' ? '请先拉取至少一个数据源' : '请上传至少一个源数据文件')
return return
} }
const signature = `${processType.value}:${uploadedFiles.value.map((file) => `${file.uid}:${file.content}`).join('|')}` const signature = buildPreviewSignature()
if (signature !== previewSignature.value) { if (signature !== previewSignature.value) {
previewItems.value = uploadedFiles.value.flatMap((file) => previewItems.value = uploadedFiles.value.flatMap((file) =>
buildPreviewItems(file.content, processType.value, String(file.uid)), buildPreviewItems(
file.content,
processType.value,
String(file.uid),
processType.value === 'unstructured' ? unstructuredOptions.value : undefined,
),
) )
selectedPreviewFileId.value = String(uploadedFiles.value[0]?.uid ?? '') || null selectedPreviewFileId.value = String(uploadedFiles.value[0]?.uid ?? '') || null
selectedPreviewId.value = activePreviewItems.value[0]?.id ?? null selectedPreviewId.value = activePreviewItems.value[0]?.id ?? null
@@ -282,7 +442,7 @@ async function nextFromCreate() {
previewSignature.value = signature previewSignature.value = signature
resetDownstream() resetDownstream()
} }
currentStep.value = 1 goToStep('preview')
} }
function selectPreviewFile(fileId: string) { function selectPreviewFile(fileId: string) {
@@ -306,6 +466,7 @@ function updatePreviewContent(id: string, value: string) {
item.editedContent = value item.editedContent = value
item.tokenCount = Math.max(1, Math.ceil(value.length / 2)) item.tokenCount = Math.max(1, Math.ceil(value.length / 2))
item.status = value === item.originalContent ? 'original' : item.sourceStart == null ? 'manual' : 'modified' item.status = value === item.originalContent ? 'original' : item.sourceStart == null ? 'manual' : 'modified'
resetDownstream()
dirty.value = true dirty.value = true
} }
@@ -315,6 +476,7 @@ function restorePreviewItem(id: string) {
item.editedContent = item.originalContent item.editedContent = item.originalContent
item.tokenCount = Math.max(1, Math.ceil(item.originalContent.length / 2)) item.tokenCount = Math.max(1, Math.ceil(item.originalContent.length / 2))
item.status = 'original' item.status = 'original'
resetDownstream()
dirty.value = true dirty.value = true
} }
@@ -334,19 +496,19 @@ function addPreviewItem() {
status: 'manual', status: 'manual',
}) })
selectPreviewItem(id) selectPreviewItem(id)
resetDownstream()
dirty.value = true dirty.value = true
} }
async function removePreviewItem(id: string) { async function removePreviewItem(id: string) {
try { const confirmed = await confirmDialogRef.value?.open({
await ElMessageBox.confirm('删除只影响本次处理,不会修改源文件。确认删除吗?', '删除预览内容', { title: '删除预览内容',
confirmButtonText: '删除', message: '删除只影响本次处理,不会修改源文件。删除后可重新从源文件生成预览。',
cancelButtonText: '取消', confirmText: '删除',
type: 'warning', cancelText: '取消',
}) tone: 'danger',
} catch { })
return if (!confirmed) return
}
const index = previewItems.value.findIndex((item) => item.id === id) const index = previewItems.value.findIndex((item) => item.id === id)
if (index < 0) return if (index < 0) return
@@ -355,93 +517,34 @@ async function removePreviewItem(id: string) {
if (selectedPreviewFileId.value && selectedPreviewId.value) { if (selectedPreviewFileId.value && selectedPreviewId.value) {
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
} }
resetDownstream()
dirty.value = true dirty.value = true
} }
function stopGenerationTimer() {
if (generationTimer) clearInterval(generationTimer)
generationTimer = null
}
function startGeneration() {
stopGenerationTimer()
generation.status = 'running'
generation.progress = 0
generation.message = '正在应用预览修改并生成标准化结果,请稍候。'
generationTimer = setInterval(() => {
generation.progress = Math.min(100, generation.progress + 8)
if (generation.progress < 100) return
stopGenerationTimer()
generation.status = 'success'
generation.message = `已完成 ${previewItems.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
results.value = createResults(previewItems.value)
selectedResultId.value = results.value[0]?.id ?? null
dirty.value = true
ElMessage.success('数据处理完成')
}, 180)
}
function stopGeneration() {
stopGenerationTimer()
generation.status = 'failed'
generation.message = '任务已停止,预览修改仍然保留,可以重新生成。'
}
function updateResultField(id: string, field: 'instruction' | 'input' | 'output', value: string) {
const item = results.value.find((entry) => entry.id === id)
if (!item) return
item[field] = value
const valid = item.instruction.trim() && item.output.trim()
item.error = valid ? undefined : 'Instruction 和 Output 不能为空'
const changed = item.instruction !== item.originalInstruction
|| item.input !== item.originalInput
|| item.output !== item.originalOutput
item.status = item.error ? 'invalid' : changed ? 'modified' : 'valid'
dirty.value = true
}
function restoreResult(id: string) {
const item = results.value.find((entry) => entry.id === id)
if (!item) return
item.instruction = item.originalInstruction
item.input = item.originalInput
item.output = item.originalOutput
item.error = undefined
item.status = 'valid'
dirty.value = true
}
function validateResults() {
let firstInvalidId: string | null = null
for (const item of results.value) {
if (!item.instruction.trim() || !item.output.trim()) {
item.error = 'Instruction 和 Output 不能为空'
item.status = 'invalid'
firstInvalidId ??= item.id
}
}
if (firstInvalidId) selectedResultId.value = firstInvalidId
return firstInvalidId == null
}
async function handlePrimaryAction() { async function handlePrimaryAction() {
if (currentStepId.value === 'create') { if (currentStepId.value === 'create') {
await nextFromCreate() await nextFromCreate()
return return
} }
if (currentStepId.value === 'model') {
await nextFromModel()
return
}
if (currentStepId.value === 'upload') {
nextFromUpload()
return
}
if (currentStepId.value === 'preview') { if (currentStepId.value === 'preview') {
if (!previewItems.value.length) { if (!previewItems.value.length) {
ElMessage.warning('当前没有可生成的预览内容') ElMessage.warning('当前没有可生成的预览内容')
return return
} }
currentStep.value = 2 goToStep('generate')
return return
} }
if (currentStepId.value === 'generate') { if (currentStepId.value === 'generate') {
if (generation.status === 'success') { if (generation.status === 'success') {
currentStep.value = 3 goToStep('results')
} else if (generation.status !== 'running') { } else if (generation.status !== 'running') {
startGeneration() startGeneration()
} }
@@ -465,7 +568,7 @@ async function saveTask() {
return return
} }
dirty.value = false dirty.value = false
localStorage.removeItem(DRAFT_STORAGE_KEY) localStorage.removeItem(DATA_PROCESS_DRAFT_STORAGE_KEY)
allowLeave = true allowLeave = true
ElMessage.success('数据处理任务已保存') ElMessage.success('数据处理任务已保存')
await router.push('/data-process') await router.push('/data-process')
@@ -477,29 +580,40 @@ async function handleCancel() {
router.back() router.back()
return return
} }
try { const confirmed = await confirmDialogRef.value?.open({
await ElMessageBox.confirm('当前存在未保存修改,确定离开吗?', '离开创建任务', { title: '确认离开当前页面?',
confirmButtonText: '放弃修改', message: '当前存在未保存修改,离开后这些修改将不会保留。',
cancelButtonText: '继续编辑', confirmText: '放弃修改',
type: 'warning', cancelText: '继续编辑',
}) tone: 'danger',
allowLeave = true })
router.back() if (!confirmed) return
} catch { allowLeave = true
// 用户继续编辑。 router.back()
}
} }
onBeforeRouteLeave((_to, _from, next) => { onBeforeRouteLeave(async () => {
if (allowLeave || !dirty.value) { if (allowLeave || !dirty.value) return true
next() const confirmed = await confirmDialogRef.value?.open({
return title: '确认离开当前页面?',
} message: '当前存在未保存修改,离开后这些修改将不会保留。',
next(window.confirm('当前存在未保存修改,确定离开吗?')) confirmText: '放弃修改',
cancelText: '继续编辑',
tone: 'danger',
})
if (confirmed) allowLeave = true
return Boolean(confirmed)
}) })
onBeforeUnmount(stopGenerationTimer) onBeforeUnmount(() => {
onMounted(restoreDraft) stopGenerationTimer()
if (connectionTimer) clearTimeout(connectionTimer)
if (pullTimer) clearTimeout(pullTimer)
})
onMounted(() => {
restoreDraft()
modelsStore.load()
})
</script> </script>
<template> <template>
@@ -508,26 +622,31 @@ onMounted(restoreDraft)
<div class="wizard-main-inner"> <div class="wizard-main-inner">
<div class="wizard-steps-container"> <div class="wizard-steps-container">
<div class="custom-wizard-steps"> <div class="custom-wizard-steps">
<div <template v-for="(step, index) in WIZARD_STEPS" :key="step.id">
v-for="(step, index) in WIZARD_STEPS" <div
:key="step.id" v-if="index !== 0"
class="step-item" class="step-connector"
:class="{ :class="{ 'is-active': currentStep >= index }"
'is-active': currentStep === index, ></div>
'is-completed': currentStep > index <div
}" class="step-item"
> :class="{
<div v-if="index !== 0" class="step-connector"></div> 'is-active': currentStep === index,
<div class="step-node"> 'is-completed': currentStep > index
<div class="step-icon"> }"
<i v-if="currentStep > index" class="fa fa-check" /> :aria-current="currentStep === index ? 'step' : undefined"
<span v-else>{{ index + 1 }}</span> >
</div> <div class="step-node">
<div class="step-text"> <div class="step-icon">
<div class="step-title">{{ step.title }}</div> <i v-if="currentStep > index" class="fa fa-check" />
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-text">
<div class="step-title">{{ step.title }}</div>
</div>
</div> </div>
</div> </div>
</div> </template>
</div> </div>
</div> </div>
@@ -538,10 +657,31 @@ onMounted(restoreDraft)
v-model:name="task.name" v-model:name="task.name"
v-model:description="task.description" v-model:description="task.description"
v-model:process-type="processType" v-model:process-type="processType"
v-model:structured-options="structuredOptions"
v-model:unstructured-options="unstructuredOptions"
/>
<ModelSelectionStep
v-else-if="currentStepId === 'model'"
ref="modelSelectionRef"
:options="modelSelectionOptions"
:models="generationModels"
@update:options="updateModelSelectionOptions"
/>
<SourceUploadStep
v-else-if="currentStepId === 'upload'"
:process-type="processType"
:uploaded-files="uploadedFiles" :uploaded-files="uploadedFiles"
:external-source="externalSource"
:external-pulling="externalPulling"
:external-connected="externalConnected"
@update:external-source="updateExternalSource"
@file-change="handleFileChange" @file-change="handleFileChange"
@remove-file="handleRemoveFile" @remove-file="handleRemoveFile"
@use-sample="useSampleFile" @use-sample="useSampleFile"
@test-connection="handleTestConnection"
@pull-data="handlePullData"
/> />
<PreviewCompareStep <PreviewCompareStep
@@ -574,7 +714,7 @@ onMounted(restoreDraft)
/> />
<ResultEditorStep <ResultEditorStep
v-else v-else-if="currentStepId === 'results'"
v-model:selected-id="selectedResultId" v-model:selected-id="selectedResultId"
:items="results" :items="results"
@update:field="updateResultField" @update:field="updateResultField"
@@ -606,157 +746,8 @@ onMounted(restoreDraft)
</div> </div>
</footer> </footer>
</div> </div>
<AppConfirmDialog ref="confirmDialogRef" />
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss" src="./create/data-process-create.scss"></style>
.create-wizard-layout {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #eef0f5;
border-radius: 8px;
background: #fff;
}
.wizard-main {
flex: 1;
overflow-y: auto;
padding: 32px;
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
.wizard-main-inner {
min-height: 100%;
box-sizing: border-box;
}
.wizard-steps-container {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px dashed #e2e8f0;
}
.custom-wizard-steps {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 860px;
margin: 0 auto;
padding: 0 20px;
}
.step-item {
display: flex;
align-items: center;
flex: 1;
&:first-child {
flex: 0;
}
}
.step-connector {
flex: 1;
height: 2px;
background-color: #e2e8f0;
margin: 0 16px;
transition: background-color 0.3s;
}
.step-item.is-completed .step-connector,
.step-item.is-active .step-connector {
background-color: #5146e5;
}
.step-node {
display: flex;
align-items: center;
gap: 10px;
}
.step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #cbd5e1;
background-color: #fff;
color: #64748b;
font-size: 13px;
font-weight: 650;
transition: all 0.3s;
}
.step-title {
font-size: 14px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
/* State: Active */
.step-item.is-active {
.step-icon {
border-color: #5146e5;
background-color: #eef2ff;
color: #5146e5;
}
.step-title {
color: #1e293b;
}
}
/* State: Completed */
.step-item.is-completed {
.step-icon {
background-color: #5146e5;
border-color: #5146e5;
color: #fff;
}
.step-title {
color: #1e293b;
}
}
.wizard-content {
min-height: 400px;
}
.wizard-footer {
flex-shrink: 0;
height: 64px;
background: #ffffff;
border-top: 1px solid #e2e8f0;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 32px;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.02);
.footer-left {
justify-self: start;
}
.footer-center {
justify-self: center;
}
.footer-right {
justify-self: end;
}
}
.wizard-primary-action {
min-width: 160px;
}
</style>

View File

@@ -0,0 +1,425 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PageCard from '@/components/PageCard.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
interface ResultRow {
id: string
instruction: string
input: string
output: string
status: 'valid' | 'modified' | 'invalid'
}
interface ProcessDetail {
id: string
name: string
status: string
description: string
processType: string
sourceDataset: string
outputDataset?: string
outputDatasetId?: number
creator: string
createTime: string
startTime?: string
completeTime?: string
duration?: string
progress: number
inputCount: number
outputCount: number
filteredCount: number
duplicateCount: number
errorCount: number
config: Array<{ label: string; value: string }>
results: ResultRow[]
failureReason?: string
}
const completedResults: ResultRow[] = [
{
id: 'result-001',
instruction: '用户询问如何修改订单收货地址,应如何回复?',
input: '订单已经提交,但还没有发货。',
output: '您好,订单发货前可以在订单详情中申请修改收货地址,提交后请等待系统审核。',
status: 'valid',
},
{
id: 'result-002',
instruction: '概括用户的退款诉求。',
input: '商品收到后发现破损,希望尽快退货退款。',
output: '用户因商品破损申请退货退款,并希望尽快处理。',
status: 'modified',
},
{
id: 'result-003',
instruction: '判断咨询所属业务类型。',
input: '会员积分什么时候到账?',
output: '会员权益 / 积分到账咨询',
status: 'valid',
},
{
id: 'result-004',
instruction: '生成简洁的客服回复。',
input: '优惠券显示已过期,但昨天还能使用。',
output: '您好,请提供优惠券名称和订单信息,我们将为您核实有效期及使用记录。',
status: 'valid',
},
]
const detailMap: Record<string, ProcessDetail> = {
'183921': {
id: '183921',
name: '客服问答数据清洗',
status: 'completed',
description: '清洗客服对话中的无效记录、重复问答和格式异常内容,生成可用于模型训练的标准数据集。',
processType: '结构化数据',
sourceDataset: '客服对话原始集',
outputDataset: '客服对话清洗集',
outputDatasetId: 7,
creator: '管理员',
createTime: '2026-07-08 14:23:00',
startTime: '2026-07-08 14:23:18',
completeTime: '2026-07-08 14:28:42',
duration: '5 分 24 秒',
progress: 100,
inputCount: 19068,
outputCount: 18240,
filteredCount: 186,
duplicateCount: 642,
errorCount: 0,
config: [
{ label: '预处理规则', value: '清理无效数据、结构检测、内容去重、格式标准化' },
{ label: '输出格式', value: 'Alpaca JSONL' },
{ label: '数据集划分', value: '训练集 80% / 验证集 10% / 测试集 10%' },
{ label: '处理引擎', value: 'DataFlow Engine v2.3' },
],
results: completedResults,
},
'492015': {
id: '492015', name: '指令微调数据构造', status: 'running',
description: '从通用语料中构造指令微调训练样本。', processType: '非结构化数据',
sourceDataset: '通用语料库', outputDataset: 'SFT 指令集', outputDatasetId: 8, creator: '管理员',
createTime: '2026-07-09 09:10:00', startTime: '2026-07-09 09:10:21', duration: '处理中', progress: 68,
inputCount: 18500, outputCount: 8568, filteredCount: 425, duplicateCount: 192, errorCount: 8,
config: [
{ label: '切分方式', value: '语义切分' }, { label: '切片长度', value: '800 Tokens重叠 80 Tokens' },
{ label: '生成数量', value: '每个切片生成 2 组问答' }, { label: '处理引擎', value: 'DataFlow Engine v2.3' },
], results: [],
},
'731948': {
id: '731948', name: '敏感信息脱敏处理', status: 'pending', description: '识别并脱敏用户反馈数据中的敏感字段。',
processType: '结构化数据', sourceDataset: '用户反馈数据', outputDataset: '用户反馈脱敏集',
outputDatasetId: 9, creator: '管理员', createTime: '2026-07-09 16:45:00', duration: '等待执行', progress: 0,
inputCount: 9340, outputCount: 0, filteredCount: 0, duplicateCount: 0, errorCount: 0,
config: [
{ label: '脱敏范围', value: '姓名、手机号、身份证号、地址' }, { label: '替换方式', value: '掩码替换' },
{ label: '输出格式', value: 'JSONL' }, { label: '处理引擎', value: 'DataFlow Engine v2.3' },
], results: [],
},
'582012': {
id: '582012', name: '多轮对话拼接', status: 'failed', description: '将单轮问答按会话标识拼接为多轮对话数据。',
processType: '结构化数据', sourceDataset: '单轮问答集', creator: '管理员',
createTime: '2026-07-10 08:30:00', startTime: '2026-07-10 08:30:16', completeTime: '2026-07-10 08:31:04',
duration: '48 秒', progress: 37, inputCount: 7520, outputCount: 2780, filteredCount: 24, duplicateCount: 0, errorCount: 1,
config: [
{ label: '会话字段', value: 'conversation_id' }, { label: '排序字段', value: 'message_time' },
{ label: '最大轮次', value: '20 轮' }, { label: '处理引擎', value: 'DataFlow Engine v2.3' },
], results: [], failureReason: '第 2 个源文件缺少 conversation_id 字段,无法继续执行会话拼接。',
},
}
const route = useRoute()
const router = useRouter()
const keyword = ref('')
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const taskId = computed(() => String(route.params.id || ''))
const detail = computed(() => detailMap[taskId.value])
const retentionRate = computed(() => {
if (!detail.value?.inputCount) return 0
return Number(((detail.value.outputCount / detail.value.inputCount) * 100).toFixed(1))
})
const filteredResults = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
return (detail.value?.results || []).filter((item) => {
const matchesStatus = !statusFilter.value || item.status === statusFilter.value
const matchesKeyword = !normalizedKeyword
|| [item.instruction, item.input, item.output].some((text) => text.toLowerCase().includes(normalizedKeyword))
return matchesStatus && matchesKeyword
})
})
const paginatedResults = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredResults.value.slice(start, start + pageSize.value)
})
function resultStatusLabel(status: ResultRow['status']) {
return status === 'valid' ? '有效' : status === 'modified' ? '已修改' : '无效'
}
function resultStatusType(status: ResultRow['status']) {
return status === 'valid' ? 'success' : status === 'modified' ? 'warning' : 'danger'
}
function resetPage() {
currentPage.value = 1
}
</script>
<template>
<PageCard class="data-process-detail-page">
<template v-if="detail" #header>
<div class="detail-heading">
<div class="heading-row">
<h1>{{ detail.name }}</h1>
<ModelStatusTag :status="detail.status" />
</div>
<p>{{ detail.description }}</p>
<dl class="heading-meta">
<div><dt>任务 ID</dt><dd>{{ detail.id }}</dd></div>
<div><dt>处理类型</dt><dd>{{ detail.processType }}</dd></div>
<div><dt>创建人</dt><dd>{{ detail.creator }}</dd></div>
</dl>
</div>
</template>
<div v-if="!detail" class="not-found-state">
<i class="fa fa-exclamation-circle" aria-hidden="true" />
<h2>未找到数据处理任务</h2>
<p>任务可能已被删除或当前链接已失效</p>
<el-button type="primary" @click="router.push('/data-process')">返回任务列表</el-button>
</div>
<template v-else>
<section class="metric-grid" aria-label="处理结果概览">
<div class="metric-card">
<span>处理耗时</span>
<strong>{{ detail.duration || '尚未开始' }}</strong>
<small>{{ detail.completeTime ? `完成于 ${detail.completeTime}` : `当前进度 ${detail.progress}%` }}</small>
</div>
<div class="metric-card">
<span>输入数据</span>
<strong>{{ detail.inputCount.toLocaleString() }}</strong>
<small>来源{{ detail.sourceDataset }}</small>
</div>
<div class="metric-card">
<span>输出结果</span>
<strong>{{ detail.outputCount.toLocaleString() }}</strong>
<small>{{ detail.outputDataset || '尚未生成输出数据集' }}</small>
</div>
<div class="metric-card is-primary">
<span>数据保留率</span>
<strong>{{ detail.inputCount ? `${retentionRate}%` : '-' }}</strong>
<el-progress :percentage="detail.progress" :show-text="false" :stroke-width="5" />
</div>
</section>
<div v-if="detail.failureReason" class="failure-alert" role="alert">
<i class="fa fa-exclamation-triangle" aria-hidden="true" />
<div><strong>处理任务执行失败</strong><p>{{ detail.failureReason }}</p></div>
</div>
<div class="detail-grid">
<section class="detail-section" aria-labelledby="runtime-title">
<div class="section-heading">
<div><h2 id="runtime-title">运行信息</h2><p>查看任务执行时间与数据流向</p></div>
</div>
<dl class="info-list">
<div><dt>创建时间</dt><dd>{{ detail.createTime }}</dd></div>
<div><dt>开始时间</dt><dd>{{ detail.startTime || '尚未开始' }}</dd></div>
<div><dt>完成时间</dt><dd>{{ detail.completeTime || '尚未完成' }}</dd></div>
<div><dt>处理耗时</dt><dd>{{ detail.duration || '-' }}</dd></div>
<div><dt>源数据集</dt><dd>{{ detail.sourceDataset }}</dd></div>
<div>
<dt>输出数据集</dt>
<dd>
<el-button
v-if="detail.outputDataset && detail.status === 'completed'"
type="primary"
link
@click="router.push(`/dataset/${detail.outputDatasetId}/preview`)"
>{{ detail.outputDataset }} <i class="fa fa-external-link" /></el-button>
<span v-else>{{ detail.outputDataset || '尚未生成' }}</span>
</dd>
</div>
</dl>
</section>
<section class="detail-section" aria-labelledby="statistics-title">
<div class="section-heading">
<div><h2 id="statistics-title">处理统计</h2><p>查看数据清洗过滤和输出情况</p></div>
</div>
<div class="statistics-grid">
<div><span>原始数据</span><strong>{{ detail.inputCount.toLocaleString() }}</strong></div>
<div><span>成功输出</span><strong>{{ detail.outputCount.toLocaleString() }}</strong></div>
<div><span>过滤数据</span><strong>{{ detail.filteredCount.toLocaleString() }}</strong></div>
<div><span>重复数据</span><strong>{{ detail.duplicateCount.toLocaleString() }}</strong></div>
<div><span>异常数据</span><strong>{{ detail.errorCount.toLocaleString() }}</strong></div>
<div><span>执行进度</span><strong>{{ detail.progress }}%</strong></div>
</div>
</section>
</div>
<section class="detail-section config-section" aria-labelledby="config-title">
<div class="section-heading">
<div><h2 id="config-title">处理配置</h2><p>任务执行时使用的规则与参数</p></div>
</div>
<dl class="config-grid">
<div v-for="item in detail.config" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
<section class="detail-section result-section" aria-labelledby="result-title">
<div class="result-toolbar">
<div class="section-heading">
<div><h2 id="result-title">结果明细</h2><p>查看处理完成后的数据内容与校验状态</p></div>
</div>
<div v-if="detail.results.length" class="result-filters">
<el-input v-model="keyword" clearable placeholder="搜索指令、输入或输出" @input="resetPage">
<template #prefix><i class="fa fa-search" aria-hidden="true" /></template>
</el-input>
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="resetPage">
<el-option label="有效" value="valid" />
<el-option label="已修改" value="modified" />
<el-option label="无效" value="invalid" />
</el-select>
</div>
</div>
<el-table v-if="filteredResults.length" :data="paginatedResults" row-key="id" table-layout="fixed">
<el-table-column type="index" label="#" width="56" align="center" />
<el-table-column label="指令" min-width="210" show-overflow-tooltip prop="instruction" />
<el-table-column label="输入" min-width="190" show-overflow-tooltip prop="input" />
<el-table-column label="输出" min-width="260" show-overflow-tooltip prop="output" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="resultStatusType(row.status)" size="small">{{ resultStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
</el-table>
<div v-else class="result-empty">
<i class="fa" :class="detail.status === 'failed' ? 'fa-exclamation-circle' : 'fa-hourglass-half'" aria-hidden="true" />
<strong>{{ detail.status === 'completed' ? '没有符合条件的结果' : detail.status === 'failed' ? '任务失败,未生成结果明细' : '处理完成后将在这里展示结果明细' }}</strong>
<span>{{ detail.status === 'completed' ? '请调整搜索或筛选条件' : `当前任务状态:${detail.status === 'running' ? '运行中' : '等待中'}` }}</span>
</div>
<div v-if="filteredResults.length" class="result-pagination">
<span> {{ filteredResults.length }} 条结果</span>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
layout="prev, pager, next"
:total="filteredResults.length"
/>
</div>
</section>
</template>
</PageCard>
</template>
<style scoped lang="scss">
.data-process-detail-page { width: 100%; min-width: 0; }
.detail-heading {
width: 100%;
.heading-row { display: flex; align-items: center; gap: 12px; }
h1 { margin: 0; color: #1f2937; font-size: 20px; font-weight: 600; }
> p { margin: 8px 0 0; color: #64748b; font-size: 13px; }
}
.heading-meta {
margin: 16px 0 0; display: flex; flex-wrap: wrap; gap: 10px 30px;
div { display: flex; gap: 7px; font-size: 12px; }
dt { color: #94a3b8; }
dd { margin: 0; color: #475569; font-weight: 500; }
}
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.metric-card {
min-height: 116px; padding: 18px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff;
display: flex; flex-direction: column;
> span { color: #64748b; font-size: 12px; }
> strong { margin-top: 10px; color: #1f2937; font-size: 22px; font-weight: 600; }
> small { margin-top: auto; color: #94a3b8; font-size: 12px; }
:deep(.el-progress) { margin-top: auto; }
&.is-primary { border-color: var(--el-color-primary-light-7); background: var(--el-color-primary-light-9); }
&.is-primary > strong { color: var(--primary-color); }
}
.failure-alert {
margin-top: 16px; padding: 14px 16px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2;
color: #b91c1c; display: flex; gap: 12px;
> i { margin-top: 2px; }
strong { font-size: 13px; }
p { margin: 4px 0 0; color: #7f1d1d; font-size: 12px; }
}
.detail-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.detail-section { border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; overflow: hidden; }
.section-heading {
padding: 16px 18px; border-bottom: 1px solid #eef0f3;
h2 { margin: 0; color: #1f2937; font-size: 15px; font-weight: 600; }
p { margin: 4px 0 0; color: #94a3b8; font-size: 12px; }
}
.info-list { margin: 0; padding: 4px 18px 12px; }
.info-list > div {
min-height: 44px; border-bottom: 1px solid #f1f5f9; display: grid; grid-template-columns: 100px minmax(0, 1fr); align-items: center;
&:last-child { border-bottom: 0; }
dt { color: #64748b; font-size: 12px; }
dd { margin: 0; color: #334155; font-size: 13px; text-align: right; }
:deep(.el-button) { height: auto; padding: 0; }
}
.statistics-grid { padding: 18px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.statistics-grid > div {
min-height: 78px; padding: 13px; border-radius: 6px; background: #f8fafc; display: flex; flex-direction: column; gap: 8px;
span { color: #64748b; font-size: 12px; }
strong { color: #1f2937; font-size: 18px; font-weight: 600; }
}
.config-section, .result-section { margin-top: 16px; }
.config-grid { margin: 0; padding: 8px 18px 16px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 40px; }
.config-grid > div { min-height: 48px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.config-grid dt { color: #64748b; font-size: 12px; }
.config-grid dd { margin: 0; color: #334155; font-size: 13px; text-align: right; }
.result-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #eef0f3; }
.result-toolbar .section-heading { border-bottom: 0; }
.result-filters { padding-right: 18px; display: flex; gap: 10px; }
.result-filters :deep(.el-input) { width: 250px; }
.result-filters :deep(.el-select) { width: 120px; }
.result-section :deep(.el-table) { border-radius: 0; }
.result-empty, .not-found-state {
min-height: 220px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8;
> i { margin-bottom: 12px; color: var(--el-color-primary-light-5); font-size: 30px; }
strong, h2 { margin: 0; color: #475569; font-size: 14px; font-weight: 500; }
span, p { margin: 6px 0 16px; font-size: 12px; }
}
.result-pagination { min-height: 56px; padding: 0 18px; border-top: 1px solid #eef0f3; display: flex; align-items: center; justify-content: space-between; }
.result-pagination > span { color: #94a3b8; font-size: 12px; }
.result-pagination :deep(.el-pagination) { margin-top: 0; }
@media (max-width: 1100px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.metric-grid, .config-grid { grid-template-columns: 1fr; }
.result-toolbar { align-items: stretch; flex-direction: column; }
.result-filters { padding: 0 16px 16px; flex-direction: column; }
.result-filters :deep(.el-input), .result-filters :deep(.el-select) { width: 100%; }
.heading-meta { flex-direction: column; gap: 6px; }
}
</style>

View File

@@ -1,90 +1,81 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue' import DataTablePage from '@/components/DataTablePage.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue' import ModelStatusTag from '@/components/ModelStatusTag.vue'
import type { ProcessType } from './create/types'
/** 数据处理任务类型 */ /** 数据处理任务类型 */
interface DataProcessTask { interface DataProcessTask {
id: number | string id: number | string
name: string name: string
status: string status: string
process_type: string process_type: ProcessType
source_dataset: string source_dataset: string
output_dataset?: string output_dataset?: string
create_time?: string create_time?: string
} }
const processTypeMap: Record<ProcessType, string> = {
structured: '结构化数据',
unstructured: '非结构化数据',
external: '外来数据源拉取',
}
// TODO: 接入真实接口前,先用本地 mock 数据 // TODO: 接入真实接口前,先用本地 mock 数据
const router = useRouter() const router = useRouter()
const dataList = ref<DataProcessTask[]>([ const dataList = ref<DataProcessTask[]>([
{ {
id: 1, id: 183921,
name: '客服问答数据清洗', name: '客服问答数据清洗',
status: 'completed', status: 'completed',
process_type: '去重清洗', process_type: 'structured',
source_dataset: '客服对话原始集', source_dataset: '客服对话原始集',
output_dataset: '客服对话清洗集', output_dataset: '客服对话清洗集',
create_time: '2026-07-08 14:23:00', create_time: '2026-07-08 14:23:00',
}, },
{ {
id: 2, id: 492015,
name: '指令微调数据构造', name: '指令微调数据构造',
status: 'running', status: 'running',
process_type: '指令构造', process_type: 'unstructured',
source_dataset: '通用语料库', source_dataset: '通用语料库',
output_dataset: 'SFT 指令集', output_dataset: 'SFT 指令集',
create_time: '2026-07-09 09:10:00', create_time: '2026-07-09 09:10:00',
}, },
{ {
id: 3, id: 731948,
name: '敏感信息脱敏处理', name: '敏感信息脱敏处理',
status: 'pending', status: 'pending',
process_type: '脱敏处理', process_type: 'structured',
source_dataset: '用户反馈数据', source_dataset: '用户反馈数据',
create_time: '2026-07-09 16:45:00', create_time: '2026-07-09 16:45:00',
}, },
{ {
id: 4, id: 582012,
name: '多轮对话拼接', name: '多轮对话拼接',
status: 'failed', status: 'failed',
process_type: '格式转换', process_type: 'structured',
source_dataset: '单轮问答集', source_dataset: '单轮问答集',
create_time: '2026-07-10 08:30:00', create_time: '2026-07-10 08:30:00',
}, },
]) ])
const activeTab = ref('all')
const filteredDataList = computed(() => {
let list = dataList.value
if (activeTab.value !== 'all') {
if (activeTab.value === 'processing') {
list = list.filter(item => item.status === 'running' || item.status === 'pending')
} else if (activeTab.value === 'completed') {
list = list.filter(item => item.status === 'completed')
} else if (activeTab.value === 'failed') {
list = list.filter(item => item.status === 'failed')
}
}
return list
})
/** 新建数据处理任务 */ /** 新建数据处理任务 */
function handleCreate() { function handleCreate() {
router.push('/data-process/create') router.push('/data-process/create')
} }
/** 查看任务详情(功能开发中) */ /** 查看任务详情 */
function viewDetail(_row: DataProcessTask) { function viewDetail(row: unknown) {
ElMessage.info('查看详情功能开发中...') const taskId = (row as { id: string | number }).id
router.push({ name: 'data-process-detail', params: { id: taskId } })
} }
/** 删除任务(功能开发中) */ /** 删除任务(功能开发中) */
function handleDelete(_row: DataProcessTask) { function handleDelete(_row: unknown) {
ElMessage.info('删除功能开发中...') ElMessage.info('删除功能开发中...')
} }
@@ -98,41 +89,16 @@ function formatDateTime(value?: string) {
<div class="data-process-page" style="height: 100%;"> <div class="data-process-page" style="height: 100%;">
<DataTablePage <DataTablePage
title="" title=""
:data="filteredDataList" :data="dataList"
searchable searchable
:search-fields="['name']" :search-fields="['name']"
create-text="新建数据处理任务" create-text="新建数据处理"
create-to="/data-process/create" create-to="/data-process/create"
row-key="id" row-key="id"
:page-size="10" :page-size="10"
> >
<template #title>
<!-- 胶囊切换控件 -->
<div class="capsule-tabs">
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'all' }"
@click="activeTab = 'all'"
>
全部任务
</button>
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'processing' }"
@click="activeTab = 'processing'"
>
处理中
</button>
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'completed' }"
@click="activeTab = 'completed'"
>
已完成
</button>
</div>
</template>
<template #columns> <template #columns>
<el-table-column label="任务ID" prop="id" align="center" width="100" />
<el-table-column label="任务名称" prop="name" align="center" show-overflow-tooltip /> <el-table-column label="任务名称" prop="name" align="center" show-overflow-tooltip />
<el-table-column label="任务状态" align="center" width="110"> <el-table-column label="任务状态" align="center" width="110">
<template #default="{ row }"> <template #default="{ row }">
@@ -140,7 +106,12 @@ function formatDateTime(value?: string) {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="处理类型" align="center" width="140"> <el-table-column label="处理类型" align="center" width="140">
<template #default="{ row }">{{ row.process_type || '-' }}</template> <template #default="{ row }">
<el-tag v-if="row.process_type" size="small" type="info" effect="plain">
{{ processTypeMap[row.process_type as ProcessType] || row.process_type }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column> </el-table-column>
<el-table-column label="源数据集" align="center" show-overflow-tooltip> <el-table-column label="源数据集" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.source_dataset || '-' }}</template> <template #default="{ row }">{{ row.source_dataset || '-' }}</template>
@@ -168,41 +139,6 @@ function formatDateTime(value?: string) {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
/* 胶囊切换栏样式 */
.capsule-tabs {
display: flex;
background: #f1f5f9;
padding: 3px;
border-radius: 8px;
gap: 2px;
border: 1px solid #e2e8f0;
}
.capsule-tab-item {
border: 0;
background: transparent;
padding: 6px 20px;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
outline: none;
&:hover {
color: #1e293b;
}
&.active {
background: #fff;
color: #4f46e5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
font-weight: 600;
}
}
.action-buttons { .action-buttons {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DatasetSplitOptions } from './types'
const props = withDefaults(defineProps<{
modelValue: DatasetSplitOptions
ariaLabelPrefix?: string
}>(), {
ariaLabelPrefix: '',
})
const emit = defineEmits<{
'update:modelValue': [value: DatasetSplitOptions]
}>()
const splitTotal = computed(() => {
const { train, validation, test } = props.modelValue
return train + validation + test
})
function updateField(field: keyof DatasetSplitOptions, value: number | undefined) {
emit('update:modelValue', {
...props.modelValue,
[field]: Math.min(100, Math.max(0, Number(value) || 0)),
})
}
function ariaLabel(label: string) {
return `${props.ariaLabelPrefix}${label}比例`
}
</script>
<template>
<div class="generation-option-row dataset-split-row">
<div class="generation-option-copy">
<strong>数据集划分</strong>
<small>按比例将生成后的问答对随机划分为训练集验证集和测试集</small>
</div>
<div class="dataset-split-config">
<div class="dataset-split-grid">
<label class="dataset-split-field">
<span>训练集</span>
<span class="dataset-split-input">
<el-input-number
:model-value="modelValue.train"
:min="0"
:max="100"
:step="1"
:precision="0"
controls-position="right"
:aria-label="ariaLabel('训练集')"
@update:model-value="updateField('train', $event)"
/>
<span>%</span>
</span>
</label>
<label class="dataset-split-field">
<span>验证集</span>
<span class="dataset-split-input">
<el-input-number
:model-value="modelValue.validation"
:min="0"
:max="100"
:step="1"
:precision="0"
controls-position="right"
:aria-label="ariaLabel('验证集')"
@update:model-value="updateField('validation', $event)"
/>
<span>%</span>
</span>
</label>
<label class="dataset-split-field">
<span>测试集</span>
<span class="dataset-split-input">
<el-input-number
:model-value="modelValue.test"
:min="0"
:max="100"
:step="1"
:precision="0"
controls-position="right"
:aria-label="ariaLabel('测试集')"
@update:model-value="updateField('test', $event)"
/>
<span>%</span>
</span>
</label>
</div>
<div class="dataset-split-summary" :class="{ 'is-invalid': splitTotal !== 100 }">
<span>总计 {{ splitTotal }}%</span>
<span v-if="splitTotal !== 100" role="alert">
训练集验证集和测试集比例总和必须为 100%
</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.generation-option-row {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
.generation-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.dataset-split-row {
align-items: flex-start;
}
.dataset-split-config {
display: grid;
gap: 10px;
flex: 0 0 460px;
}
.dataset-split-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.dataset-split-field {
display: grid;
gap: 7px;
color: #5f6878;
font-size: 12px;
}
.dataset-split-input {
display: flex;
align-items: center;
gap: 8px;
:deep(.el-input-number) {
width: 100%;
}
}
.dataset-split-summary {
display: flex;
justify-content: space-between;
gap: 12px;
color: #2ca66a;
font-size: 12px;
&.is-invalid {
color: #dc2626;
}
}
@media (max-width: 900px) {
.dataset-split-row {
flex-direction: column;
align-items: stretch;
}
.dataset-split-config {
flex: auto;
}
.dataset-split-grid {
grid-template-columns: minmax(0, 1fr);
}
.dataset-split-summary {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,382 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ModelItem } from '@/types'
import type { GenerationControlOptions } from './types'
const props = defineProps<{
options: GenerationControlOptions
models?: ModelItem[]
section: 'model' | 'quality'
validationMessage?: string
}>()
const emit = defineEmits<{
'update:options': [value: GenerationControlOptions]
}>()
function updateField<K extends keyof GenerationControlOptions>(
field: K,
value: GenerationControlOptions[K],
) {
emit('update:options', { ...props.options, [field]: value })
}
function updateQualityRules(value: Array<string | number>) {
const rules = value.filter((item): item is string => typeof item === 'string')
emit('update:options', {
...props.options,
filterLowQuality: rules.includes('low_quality'),
filterShortContent: rules.includes('short_content'),
})
}
const selectedQualityRules = () => [
props.options.filterLowQuality ? 'low_quality' : '',
props.options.filterShortContent ? 'short_content' : '',
].filter(Boolean)
function sectionValidationMessage() {
if (!props.validationMessage) return ''
const isModelMessage = props.validationMessage.includes('数据生成模型')
if (props.section === 'model') return isModelMessage ? props.validationMessage : ''
return isModelMessage ? '' : props.validationMessage
}
</script>
<template>
<div v-if="section === 'model'" class="generation-config-group model-config-group">
<div class="model-field">
<div class="field-copy">
<strong>数据生成模型 <em>*</em></strong>
<small>选择负责生成问答指令或结构化内容的大模型</small>
</div>
<el-select
class="model-select"
:model-value="options.generationModelId"
filterable
placeholder="请选择用于生成数据的模型"
aria-label="数据生成模型"
@update:model-value="updateField('generationModelId', $event)"
>
<el-option
v-for="model in models || []"
:key="model.id"
:label="model.name"
:value="model.id"
>
<div class="model-option">
<span>{{ model.name }}</span>
<small>{{ model.model_source === 'api' ? '在线模型' : '本地模型' }}</small>
</div>
</el-option>
<template #empty>
<div class="model-empty">暂无可用的大模型请先在模型管理中添加</div>
</template>
</el-select>
</div>
<div class="model-field">
<div class="field-copy">
<strong>默认提示语</strong>
<small>用于约束生成内容的格式语气和完整性可按任务需要修改</small>
</div>
<el-input
class="prompt-input"
:model-value="options.generationPrompt"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
resize="vertical"
placeholder="请输入模型生成内容时需要遵循的要求"
aria-label="模型默认提示语"
@update:model-value="updateField('generationPrompt', $event)"
/>
<div class="prompt-variables-hint">
提示可在文本中通过 <code v-text="'{{ content }}'" /> 引用当前正在处理的数据内容
</div>
</div>
<div class="advanced-settings-grid">
<label class="config-field">
<span class="config-field-label">生成温度 (Temperature)</span>
<el-slider
:model-value="options.temperature"
:min="0"
:max="1"
:step="0.1"
show-input
@update:model-value="updateField('temperature', Number($event) || 0)"
/>
<small>较低值输出更确定保守较高值输出更多样有创造性</small>
</label>
<label class="config-field">
<span class="config-field-label">最大输出长度 (Max Tokens)</span>
<span class="unit-input">
<el-input-number
:model-value="options.maxTokens"
:min="100"
:max="8192"
:step="256"
controls-position="right"
@update:model-value="updateField('maxTokens', Number($event) || 1024)"
/>
<span>Token</span>
</span>
<small>限制单次生成的最大内容长度防止过度发散</small>
</label>
</div>
<div class="json-mode-row">
<div class="field-copy">
<strong>强制 JSON 格式输出</strong>
<small>要求大模型返回纯 JSON 对象有助于提高下游结构化提取的稳定性</small>
</div>
<el-switch
:model-value="options.jsonMode"
inline-prompt
active-text=""
inactive-text=""
@update:model-value="updateField('jsonMode', Boolean($event))"
/>
</div>
<p v-if="sectionValidationMessage()" class="generation-validation-message" role="alert">
{{ sectionValidationMessage() }}
</p>
</div>
<div v-else class="generation-config-group quality-config-group">
<div class="quality-switch-row">
<div class="config-group-heading">
<div>
<h4>质量筛选</h4>
<p>生成后自动过滤不符合要求的内容不影响源数据预处理</p>
</div>
</div>
<el-switch
:model-value="options.qualityFilterEnabled"
inline-prompt
active-text=""
inactive-text=""
aria-label="质量筛选"
@update:model-value="updateField('qualityFilterEnabled', Boolean($event))"
/>
</div>
<div v-if="options.qualityFilterEnabled" class="quality-rule-panel">
<el-checkbox-group
:model-value="selectedQualityRules()"
class="quality-rule-list"
@update:model-value="updateQualityRules"
>
<el-checkbox value="low_quality">
<span class="rule-copy">
<strong>过滤低质量内容</strong>
<small>按相关性完整性和可读性过滤明显不合格的生成结果</small>
</span>
</el-checkbox>
<el-checkbox value="short_content">
<span class="rule-copy">
<strong>过滤过短内容</strong>
<small>过滤正文长度低于指定字数的生成结果</small>
</span>
</el-checkbox>
</el-checkbox-group>
<label v-if="options.filterShortContent" class="minimum-length-field">
<span>最少字数</span>
<span class="minimum-length-input">
<el-input-number
:model-value="options.minOutputLength"
:min="1"
:max="1000"
:step="5"
:precision="0"
controls-position="right"
aria-label="生成内容最少字数"
@update:model-value="updateField('minOutputLength', Number($event) || 1)"
/>
<span></span>
</span>
<small>少于该字数的生成结果将被过滤</small>
</label>
<p v-if="!options.filterLowQuality && !options.filterShortContent" class="quality-rule-warning" role="alert">
请至少选择一项质量筛选规则
</p>
</div>
<p v-if="sectionValidationMessage()" class="generation-validation-message" role="alert">
{{ sectionValidationMessage() }}
</p>
</div>
</template>
<style scoped lang="scss">
.generation-config-group {
padding: 14px;
border: 1px solid #e2e5ec;
border-radius: 8px;
background: #fff;
}
.config-group-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h4 { margin: 0; color: #344054; font-size: 13px; font-weight: 600; }
p { margin: 4px 0 0; color: #8a93a3; font-size: 12px; line-height: 1.5; }
}
.model-config-group {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.model-config-group .advanced-settings-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.model-config-group .config-field {
padding: 0;
border: 0;
border-radius: 0;
}
.model-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
em { color: var(--el-color-danger); font-style: normal; }
}
.model-select { width: 100%; }
.prompt-input { width: 100%; }
.model-option { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.model-option small { color: #98a2b3; font-size: 11px; }
.model-empty { padding: 12px; color: #8a93a3; font-size: 12px; text-align: center; }
.prompt-variables-hint {
color: #8a93a3;
font-size: 12px;
code {
padding: 2px 4px;
color: #5b50f2;
background: #f0f0ff;
border-radius: 4px;
font-family: inherit;
}
}
.advanced-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.config-field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 14px;
color: #344054;
background: #fff;
border: 1px solid #e2e5ec;
border-radius: 8px;
> small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
:deep(.el-slider) {
margin: 0 8px;
}
}
.config-field-label {
font-size: 13px;
font-weight: 600;
}
.unit-input {
display: flex;
align-items: center;
gap: 8px;
color: #7b8495;
font-size: 12px;
:deep(.el-input-number) {
width: 100%;
}
}
.json-mode-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
.quality-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.quality-rule-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e7eaf0; }
.quality-rule-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.quality-rule-list :deep(.el-checkbox) {
min-height: 62px; height: auto; margin-right: 0; padding: 10px 12px;
align-items: flex-start; border: 1px solid #e2e5ec; border-radius: 7px; background: #fff;
}
.quality-rule-list :deep(.el-checkbox__input) { margin-top: 3px; }
.quality-rule-list :deep(.el-checkbox__label) { min-width: 0; padding-left: 9px; white-space: normal; }
.rule-copy { display: grid; gap: 3px; }
.rule-copy strong { color: #344054; font-size: 12px; font-weight: 600; }
.rule-copy small { color: #8a93a3; font-size: 11px; line-height: 1.5; }
.minimum-length-field {
margin-top: 12px; display: grid; grid-template-columns: 110px 220px minmax(0, 1fr);
align-items: center; gap: 12px; color: #475467; font-size: 12px;
}
.minimum-length-input { display: flex; align-items: center; gap: 8px; }
.minimum-length-input :deep(.el-input-number) { width: 180px; }
.minimum-length-field > small { color: #8a93a3; }
.quality-rule-warning { margin: 10px 0 0; color: #dc2626; font-size: 12px; }
.generation-validation-message { margin: 10px 0 0; color: #dc2626; font-size: 12px; }
@media (max-width: 900px) {
.advanced-settings-grid { grid-template-columns: 1fr; }
.quality-rule-list { grid-template-columns: 1fr; }
.minimum-length-field { grid-template-columns: 1fr; }
}
</style>

View File

@@ -26,7 +26,7 @@ const emit = defineEmits<{
</div> </div>
<dl> <dl>
<div><dt>任务名称</dt><dd>{{ taskName }}</dd></div> <div><dt>任务名称</dt><dd>{{ taskName }}</dd></div>
<div><dt>数据类型</dt><dd>{{ processType === 'unstructured' ? '非结构化数据' : '结构化数据' }}</dd></div> <div><dt>数据类型</dt><dd>{{ processType === 'unstructured' ? '非结构化数据' : processType === 'external' ? '外来数据源拉取' : '结构化数据' }}</dd></div>
<div><dt>源文件</dt><dd>{{ fileName }}</dd></div> <div><dt>源文件</dt><dd>{{ fileName }}</dd></div>
<div><dt>预览条目</dt><dd>{{ previewCount.toLocaleString() }} </dd></div> <div><dt>预览条目</dt><dd>{{ previewCount.toLocaleString() }} </dd></div>
<div><dt>已修改</dt><dd>{{ modifiedCount.toLocaleString() }} </dd></div> <div><dt>已修改</dt><dd>{{ modifiedCount.toLocaleString() }} </dd></div>
@@ -97,7 +97,7 @@ const emit = defineEmits<{
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 15px 17px; padding: 15px 17px;
background: #fbfcfe; background: #fff;
border-bottom: 1px solid #e8ebf0; border-bottom: 1px solid #e8ebf0;
strong { strong {

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { ModelItem } from '@/types'
import type { GenerationControlOptions } from './types'
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
const props = defineProps<{
options: GenerationControlOptions
models: ModelItem[]
}>()
const emit = defineEmits<{
'update:options': [value: GenerationControlOptions]
}>()
const validationAttempted = ref(false)
const modelValidationMessage = computed(() => (
props.options.generationModelId === '' ? '请选择数据生成模型' : ''
))
function validate() {
validationAttempted.value = true
if (!modelValidationMessage.value) return true
ElMessage.error(modelValidationMessage.value)
return false
}
defineExpose({ validate })
</script>
<template>
<section class="model-selection-step" aria-labelledby="model-selection-title">
<div class="form-section">
<div class="section-title-row">
<div>
<h3 id="model-selection-title">大模型选择</h3>
<p>选择本次数据生成使用的模型并设置统一的输出要求</p>
</div>
</div>
<div class="model-form-content">
<GenerationOptionsPanel
:options="options"
:models="models"
section="model"
:validation-message="validationAttempted ? modelValidationMessage : ''"
@update:options="emit('update:options', $event)"
/>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.model-selection-step {
width: 100%;
}
.form-section {
padding: 0 0 26px;
margin-bottom: 26px;
border-bottom: 1px solid #edf0f5;
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin: 0 0 5px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
line-height: 1.6;
}
}
.model-form-content {
margin-top: 16px;
}
</style>

View File

@@ -350,7 +350,7 @@ function lineRange(item: PreviewItem) {
min-height: 52px; min-height: 52px;
padding: 0 15px; padding: 0 15px;
color: #313949; color: #313949;
background: #fbfcfe; background: #fff;
border-bottom: 1px solid #e8ebf0; border-bottom: 1px solid #e8ebf0;
font-size: 13px; font-size: 13px;

View File

@@ -143,7 +143,7 @@ function selectRelative(offset: number) {
min-height: 52px; min-height: 52px;
padding: 0 15px; padding: 0 15px;
color: #344054; color: #344054;
background: #fbfcfe; background: #fff;
border-bottom: 1px solid #e8ebf0; border-bottom: 1px solid #e8ebf0;
font-size: 13px; font-size: 13px;

View File

@@ -0,0 +1,558 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { UploadFile } from 'element-plus'
import type { ExternalDataSource, ProcessType, UploadedDataFile } from './types'
const props = defineProps<{
processType: ProcessType
uploadedFiles: UploadedDataFile[]
externalSource: ExternalDataSource
externalPulling: boolean
externalConnected: boolean
}>()
const emit = defineEmits<{
'update:externalSource': [value: ExternalDataSource]
'file-change': [file: UploadFile]
'remove-file': [uid: string | number]
'use-sample': []
'test-connection': []
'pull-data': []
}>()
const DATA_SOURCE_TYPES = [
{ value: 'mysql', label: 'MySQL' },
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'mongodb', label: 'MongoDB' },
{ value: 'api', label: 'REST API' },
]
const AUTH_MODES = [
{ value: 'none', label: '免鉴权' },
{ value: 'basic', label: '账号密码' },
{ value: 'token', label: 'Token' },
]
const FILE_PAGE_SIZE = 10
const currentFilePage = ref(1)
const isExternal = computed(() => props.processType === 'external')
const uploadAccept = computed(() => props.processType === 'unstructured'
? '.txt,.md,.pdf,.docx,.doc,.json,.jsonl'
: '.json,.jsonl,.csv,.xlsx,.xls')
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))
if (newLength > oldLength) {
currentFilePage.value = totalPages
return
}
currentFilePage.value = Math.min(currentFilePage.value, totalPages)
})
watch(() => props.processType, () => {
currentFilePage.value = 1
})
function updateExternalField<K extends keyof ExternalDataSource>(field: K, value: ExternalDataSource[K]) {
emit('update:externalSource', { ...props.externalSource, [field]: value })
}
function formatSize(size: number) {
if (!size) return '0 KB'
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
return `${(size / 1024).toFixed(1)} KB`
}
</script>
<template>
<section class="source-upload-step" aria-labelledby="source-upload-title">
<div v-if="isExternal" class="form-section external-section">
<div class="section-title-row">
<div>
<h3 id="source-upload-title">数据源配置</h3>
<p>配置并验证外部数据源拉取成功后可在下一步预览数据内容</p>
</div>
</div>
<div class="external-form">
<el-form label-position="top" class="external-grid">
<el-form-item label="数据源类型">
<el-select
:model-value="externalSource.type"
placeholder="请选择数据源类型"
aria-label="数据源类型"
@update:model-value="updateExternalField('type', $event)"
>
<el-option
v-for="item in DATA_SOURCE_TYPES"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="地址 / URL">
<el-input
:model-value="externalSource.url"
placeholder="例如mysql://host:3306/db 或 https://api.example.com/data"
aria-label="数据源地址或 URL"
@update:model-value="updateExternalField('url', $event)"
/>
</el-form-item>
<el-form-item label="鉴权方式">
<el-select
:model-value="externalSource.authMode"
aria-label="鉴权方式"
@update:model-value="updateExternalField('authMode', $event)"
>
<el-option
v-for="item in AUTH_MODES"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'basic'" label="账号">
<el-input
:model-value="externalSource.username"
autocomplete="username"
placeholder="请输入账号"
aria-label="数据源账号"
@update:model-value="updateExternalField('username', $event)"
/>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'basic'" label="密码">
<el-input
:model-value="externalSource.password"
type="password"
show-password
autocomplete="current-password"
placeholder="请输入密码"
aria-label="数据源密码"
@update:model-value="updateExternalField('password', $event)"
/>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'token'" label="Token">
<el-input
:model-value="externalSource.token"
type="password"
show-password
autocomplete="off"
placeholder="请输入访问 Token"
aria-label="数据源访问 Token"
@update:model-value="updateExternalField('token', $event)"
/>
</el-form-item>
<el-form-item label="拉取条数">
<el-input-number
:model-value="externalSource.limit"
:min="1"
:max="100000"
:step="100"
controls-position="right"
aria-label="数据拉取条数"
@update:model-value="updateExternalField('limit', Number($event) || 0)"
/>
</el-form-item>
</el-form>
<div class="external-actions">
<el-button
:loading="externalPulling && !externalConnected"
:disabled="externalPulling"
plain
@click="emit('test-connection')"
>
测试连接
</el-button>
<el-button
type="primary"
:loading="externalPulling"
:disabled="externalPulling"
@click="emit('pull-data')"
>
拉取数据
</el-button>
<span v-if="externalConnected" class="external-status is-connected" role="status">
<i class="fa fa-check-circle" aria-hidden="true" /> 连接正常
</span>
</div>
<section v-if="uploadedFiles.length" class="uploaded-file-list" aria-label="已拉取数据列表">
<div class="uploaded-file-list-header">
<span>已拉取 {{ uploadedFiles.length }} 个数据集</span>
</div>
<div class="uploaded-file-items">
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
<span class="file-icon"><i class="fa fa-cloud-download" aria-hidden="true" /></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" aria-hidden="true" /> 拉取成功</span>
<el-button
link
type="danger"
:aria-label="`删除数据集 ${file.name}`"
@click="emit('remove-file', file.uid)"
>
删除
</el-button>
</div>
</div>
<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="已拉取数据分页"
/>
</section>
</div>
</div>
<div v-else class="form-section upload-section">
<div class="section-title-row">
<div>
<h3 id="source-upload-title">源数据上传</h3>
<p>上传后可在下一步检查内容和切分效果支持同时添加多个文件</p>
</div>
<el-button v-if="uploadedFiles.length === 0" link type="primary" @click="emit('use-sample')">
使用示例数据
</el-button>
</div>
<el-upload
v-if="uploadedFiles.length === 0"
drag
multiple
:accept="uploadAccept"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
aria-label="选择或拖拽源数据文件"
>
<i class="fa fa-cloud-upload upload-icon" aria-hidden="true" />
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择文件</em></div>
<template #tip>
<div class="el-upload__tip">
{{ processType === 'unstructured'
? '支持 TXT、Markdown、PDF、Word、JSON、JSONL单文件不超过 200MB'
: '支持 JSON、JSONL、CSV、Excel单文件不超过 200MB' }}
</div>
</template>
</el-upload>
<section v-else class="uploaded-file-list" aria-label="已上传文件列表">
<div class="uploaded-file-list-header">
<span>已添加 {{ uploadedFiles.length }} 个文件</span>
<div class="continue-upload">
<el-upload
multiple
:accept="uploadAccept"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
aria-label="继续添加源数据文件"
>
<el-button size="small" type="primary">继续上传</el-button>
</el-upload>
</div>
</div>
<div class="uploaded-file-items">
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
<span class="file-icon"><i class="fa fa-file-text-o" aria-hidden="true" /></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" aria-hidden="true" /> 校验通过</span>
<el-button
link
type="danger"
:aria-label="`删除文件 ${file.name}`"
@click="emit('remove-file', file.uid)"
>
删除
</el-button>
</div>
</div>
<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="已上传文件分页"
/>
</section>
</div>
</section>
</template>
<style scoped lang="scss">
.source-upload-step {
width: 100%;
}
.form-section {
padding: 0;
h3 {
margin: 0 0 5px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
line-height: 1.6;
}
}
.external-section {
.external-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
margin-top: 16px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-select),
:deep(.el-input),
:deep(.el-input-number) {
width: 100%;
}
}
.external-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 22px;
}
.external-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
&.is-connected {
color: #2ca66a;
}
}
.upload-section :deep(.el-upload) {
width: 100%;
margin-top: 16px;
}
.upload-section :deep(.el-upload-dragger) {
width: 100%;
min-height: 154px;
padding: 32px 20px;
background: #fff;
border-color: #dfe3ea;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover,
&:focus-visible {
background: #fafaff;
border-color: #8b82f4;
}
&:focus-visible {
outline: 2px solid #5b50f2;
outline-offset: 2px;
}
}
.upload-icon {
margin-bottom: 12px;
color: #5b50f2;
font-size: 30px;
}
.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: #fff;
border-bottom: 1px solid #edf0f5;
}
.continue-upload {
flex: 0 0 auto;
}
.continue-upload :deep(.el-upload) {
width: auto;
margin-top: 0;
}
.uploaded-file-pagination {
display: flex;
justify-content: flex-end;
padding: 10px 14px;
border-top: 1px solid #edf0f5;
}
.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 {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
color: #5b50f2;
font-size: 14px;
background: #f0efff;
border-radius: 7px;
}
.file-main {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
strong {
overflow: hidden;
color: #273142;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
color: #8a93a3;
font-size: 12px;
}
}
.file-status {
color: #2ca66a;
font-size: 12px;
}
.uploaded-file :deep(.el-button) {
flex: 0 0 auto;
}
@media (max-width: 900px) {
.external-section .external-grid {
grid-template-columns: minmax(0, 1fr);
}
.uploaded-file {
gap: 8px;
padding: 8px 10px;
}
.file-status {
flex: 0 1 auto;
line-height: 1.4;
white-space: normal;
}
.uploaded-file-pagination {
justify-content: center;
}
}
@media (max-width: 560px) {
.section-title-row {
align-items: flex-start;
flex-direction: column;
}
.uploaded-file-list-header {
align-items: flex-start;
flex-direction: column;
}
.uploaded-file {
align-items: flex-start;
flex-wrap: wrap;
}
.file-main {
min-width: calc(100% - 40px);
}
.file-status {
margin-left: 38px;
}
}
@media (prefers-reduced-motion: reduce) {
.upload-section :deep(.el-upload-dragger) {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import type {
GenerationControlOptions,
PreprocessOption,
StructuredProcessOptions,
} from './types'
import DatasetSplitEditor from './DatasetSplitEditor.vue'
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
const props = defineProps<{
options: StructuredProcessOptions
validationAttempted: boolean
qualityValidationMessage: string
}>()
const emit = defineEmits<{
'update:options': [value: StructuredProcessOptions]
}>()
const PREPROCESS_OPTIONS: Array<{
value: PreprocessOption
label: string
description: string
}> = [
{ value: 'clean_invalid', label: '清理无效数据', description: '处理空行、空列和残缺行' },
{ value: 'detect_structure', label: '识别表格结构', description: '识别表头、多级表头和合并单元格' },
{ value: 'deduplicate', label: '重复数据去重', description: '删除完全重复或关键字段重复的数据' },
{ value: 'normalize_format', label: '数据格式标准化', description: '统一日期、数字、单位和枚举值格式' },
{ value: 'filter_anomaly', label: '异常数据过滤', description: '过滤乱码、无效内容和异常记录' },
{ value: 'desensitize', label: '敏感信息脱敏', description: '处理姓名、手机号、邮箱等敏感信息' },
]
function updateField<K extends keyof StructuredProcessOptions>(
field: K,
value: StructuredProcessOptions[K],
) {
emit('update:options', { ...props.options, [field]: value })
}
function updateGenerationOptions(value: GenerationControlOptions) {
emit('update:options', { ...props.options, ...value })
}
function updatePreprocessOptions(value: Array<string | number | boolean>) {
const allowedValues = new Set(PREPROCESS_OPTIONS.map((option) => option.value))
const preprocessOptions = value.filter(
(option): option is PreprocessOption => typeof option === 'string' && allowedValues.has(option as PreprocessOption),
)
updateField('preprocessOptions', preprocessOptions)
}
</script>
<template>
<div class="form-section structured-options-section">
<div class="section-title-row">
<div>
<h3>预处理选项</h3>
<p>选择在生成问答对之前需要执行的数据处理方式</p>
</div>
</div>
<el-checkbox-group
:model-value="options.preprocessOptions"
class="preprocess-option-grid"
@update:model-value="updatePreprocessOptions"
>
<el-checkbox
v-for="option in PREPROCESS_OPTIONS"
:key="option.value"
:value="option.value"
class="preprocess-option"
>
<span class="preprocess-option-copy">
<strong>{{ option.label }}</strong>
<small>{{ option.description }}</small>
</span>
</el-checkbox>
</el-checkbox-group>
</div>
<div class="form-section generation-options-section">
<div class="section-title-row">
<div>
<h3>生成选项</h3>
<p>配置每条结构化记录生成问答对的方式和数量</p>
</div>
</div>
<div class="generation-option-list">
<GenerationOptionsPanel
:options="options"
section="quality"
:validation-message="validationAttempted ? qualityValidationMessage : ''"
@update:options="updateGenerationOptions"
/>
<div class="generation-option-row">
<div class="generation-option-copy">
<strong>每行生成数量</strong>
<small>每行结构化数据生成的问答对数量</small>
</div>
<el-input-number
:model-value="options.qaPairsPerRow"
:min="1"
:max="5"
:step="1"
controls-position="right"
@update:model-value="updateField('qaPairsPerRow', Number($event) || 1)"
/>
</div>
<DatasetSplitEditor
:model-value="options.datasetSplit"
@update:model-value="updateField('datasetSplit', $event)"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.form-section {
padding: 0 0 26px;
margin-bottom: 26px;
border-bottom: 1px solid #edf0f5;
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
h3 {
margin: 0 0 16px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin-bottom: 5px;
}
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
}
}
.preprocess-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.preprocess-option {
display: flex;
gap: 10px;
width: 100%;
min-height: 64px;
margin-right: 0;
padding: 12px 14px;
box-sizing: border-box;
align-items: flex-start;
border: 1px solid #e2e5ec;
border-radius: 8px;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover {
border-color: #b7b2f7;
}
&.is-checked {
background: #fafaff;
border-color: #8b82f4;
}
:deep(.el-checkbox__input) {
margin-top: 3px;
}
:deep(.el-checkbox__label) {
min-width: 0;
padding-left: 10px;
white-space: normal;
}
}
.preprocess-option-copy,
.generation-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.generation-option-list {
display: grid;
gap: 12px;
margin-top: 16px;
}
.generation-option-row {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
@media (max-width: 900px) {
.preprocess-option-grid {
grid-template-columns: minmax(0, 1fr);
}
.generation-option-row {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -1,30 +1,86 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref } from 'vue'
import type { FormInstance, FormRules, UploadFile } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { ProcessType } from './types' import type {
GenerationControlOptions,
ProcessType,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
import StructuredOptionsPanel from './StructuredOptionsPanel.vue'
import UnstructuredOptionsPanel from './UnstructuredOptionsPanel.vue'
const props = defineProps<{ const props = defineProps<{
name: string name: string
description: string description: string
processType: ProcessType processType: ProcessType
uploadedFiles: { uid: string | number; name: string; size: number; count: number }[] structuredOptions: StructuredProcessOptions
unstructuredOptions: UnstructuredProcessOptions
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:name': [value: string] 'update:name': [value: string]
'update:description': [value: string] 'update:description': [value: string]
'update:processType': [value: ProcessType] 'update:processType': [value: ProcessType]
'file-change': [file: UploadFile] 'update:structuredOptions': [value: StructuredProcessOptions]
'remove-file': [uid: string | number] 'update:unstructuredOptions': [value: UnstructuredProcessOptions]
'use-sample': []
}>() }>()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const unstructuredOptionsPanelRef = ref<InstanceType<typeof UnstructuredOptionsPanel>>()
const validationAttempted = ref(false)
const formModel = computed(() => ({ const formModel = computed(() => ({
name: props.name, name: props.name,
processType: props.processType, processType: props.processType,
})) }))
const splitTotal = computed(() => {
const { train, validation, test } = props.structuredOptions.datasetSplit
return train + validation + test
})
const unstructuredSplitTotal = computed(() => {
const { train, validation, test } = props.unstructuredOptions.datasetSplit
return train + validation + test
})
const activeGenerationOptions = computed<GenerationControlOptions | null>(() => {
if (props.processType === 'structured') return props.structuredOptions
if (props.processType === 'unstructured') return props.unstructuredOptions
return null
})
const qualityValidationMessage = computed(() => {
const options = activeGenerationOptions.value
if (!options) return ''
if (options.qualityFilterEnabled && !options.filterLowQuality && !options.filterShortContent) {
return '开启质量筛选后,请至少选择一项筛选规则'
}
if (options.qualityFilterEnabled && options.filterShortContent && options.minOutputLength < 1) {
return '最少字数必须大于 0'
}
return ''
})
const chunkValidationMessage = computed(() => {
if (props.unstructuredOptions.chunkOverlap >= props.unstructuredOptions.chunkSize) {
return '重叠长度必须小于切片长度'
}
if (props.unstructuredOptions.minChunkSize > props.unstructuredOptions.chunkSize) {
return '最小切片长度不能大于切片长度'
}
if (
props.unstructuredOptions.chunkOverlap + props.unstructuredOptions.minChunkSize
> props.unstructuredOptions.chunkSize
) {
return '重叠长度与最小切片长度之和不能大于切片长度'
}
if (props.unstructuredOptions.chunkMethod === 'custom' && !props.unstructuredOptions.customDelimiter.trim()) {
return '请输入自定义分隔符'
}
return ''
})
const rules: FormRules = { const rules: FormRules = {
name: [ name: [
{ required: true, message: '请输入任务名称', trigger: 'blur' }, { required: true, message: '请输入任务名称', trigger: 'blur' },
@@ -33,38 +89,33 @@ const rules: FormRules = {
processType: [{ required: true, message: '请选择数据处理类型', trigger: 'change' }], processType: [{ required: true, message: '请选择数据处理类型', trigger: 'change' }],
} }
const uploadAccept = computed(() => props.processType === 'unstructured'
? '.txt,.md,.pdf,.docx,.doc,.json,.jsonl'
: '.json,.jsonl,.csv,.xlsx,.xls')
const FILE_PAGE_SIZE = 10
const currentFilePage = ref(1)
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))
if (newLength > oldLength) {
currentFilePage.value = totalPages
return
}
currentFilePage.value = Math.min(currentFilePage.value, totalPages)
})
function formatSize(size: number) {
if (!size) return '0 KB'
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
return `${(size / 1024).toFixed(1)} KB`
}
async function validate() { async function validate() {
validationAttempted.value = true
if (!formRef.value) return false if (!formRef.value) return false
try { try {
await formRef.value.validate() await formRef.value.validate()
if (props.processType === 'structured' && splitTotal.value !== 100) {
ElMessage.error('结构化数据处理:数据集划分比例总和必须为 100%')
return false
}
if (props.processType === 'unstructured') {
if (unstructuredSplitTotal.value !== 100) {
ElMessage.error('非结构化数据处理:数据集划分比例总和必须为 100%')
return false
}
if (chunkValidationMessage.value) {
unstructuredOptionsPanelRef.value?.revealValidation()
ElMessage.error(chunkValidationMessage.value)
return false
}
}
if (qualityValidationMessage.value) {
ElMessage.error(qualityValidationMessage.value)
return false
}
return true return true
} catch { } catch {
ElMessage.error('请完善标红的必填项')
return false return false
} }
} }
@@ -74,7 +125,7 @@ defineExpose({ validate })
<template> <template>
<section class="task-setup-step"> <section class="task-setup-step">
<el-form ref="formRef" :model="formModel" :rules="rules" label-position="top"> <el-form ref="formRef" :model="formModel" :rules="rules" label-position="top" scroll-to-error>
<div class="form-section"> <div class="form-section">
<h3>基本信息</h3> <h3>基本信息</h3>
<div class="basic-grid"> <div class="basic-grid">
@@ -105,7 +156,7 @@ defineExpose({ validate })
<div class="section-title-row"> <div class="section-title-row">
<div> <div>
<h3>处理类型</h3> <h3>处理类型</h3>
<p>类型影响后续预览方式不会改变四步流程</p> <p>类型影响下一步支持的数据源格式和后续预览方式</p>
</div> </div>
</div> </div>
<el-form-item prop="processType" class="type-form-item"> <el-form-item prop="processType" class="type-form-item">
@@ -136,80 +187,40 @@ defineExpose({ validate })
</span> </span>
<i class="fa fa-check-circle selection-mark" /> <i class="fa fa-check-circle selection-mark" />
</button> </button>
<button
type="button"
class="type-option"
:class="{ 'is-active': processType === 'external' }"
@click="emit('update:processType', 'external')"
>
<span class="type-icon"><i class="fa fa-cloud-download" /></span>
<span>
<strong>外来数据源拉取</strong>
<small>适用于数据库API 接口等需远程拉取的外部数据</small>
</span>
<i class="fa fa-check-circle selection-mark" />
</button>
</div> </div>
</el-form-item> </el-form-item>
</div> </div>
<div class="form-section upload-section"> <StructuredOptionsPanel
<div class="section-title-row"> v-if="processType === 'structured'"
<div> :options="structuredOptions"
<h3>源数据上传</h3> :validation-attempted="validationAttempted"
<p>系统将在下一步生成可对照编辑的预览内容支持上传多个文件</p> :quality-validation-message="qualityValidationMessage"
</div> @update:options="emit('update:structuredOptions', $event)"
<el-button v-if="uploadedFiles.length === 0" link type="primary" @click="emit('use-sample')"> />
使用示例数据
</el-button>
</div>
<el-upload <UnstructuredOptionsPanel
v-if="uploadedFiles.length === 0" v-if="processType === 'unstructured'"
drag ref="unstructuredOptionsPanelRef"
multiple :options="unstructuredOptions"
:accept="uploadAccept" :validation-attempted="validationAttempted"
:auto-upload="false" :quality-validation-message="qualityValidationMessage"
:show-file-list="false" :chunk-validation-message="chunkValidationMessage"
:on-change="(file: UploadFile) => emit('file-change', file)" @update:options="emit('update:unstructuredOptions', $event)"
> />
<i class="fa fa-cloud-upload upload-icon" />
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择文件</em></div>
<template #tip>
<div class="el-upload__tip">
{{ processType === 'unstructured'
? '支持 TXT、Markdown、PDF、Word、JSON、JSONL单文件不超过 200MB'
: '支持 JSON、JSONL、CSV、Excel单文件不超过 200MB' }}
</div>
</template>
</el-upload>
<section v-else class="uploaded-file-list" aria-label="已上传文件列表">
<div class="uploaded-file-list-header">
<span>已添加 {{ uploadedFiles.length }} 个文件</span>
<div class="continue-upload">
<el-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>
<div class="uploaded-file-items">
<div v-for="file in pagedUploadedFiles" :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>
<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"
/>
</section>
</div>
</el-form> </el-form>
</section> </section>
</template> </template>
@@ -219,17 +230,11 @@ defineExpose({ validate })
width: 100%; width: 100%;
} }
.form-section { .form-section {
padding: 0 0 26px; padding: 0 0 26px;
margin-bottom: 26px; margin-bottom: 26px;
border-bottom: 1px solid #edf0f5; border-bottom: 1px solid #edf0f5;
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
h3 { h3 {
margin: 0 0 16px; margin: 0 0 16px;
color: #2f3747; color: #2f3747;
@@ -267,7 +272,7 @@ defineExpose({ validate })
.type-options { .type-options {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px; gap: 16px;
width: 100%; width: 100%;
} }
@@ -315,8 +320,7 @@ defineExpose({ validate })
} }
} }
.type-icon, .type-icon {
.file-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -341,129 +345,9 @@ defineExpose({ validate })
opacity: 1; opacity: 1;
} }
.upload-section :deep(.el-upload) {
width: 100%;
margin-top: 16px;
}
.upload-section :deep(.el-upload-dragger) {
width: 100%;
min-height: 154px;
padding: 32px 20px;
background: #fbfcfe;
border-color: #dfe3ea;
}
.upload-icon {
margin-bottom: 12px;
color: #5b50f2;
font-size: 30px;
}
.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;
}
.continue-upload {
flex: 0 0 auto;
}
.continue-upload :deep(.el-upload) {
width: auto;
margin-top: 0;
}
.uploaded-file-pagination {
display: flex;
justify-content: flex-end;
padding: 10px 14px;
border-top: 1px solid #edf0f5;
}
.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 {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #273142;
font-size: 14px;
}
span {
color: #8a93a3;
font-size: 12px;
}
}
.file-status {
color: #2ca66a;
font-size: 12px;
}
.uploaded-file :deep(.el-button) {
flex: 0 0 auto;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.type-options { .type-options {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.uploaded-file {
gap: 8px;
padding: 8px 10px;
}
.file-status {
flex: 0 1 auto;
line-height: 1.4;
white-space: normal;
}
.uploaded-file-pagination {
justify-content: center;
}
} }
</style> </style>

View File

@@ -0,0 +1,512 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
ChunkMethod,
GenerationControlOptions,
UnstructuredPreprocessOption,
UnstructuredProcessOptions,
} from './types'
import DatasetSplitEditor from './DatasetSplitEditor.vue'
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
const props = defineProps<{
options: UnstructuredProcessOptions
validationAttempted: boolean
qualityValidationMessage: string
chunkValidationMessage: string
}>()
const emit = defineEmits<{
'update:options': [value: UnstructuredProcessOptions]
}>()
const SMART_PREPROCESS_OPTIONS: UnstructuredPreprocessOption[] = [
'clean_invalid_content',
'detect_document_structure',
'merge_short_content',
'filter_low_quality',
'deduplicate_content',
'preserve_context',
]
const CHUNK_METHODS: Array<{ value: ChunkMethod; label: string }> = [
{ value: 'semantic', label: '自动语义切分' },
{ value: 'heading', label: '按标题和段落' },
{ value: 'fixed', label: '按固定长度' },
{ value: 'custom', label: '自定义分隔符' },
]
const UNSTRUCTURED_NUMBER_LIMITS = {
chunkSize: { min: 200, max: 2000 },
chunkOverlap: { min: 0, max: 500 },
minChunkSize: { min: 20, max: 500 },
qaPairsPerChunk: { min: 1, max: 3 },
} as const
type UnstructuredNumberField = keyof typeof UNSTRUCTURED_NUMBER_LIMITS
const smartPreprocessEnabled = computed(() => SMART_PREPROCESS_OPTIONS.every(
(option) => props.options.preprocessOptions.includes(option),
))
const desensitizeEnabled = computed(() => props.options.preprocessOptions.includes('desensitize'))
const preserveSpecialContentEnabled = computed(() => (
props.options.preserveTables
&& props.options.preserveCodeBlocks
&& props.options.preserveLists
))
function updateField<K extends keyof UnstructuredProcessOptions>(
field: K,
value: UnstructuredProcessOptions[K],
) {
emit('update:options', { ...props.options, [field]: value })
}
function updateGenerationOptions(value: GenerationControlOptions) {
emit('update:options', { ...props.options, ...value })
}
function updateSmartPreprocess(value: string | number | boolean) {
const enabled = Boolean(value)
const remainingOptions = props.options.preprocessOptions.filter(
(option) => !SMART_PREPROCESS_OPTIONS.includes(option),
)
updateField(
'preprocessOptions',
enabled ? [...SMART_PREPROCESS_OPTIONS, ...remainingOptions] : remainingOptions,
)
}
function updateDesensitize(value: string | number | boolean) {
const preprocessOptions: UnstructuredPreprocessOption[] = props.options.preprocessOptions.filter(
(option) => option !== 'desensitize',
)
if (Boolean(value)) preprocessOptions.push('desensitize')
updateField('preprocessOptions', preprocessOptions)
}
function updateSpecialContentProtection(value: string | number | boolean) {
const enabled = Boolean(value)
emit('update:options', {
...props.options,
preserveTables: enabled,
preserveCodeBlocks: enabled,
preserveLists: enabled,
})
}
function updateUnstructuredNumber(field: UnstructuredNumberField, value: number | undefined) {
const limits = UNSTRUCTURED_NUMBER_LIMITS[field]
const parsedValue = Number(value)
const nextValue = Number.isFinite(parsedValue) ? parsedValue : limits.min
updateField(field, Math.min(limits.max, Math.max(limits.min, nextValue)))
}
function revealValidation() {
// Advanced settings are now flattened, no need to open toggle
}
defineExpose({ revealValidation })
</script>
<template>
<div class="form-section unstructured-options-section">
<div class="section-title-row">
<div>
<h3>预处理选项</h3>
<p>默认使用推荐策略只需决定是否需要脱敏</p>
</div>
</div>
<div class="preprocess-option-grid">
<label class="preprocess-option" :class="{ 'is-checked': smartPreprocessEnabled }">
<el-checkbox
:model-value="smartPreprocessEnabled"
@update:model-value="updateSmartPreprocess"
/>
<span class="preprocess-option-copy">
<strong>智能预处理</strong>
<small>自动清理解析去重及保留上下文</small>
</span>
</label>
<label class="preprocess-option" :class="{ 'is-checked': desensitizeEnabled }">
<el-checkbox
:model-value="desensitizeEnabled"
@update:model-value="updateDesensitize"
/>
<span class="preprocess-option-copy">
<strong>敏感信息脱敏</strong>
<small>处理姓名手机号等隐私信息</small>
</span>
</label>
<label class="preprocess-option" :class="{ 'is-checked': preserveSpecialContentEnabled }">
<el-checkbox
:model-value="preserveSpecialContentEnabled"
@update:model-value="updateSpecialContentProtection"
/>
<span class="preprocess-option-copy">
<strong>保护表格代码和列表</strong>
<small>避免切分点破坏特殊内容块的完整性</small>
</span>
</label>
</div>
</div>
<div class="form-section chunk-options-section">
<div class="section-title-row">
<div>
<h3>切分选项</h3>
<p>以语义完整为优先将长文档拆成可独立生成问答的内容块</p>
</div>
</div>
<div class="chunk-settings-grid is-basic">
<label class="config-field">
<span class="config-field-label">切分方式</span>
<el-select
:model-value="options.chunkMethod"
aria-label="切分方式"
@update:model-value="updateField('chunkMethod', $event)"
>
<el-option
v-for="method in CHUNK_METHODS"
:key="method.value"
:label="method.label"
:value="method.value"
/>
</el-select>
<small>推荐使用自动语义切分在长度限制内优先保留完整句段</small>
</label>
<label class="config-field">
<span class="config-field-label">切片长度</span>
<span class="unit-input">
<el-input-number
:model-value="options.chunkSize"
:min="200"
:max="2000"
:step="50"
:precision="0"
controls-position="right"
aria-label="切片长度"
@update:model-value="updateUnstructuredNumber('chunkSize', $event)"
/>
<span>Token</span>
</span>
<small>单个切片的目标上限默认 800 Token</small>
</label>
<label class="config-field">
<span class="config-field-label">重叠长度</span>
<span class="unit-input">
<el-input-number
:model-value="options.chunkOverlap"
:min="0"
:max="500"
:step="10"
:precision="0"
controls-position="right"
aria-label="重叠长度"
@update:model-value="updateUnstructuredNumber('chunkOverlap', $event)"
/>
<span>Token</span>
</span>
<small>在相邻切片中最多重复保留的上下文默认 100 Token</small>
</label>
<label class="config-field">
<span class="config-field-label">最小切片长度</span>
<span class="unit-input">
<el-input-number
:model-value="options.minChunkSize"
:min="20"
:max="500"
:step="10"
:precision="0"
controls-position="right"
aria-label="最小切片长度"
@update:model-value="updateUnstructuredNumber('minChunkSize', $event)"
/>
<span>Token</span>
</span>
<small>过短的尾部内容会尽量并入前一个切片</small>
</label>
<label v-if="options.chunkMethod === 'custom'" class="config-field">
<span class="config-field-label">自定义分隔符</span>
<el-input
:model-value="options.customDelimiter"
maxlength="40"
show-word-limit
placeholder="例如:--- 或 ###"
aria-label="自定义分隔符"
@update:model-value="updateField('customDelimiter', $event)"
/>
<small>系统会优先在分隔符位置结束当前切片</small>
</label>
</div>
<p class="chunk-estimation-note">
Token 数为轻量估算值实际长度以训练使用的模型分词器为准
</p>
<p v-if="chunkValidationMessage" class="option-validation-message" role="alert">
{{ chunkValidationMessage }}
</p>
</div>
<div class="form-section generation-options-section">
<div class="section-title-row">
<div>
<h3>生成选项</h3>
<p>只保留会直接影响输出的核心设置</p>
</div>
</div>
<div class="generation-option-list">
<GenerationOptionsPanel
:options="options"
section="quality"
:validation-message="validationAttempted ? qualityValidationMessage : ''"
@update:options="updateGenerationOptions"
/>
<div class="generation-option-row">
<div class="generation-option-copy">
<strong>每个切片生成数量</strong>
<small>每个内容切片最多生成 3 个不同角度的问答对</small>
</div>
<el-input-number
:model-value="options.qaPairsPerChunk"
:min="1"
:max="3"
:step="1"
:precision="0"
controls-position="right"
aria-label="每个切片生成数量"
@update:model-value="updateUnstructuredNumber('qaPairsPerChunk', $event)"
/>
</div>
<DatasetSplitEditor
:model-value="options.datasetSplit"
aria-label-prefix="非结构化"
@update:model-value="updateField('datasetSplit', $event)"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.form-section {
padding: 0 0 26px;
margin-bottom: 26px;
border-bottom: 1px solid #edf0f5;
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
h3 {
margin: 0 0 16px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin-bottom: 5px;
}
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
}
}
.generation-option-list {
display: grid;
gap: 12px;
margin-top: 16px;
}
.preprocess-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.preprocess-option {
display: flex;
gap: 10px;
width: 100%;
min-height: 64px;
margin-right: 0;
padding: 12px 14px;
box-sizing: border-box;
align-items: flex-start;
border: 1px solid #e2e5ec;
border-radius: 8px;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover {
border-color: #b7b2f7;
}
&.is-checked {
background: #fafaff;
border-color: #8b82f4;
}
:deep(.el-checkbox__input) {
margin-top: 3px;
}
:deep(.el-checkbox__label) {
min-width: 0;
padding-left: 10px;
white-space: normal;
}
}
.preprocess-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.generation-option-row {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
.generation-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.chunk-settings-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.config-field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 14px;
color: #344054;
background: #fff;
border: 1px solid #e2e5ec;
border-radius: 8px;
> small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
:deep(.el-select),
:deep(.el-input) {
width: 100%;
}
}
.config-field-label {
font-size: 13px;
font-weight: 600;
}
.unit-input {
display: flex;
align-items: center;
gap: 8px;
color: #7b8495;
font-size: 12px;
:deep(.el-input-number) {
width: 100%;
}
}
.option-validation-message {
display: block;
margin: 8px 0 0;
color: #dc2626;
font-size: 12px;
line-height: 1.5;
}
.chunk-estimation-note {
margin: 9px 0 0;
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 900px) {
.chunk-settings-grid {
grid-template-columns: minmax(0, 1fr);
}
.generation-option-row {
align-items: flex-start;
flex-direction: column;
}
.compact-option-list .generation-option-row {
align-items: center;
flex-direction: row;
}
.preprocess-option-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,211 @@
.create-wizard-layout {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #eef0f5;
border-radius: 8px;
background: #fff;
}
.wizard-main {
flex: 1;
overflow-y: auto;
padding: 32px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
.wizard-main-inner {
min-height: 100%;
box-sizing: border-box;
}
.wizard-steps-container {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px dashed #e2e8f0;
}
.custom-wizard-steps {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1040px;
margin: 0 auto;
padding: 0 20px;
}
.step-item {
display: flex;
align-items: center;
flex: none;
}
.step-connector {
flex: 1;
height: 2px;
margin: 0 16px;
background-color: #e2e8f0;
transition: background-color 0.3s;
}
.step-connector.is-active {
background-color: #5146e5;
}
.step-node {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 10px;
}
.step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #64748b;
font-size: 13px;
font-weight: 650;
background-color: #fff;
border: 2px solid #cbd5e1;
border-radius: 50%;
transition: all 0.3s;
}
.step-title {
color: #64748b;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.step-item.is-active {
.step-icon {
color: #5146e5;
background-color: #eef2ff;
border-color: #5146e5;
}
.step-title {
color: #1e293b;
}
}
.step-item.is-completed {
.step-icon {
color: #fff;
background-color: #5146e5;
border-color: #5146e5;
}
.step-title {
color: #1e293b;
}
}
.wizard-content {
min-height: 400px;
}
.wizard-footer {
display: grid;
flex-shrink: 0;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: 64px;
padding: 0 32px;
background: #fff;
border-top: 1px solid #e2e8f0;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.02);
.footer-left {
justify-self: start;
}
.footer-center {
justify-self: center;
}
.footer-right {
justify-self: end;
}
}
.wizard-primary-action {
min-width: 160px;
}
@media (max-width: 1100px) {
.custom-wizard-steps {
padding: 0;
}
.step-connector {
margin: 0 8px;
}
.step-node {
gap: 7px;
}
.step-title {
display: none;
}
.step-item.is-active .step-title {
display: block;
font-size: 12px;
white-space: nowrap;
}
}
@media (max-width: 760px) {
.wizard-main {
padding: 24px 20px;
}
.custom-wizard-steps {
padding: 0;
}
.step-connector {
margin: 0 8px;
}
.step-node {
gap: 6px;
}
.step-title {
max-width: 72px;
line-height: 1.35;
}
.wizard-footer {
padding: 0 20px;
}
}
@media (max-width: 600px) {
.step-title {
display: none;
}
.step-connector {
margin: 0 6px;
}
.wizard-primary-action {
min-width: 0;
}
}

View File

@@ -0,0 +1,54 @@
import type { StructuredProcessOptions, UnstructuredProcessOptions } from './types'
export const DEFAULT_GENERATION_PROMPT = '你是一名专业的数据生成助手。请根据输入内容生成准确、完整、可直接用于模型训练的问答数据。仅输出符合目标格式的内容,答案应事实清晰、语言自然,不要添加分析过程、说明或无关内容。'
export function createDefaultStructuredOptions(): StructuredProcessOptions {
return {
preprocessOptions: ['clean_invalid', 'detect_structure', 'deduplicate', 'normalize_format'],
semanticEnrichment: false,
qaPairsPerRow: 1,
datasetSplit: { train: 80, validation: 10, test: 10 },
generationModelId: '',
generationPrompt: DEFAULT_GENERATION_PROMPT,
temperature: 0.7,
maxTokens: 1024,
jsonMode: false,
qualityFilterEnabled: false,
filterLowQuality: true,
filterShortContent: true,
minOutputLength: 20,
}
}
export function createDefaultUnstructuredOptions(): UnstructuredProcessOptions {
return {
preprocessOptions: [
'clean_invalid_content',
'detect_document_structure',
'merge_short_content',
'filter_low_quality',
'deduplicate_content',
'preserve_context',
],
chunkMethod: 'semantic',
chunkSize: 800,
chunkOverlap: 100,
minChunkSize: 100,
customDelimiter: '',
preserveTables: true,
preserveCodeBlocks: true,
preserveLists: true,
semanticEnrichment: false,
qaPairsPerChunk: 1,
datasetSplit: { train: 80, validation: 10, test: 10 },
generationModelId: '',
generationPrompt: DEFAULT_GENERATION_PROMPT,
temperature: 0.7,
maxTokens: 1024,
jsonMode: false,
qualityFilterEnabled: false,
filterLowQuality: true,
filterShortContent: true,
minOutputLength: 20,
}
}

View File

@@ -1,4 +1,11 @@
import type { PreviewItem, ProcessType, ResultItem, SourceLine } from './types' import type {
PreviewItem,
ProcessType,
ResultItem,
SourceLine,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
export const DEFAULT_SOURCE_TEXT = [ export const DEFAULT_SOURCE_TEXT = [
'问:如何看待当前的通货膨胀风险?', '问:如何看待当前的通货膨胀风险?',
@@ -39,8 +46,417 @@ export function sourceLines(sourceText: string): SourceLine[] {
}) })
} }
export function buildPreviewItems(sourceText: string, processType: ProcessType, sourceFileId = 'default-source'): PreviewItem[] { interface SourceRange {
const meaningfulLines = sourceLines(sourceText).filter((line) => line.content.trim()) start: number
end: number
}
interface ProtectedRange extends SourceRange {
kind: 'code' | 'table' | 'list'
}
const DEFAULT_CHUNK_SIZE = 800
const DEFAULT_CHUNK_OVERLAP = 100
const DEFAULT_MIN_CHUNK_SIZE = 100
function finiteInteger(value: number | undefined, fallback: number, min: number): number {
return Number.isFinite(value) ? Math.max(min, Math.round(value as number)) : fallback
}
function trimSourceRange(sourceText: string, start: number, end: number): SourceRange {
let nextStart = Math.max(0, start)
let nextEnd = Math.min(sourceText.length, end)
while (nextStart < nextEnd && /\s/.test(sourceText[nextStart])) nextStart += 1
while (nextEnd > nextStart && /\s/.test(sourceText[nextEnd - 1])) nextEnd -= 1
return { start: nextStart, end: nextEnd }
}
function normalizeDelimiter(delimiter: string | undefined): string {
return (delimiter ?? '').replace(/\\n/g, '\n').replace(/\\t/g, '\t')
}
function overlapsRange(line: SourceLine, range: SourceRange): boolean {
return line.start < range.end && line.end > range.start
}
function isLineProtected(line: SourceLine, ranges: SourceRange[]): boolean {
return ranges.some((range) => overlapsRange(line, range))
}
function detectCodeBlockRanges(sourceText: string, lines: SourceLine[]): ProtectedRange[] {
const ranges: ProtectedRange[] = []
let openFence: { start: number; marker: string; length: number } | null = null
for (const line of lines) {
const fence = line.content.match(/^\s*(`{3,}|~{3,})/)
if (!fence) continue
const marker = fence[1][0]
if (!openFence) {
openFence = { start: line.start, marker, length: fence[1].length }
continue
}
if (marker === openFence.marker && fence[1].length >= openFence.length) {
ranges.push({ start: openFence.start, end: line.end, kind: 'code' })
openFence = null
}
}
if (openFence) ranges.push({ start: openFence.start, end: sourceText.length, kind: 'code' })
return ranges
}
function isTableSeparator(content: string): boolean {
const normalized = content.trim().replace(/^\|/, '').replace(/\|$/, '')
const cells = normalized.split('|').map((cell) => cell.trim())
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
}
function detectTableRanges(lines: SourceLine[], codeRanges: SourceRange[]): ProtectedRange[] {
const ranges: ProtectedRange[] = []
for (let index = 0; index < lines.length - 1; index += 1) {
const header = lines[index]
const separator = lines[index + 1]
if (
isLineProtected(header, codeRanges)
|| isLineProtected(separator, codeRanges)
|| !header.content.includes('|')
|| !isTableSeparator(separator.content)
) {
continue
}
let endIndex = index + 1
while (
endIndex + 1 < lines.length
&& !isLineProtected(lines[endIndex + 1], codeRanges)
&& lines[endIndex + 1].content.trim()
&& lines[endIndex + 1].content.includes('|')
) {
endIndex += 1
}
ranges.push({ start: header.start, end: lines[endIndex].end, kind: 'table' })
index = endIndex
}
return ranges
}
function isListItem(content: string): boolean {
return /^\s*(?:[-+*]|\d+[.)])\s+\S/.test(content)
}
function isListContinuation(content: string): boolean {
return /^\s{2,}\S/.test(content)
}
function detectListRanges(
lines: SourceLine[],
excludedRanges: SourceRange[],
): ProtectedRange[] {
const ranges: ProtectedRange[] = []
for (let index = 0; index < lines.length; index += 1) {
if (isLineProtected(lines[index], excludedRanges) || !isListItem(lines[index].content)) continue
let endIndex = index
let itemCount = 1
while (endIndex + 1 < lines.length && !isLineProtected(lines[endIndex + 1], excludedRanges)) {
const nextContent = lines[endIndex + 1].content
if (isListItem(nextContent)) {
itemCount += 1
endIndex += 1
continue
}
if (isListContinuation(nextContent)) {
endIndex += 1
continue
}
break
}
if (itemCount >= 2) {
ranges.push({ start: lines[index].start, end: lines[endIndex].end, kind: 'list' })
index = endIndex
}
}
return ranges
}
function mergeProtectedRanges(ranges: ProtectedRange[]): ProtectedRange[] {
return ranges
.sort((left, right) => left.start - right.start || left.end - right.end)
.reduce<ProtectedRange[]>((merged, range) => {
const previous = merged[merged.length - 1]
if (previous && range.start < previous.end) {
previous.end = Math.max(previous.end, range.end)
return merged
}
merged.push({ ...range })
return merged
}, [])
}
function protectedRangesForOptions(
sourceText: string,
options?: UnstructuredProcessOptions,
): ProtectedRange[] {
if (!options?.preserveCodeBlocks && !options?.preserveTables && !options?.preserveLists) return []
const lines = sourceLines(sourceText)
const codeRanges = detectCodeBlockRanges(sourceText, lines)
const tableRanges = detectTableRanges(lines, codeRanges)
const listRanges = detectListRanges(lines, [...codeRanges, ...tableRanges])
const enabledRanges = [
...(options?.preserveCodeBlocks ? codeRanges : []),
...(options?.preserveTables ? tableRanges : []),
...(options?.preserveLists ? listRanges : []),
]
return mergeProtectedRanges(enabledRanges)
}
function protectedRangeContaining(
ranges: ProtectedRange[],
offset: number,
): ProtectedRange | undefined {
return ranges.find((range) => range.start < offset && offset < range.end)
}
function normalizeChunkStart(
sourceText: string,
cursor: number,
protectedRanges: ProtectedRange[],
): number {
let start = Math.max(0, Math.min(cursor, sourceText.length))
const overlapBlock = protectedRangeContaining(protectedRanges, start)
if (overlapBlock) start = overlapBlock.end
while (start < sourceText.length && /\s/.test(sourceText[start])) start += 1
// 去除块前空白时可能进入缩进代码块/列表;此时恢复到完整块起点。
const blockAfterTrim = protectedRangeContaining(protectedRanges, start)
if (blockAfterTrim) return cursor <= blockAfterTrim.start ? blockAfterTrim.start : blockAfterTrim.end
return start
}
function protectChunkEnd(
proposedEnd: number,
start: number,
minimumEnd: number,
protectedRanges: ProtectedRange[],
): number {
const splitBlock = protectedRangeContaining(protectedRanges, proposedEnd)
if (!splitBlock) return proposedEnd
// 优先在块前结束;块前不足最小切片长度时,将整个块收入当前切片。
return splitBlock.start > start && splitBlock.start >= minimumEnd
? splitBlock.start
: splitBlock.end
}
function restoreProtectedEdges(
range: SourceRange,
rawStart: number,
rawEnd: number,
protectedRanges: ProtectedRange[],
): SourceRange {
const nextRange = { ...range }
const startBlock = protectedRangeContaining(protectedRanges, nextRange.start)
if (startBlock && rawStart <= startBlock.start) nextRange.start = startBlock.start
const endBlock = protectedRangeContaining(protectedRanges, nextRange.end)
if (endBlock && rawEnd >= endBlock.end) nextRange.end = endBlock.end
return nextRange
}
function lastBoundaryInRange(
sourceText: string,
idealEnd: number,
minimumEnd: number,
): number | null {
const candidates: number[] = []
const boundaryTokens = ['\n\n', '\n', '。', '', '', '', '.', '!', '?', ';']
boundaryTokens.forEach((token) => {
const tokenStart = sourceText.lastIndexOf(token, idealEnd - token.length)
const boundary = tokenStart === -1 ? -1 : tokenStart + token.length
if (boundary >= minimumEnd && boundary <= idealEnd) candidates.push(boundary)
})
return candidates.length ? Math.max(...candidates) : null
}
function lastHeadingBoundary(
sourceText: string,
start: number,
idealEnd: number,
minimumEnd: number,
): number | null {
const section = sourceText.slice(start, idealEnd)
const headingPattern = /^(?:#{1,6}\s+|第[一二三四五六七八九十百]+[章节篇部分]|\d+(?:\.\d+)*[、.\s])/gm
let boundary: number | null = null
let match: RegExpExecArray | null
while ((match = headingPattern.exec(section))) {
const absoluteStart = start + match.index
if (absoluteStart >= minimumEnd) boundary = absoluteStart
}
return boundary
}
function resolveChunkEnd(
sourceText: string,
start: number,
idealEnd: number,
minimumEnd: number,
options: UnstructuredProcessOptions | undefined,
): number {
const method = options?.chunkMethod ?? 'semantic'
if (method === 'fixed') return idealEnd
if (method === 'custom') {
const delimiter = normalizeDelimiter(options?.customDelimiter)
if (!delimiter) return idealEnd
const delimiterStart = sourceText.lastIndexOf(delimiter, idealEnd - delimiter.length)
const boundary = delimiterStart === -1 ? -1 : delimiterStart + delimiter.length
return boundary >= minimumEnd ? boundary : idealEnd
}
if (method === 'heading') {
const headingBoundary = lastHeadingBoundary(sourceText, start, idealEnd, minimumEnd)
if (headingBoundary !== null) return headingBoundary
}
return lastBoundaryInRange(sourceText, idealEnd, minimumEnd) ?? idealEnd
}
function buildUnstructuredRanges(
sourceText: string,
options?: UnstructuredProcessOptions,
): SourceRange[] {
// 预览统一沿用“约 2 个字符 = 1 token”的轻量估算避免引入分词器依赖。
const targetCharacters = finiteInteger(options?.chunkSize, DEFAULT_CHUNK_SIZE, 1) * 2
const minimumCharacters = Math.min(
targetCharacters,
finiteInteger(options?.minChunkSize, DEFAULT_MIN_CHUNK_SIZE, 1) * 2,
)
const requestedOverlap = finiteInteger(options?.chunkOverlap, DEFAULT_CHUNK_OVERLAP, 0) * 2
const protectedRanges = protectedRangesForOptions(sourceText, options)
const ranges: SourceRange[] = []
let cursor = 0
while (cursor < sourceText.length) {
const start = normalizeChunkStart(sourceText, cursor, protectedRanges)
if (start >= sourceText.length) break
const idealEnd = Math.min(sourceText.length, start + targetCharacters)
const minimumEnd = Math.min(idealEnd, start + minimumCharacters)
let end = idealEnd === sourceText.length
? idealEnd
: resolveChunkEnd(sourceText, start, idealEnd, minimumEnd, options)
end = protectChunkEnd(end, start, minimumEnd, protectedRanges)
// 所有自定义边界都必须向前推进;异常配置回退到固定长度切分。
if (end <= start) end = Math.min(sourceText.length, start + targetCharacters)
let range = trimSourceRange(sourceText, start, end)
range = restoreProtectedEdges(range, start, end, protectedRanges)
if (end < sourceText.length && range.end - range.start < minimumCharacters) {
range.end = Math.min(end, range.start + minimumCharacters)
}
if (range.end <= range.start) {
cursor = Math.max(cursor + 1, end)
continue
}
const isLastRange = end >= sourceText.length
if (isLastRange && range.end - range.start < minimumCharacters && ranges.length) {
ranges[ranges.length - 1].end = range.end
break
}
ranges.push(range)
if (isLastRange) break
// overlap 是允许的最大重叠量;按当前切片动态收缩,保证每轮至少推进最小切片长度。
const maximumOverlap = Math.max(0, range.end - range.start - minimumCharacters)
const actualOverlap = Math.min(requestedOverlap, maximumOverlap)
const nextCursor = range.end - actualOverlap
cursor = nextCursor > start ? nextCursor : range.end
}
return ranges
}
function lineNumberAtOffset(lines: SourceLine[], offset: number): number | null {
if (!lines.length) return null
let low = 0
let high = lines.length - 1
let result = 0
while (low <= high) {
const middle = Math.floor((low + high) / 2)
if (lines[middle].start <= offset) {
result = middle
low = middle + 1
} else {
high = middle - 1
}
}
return lines[result].number
}
function previewItemFromRange(
sourceText: string,
lines: SourceLine[],
range: SourceRange,
sourceFileId: string,
index: number,
): PreviewItem {
const content = sourceText.slice(range.start, range.end)
return {
id: `preview-${sourceFileId}-${index + 1}`,
sourceFileId,
originalContent: content,
editedContent: content,
sourceStart: range.start,
sourceEnd: range.end,
sourceStartLine: lineNumberAtOffset(lines, range.start),
sourceEndLine: lineNumberAtOffset(lines, Math.max(range.start, range.end - 1)),
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
status: 'original',
}
}
export function buildPreviewItems(
sourceText: string,
processType: ProcessType,
sourceFileId = 'default-source',
unstructuredOptions?: UnstructuredProcessOptions,
): PreviewItem[] {
const lines = sourceLines(sourceText)
if (processType === 'unstructured') {
return buildUnstructuredRanges(sourceText, unstructuredOptions).map((range, index) => (
previewItemFromRange(sourceText, lines, range, sourceFileId, index)
))
}
const meaningfulLines = lines.filter((line) => line.content.trim())
const groupSize = processType === 'structured' ? 1 : 3 const groupSize = processType === 'structured' ? 1 : 3
const items: PreviewItem[] = [] const items: PreviewItem[] = []
@@ -69,21 +485,56 @@ export function buildPreviewItems(sourceText: string, processType: ProcessType,
return items return items
} }
export function createResults(items: PreviewItem[]): ResultItem[] { const SEMANTIC_PREFIXES = [
return items.slice(0, 12).map((item, index) => { '请结合实际情况,说明一下:',
'如果方便的话,请详细解答:',
'请用通俗易懂的方式说明:',
'请从实际应用角度说明:',
'请简洁、自然地说明:',
]
export function createResults(
items: PreviewItem[],
options?: StructuredProcessOptions | UnstructuredProcessOptions,
): ResultItem[] {
const resultCount = options && 'qaPairsPerChunk' in options
? Math.min(3, finiteInteger(options.qaPairsPerChunk, 1, 1))
: Math.min(5, finiteInteger(options?.qaPairsPerRow, 1, 1))
const generatedResults = items.flatMap((item, index) => {
if (options?.qualityFilterEnabled && options.filterLowQuality) {
if (item.status === 'invalid' || !item.editedContent.trim()) return []
}
const [firstLine = '', ...rest] = item.editedContent.split('\n') const [firstLine = '', ...rest] = item.editedContent.split('\n')
const output = rest.join('\n').trim() || item.editedContent.trim() const output = rest.join('\n').trim() || item.editedContent.trim()
const instruction = firstLine.replace(/^问[:]\s*/, '').trim() || `数据条目 ${index + 1}` const baseInstruction = firstLine.replace(/^问[:]\s*/, '').trim() || `数据条目 ${index + 1}`
return { return Array.from({ length: resultCount }, (_, variantIndex) => {
id: `result-${index + 1}`, const instruction = options?.semanticEnrichment
instruction, ? `${SEMANTIC_PREFIXES[variantIndex]}${baseInstruction}`
input: '', : variantIndex === 0
output, ? baseInstruction
originalInstruction: instruction, : `${baseInstruction}(问法 ${variantIndex + 1}`
originalInput: '',
originalOutput: output, return {
status: 'valid', id: resultCount === 1 ? `result-${index + 1}` : `result-${index + 1}-${variantIndex + 1}`,
} instruction,
input: '',
output,
originalInstruction: instruction,
originalInput: '',
originalOutput: output,
status: 'valid' as const,
}
})
})
if (!options?.qualityFilterEnabled) return generatedResults
return generatedResults.filter((result) => {
if (options.filterLowQuality && (!result.instruction.trim() || !result.output.trim())) return false
if (options.filterShortContent && result.output.trim().length < options.minOutputLength) return false
return true
}) })
} }

View File

@@ -1,6 +1,83 @@
export type ProcessType = 'structured' | 'unstructured' export type ProcessType = 'structured' | 'unstructured' | 'external'
export type StepId = 'create' | 'preview' | 'generate' | 'results' export type StepId = 'create' | 'model' | 'upload' | 'preview' | 'generate' | 'results'
export type PreprocessOption =
| 'clean_invalid'
| 'detect_structure'
| 'deduplicate'
| 'normalize_format'
| 'filter_anomaly'
| 'desensitize'
export interface DatasetSplitOptions {
train: number
validation: number
test: number
}
export interface GenerationControlOptions {
generationModelId: string | number | ''
generationPrompt: string
temperature: number
maxTokens: number
jsonMode: boolean
qualityFilterEnabled: boolean
filterLowQuality: boolean
filterShortContent: boolean
minOutputLength: number
}
export interface StructuredProcessOptions extends GenerationControlOptions {
preprocessOptions: PreprocessOption[]
semanticEnrichment: boolean
qaPairsPerRow: number
datasetSplit: DatasetSplitOptions
}
export type UnstructuredPreprocessOption =
| 'clean_invalid_content'
| 'detect_document_structure'
| 'merge_short_content'
| 'filter_low_quality'
| 'deduplicate_content'
| 'desensitize'
| 'preserve_context'
export type ChunkMethod = 'semantic' | 'heading' | 'fixed' | 'custom'
export interface UnstructuredProcessOptions extends GenerationControlOptions {
preprocessOptions: UnstructuredPreprocessOption[]
chunkMethod: ChunkMethod
chunkSize: number
chunkOverlap: number
minChunkSize: number
customDelimiter: string
preserveTables: boolean
preserveCodeBlocks: boolean
preserveLists: boolean
semanticEnrichment: boolean
qaPairsPerChunk: number
datasetSplit: DatasetSplitOptions
}
export interface ExternalDataSource {
type: string
url: string
authMode: string
username?: string
password?: string
token?: string
limit: number
}
export interface UploadedDataFile {
uid: string | number
name: string
size: number
count: number
content: string
}
export interface SourceLine { export interface SourceLine {
number: number number: number

View File

@@ -0,0 +1,147 @@
import { nextTick, type Reactive, type Ref } from 'vue'
import { ElMessage } from 'element-plus'
import type {
ExternalDataSource,
ProcessType,
StepId,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
export const DATA_PROCESS_DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
export const DATA_PROCESS_DRAFT_SCHEMA_VERSION = 6
interface DraftSnapshot {
schemaVersion?: number
currentStepId?: StepId
task?: { name?: string; description?: string }
processType?: ProcessType
structuredOptions?: Partial<StructuredProcessOptions>
unstructuredOptions?: Partial<UnstructuredProcessOptions>
externalSource?: Partial<ExternalDataSource>
}
interface DraftBindings {
currentStepId: Readonly<Ref<StepId>>
task: Reactive<{ name: string; description: string }>
processType: Ref<ProcessType>
structuredOptions: Ref<StructuredProcessOptions>
unstructuredOptions: Ref<UnstructuredProcessOptions>
externalSource: Reactive<ExternalDataSource>
restoringDraft: Ref<boolean>
dirty: Ref<boolean>
goToStep: (stepId: StepId) => void
}
function sanitizeExternalSource(source: Partial<ExternalDataSource>) {
return {
type: typeof source.type === 'string' ? source.type : 'mysql',
url: typeof source.url === 'string' ? source.url : '',
authMode: typeof source.authMode === 'string' ? source.authMode : 'none',
username: typeof source.username === 'string' ? source.username : '',
limit: Number.isFinite(source.limit) ? Number(source.limit) : 1000,
}
}
function isDraftSnapshot(value: unknown): value is DraftSnapshot {
return Boolean(value && typeof value === 'object')
}
/**
* 只持久化可重建的配置。密码、令牌、文件正文、预览与结果都不进入 localStorage。
*/
export function useDataProcessDraft(bindings: DraftBindings) {
function draftSnapshot(): DraftSnapshot {
return {
schemaVersion: DATA_PROCESS_DRAFT_SCHEMA_VERSION,
currentStepId: bindings.currentStepId.value,
task: { ...bindings.task },
processType: bindings.processType.value,
structuredOptions: {
...bindings.structuredOptions.value,
preprocessOptions: [...bindings.structuredOptions.value.preprocessOptions],
datasetSplit: { ...bindings.structuredOptions.value.datasetSplit },
},
unstructuredOptions: {
...bindings.unstructuredOptions.value,
preprocessOptions: [...bindings.unstructuredOptions.value.preprocessOptions],
datasetSplit: { ...bindings.unstructuredOptions.value.datasetSplit },
},
externalSource: sanitizeExternalSource(bindings.externalSource),
}
}
function writeDraft(showWarning: boolean) {
try {
localStorage.setItem(DATA_PROCESS_DRAFT_STORAGE_KEY, JSON.stringify(draftSnapshot()))
} catch {
if (showWarning) ElMessage.warning('草稿保存失败,请检查浏览器存储空间')
}
}
function persistDraft() {
if (!bindings.restoringDraft.value) writeDraft(true)
}
function restoreDraft() {
try {
const raw = localStorage.getItem(DATA_PROCESS_DRAFT_STORAGE_KEY)
if (!raw) return
const snapshot: unknown = JSON.parse(raw)
if (!isDraftSnapshot(snapshot)) return
bindings.restoringDraft.value = true
bindings.goToStep('create')
bindings.task.name = snapshot.task?.name || ''
bindings.task.description = snapshot.task?.description || ''
bindings.processType.value = snapshot.processType === 'unstructured' || snapshot.processType === 'external'
? snapshot.processType
: 'structured'
if (snapshot.structuredOptions) {
bindings.structuredOptions.value = {
...bindings.structuredOptions.value,
...snapshot.structuredOptions,
preprocessOptions: Array.isArray(snapshot.structuredOptions.preprocessOptions)
? snapshot.structuredOptions.preprocessOptions
: bindings.structuredOptions.value.preprocessOptions,
datasetSplit: {
...bindings.structuredOptions.value.datasetSplit,
...snapshot.structuredOptions.datasetSplit,
},
}
}
if (snapshot.unstructuredOptions) {
bindings.unstructuredOptions.value = {
...bindings.unstructuredOptions.value,
...snapshot.unstructuredOptions,
preprocessOptions: Array.isArray(snapshot.unstructuredOptions.preprocessOptions)
? snapshot.unstructuredOptions.preprocessOptions
: bindings.unstructuredOptions.value.preprocessOptions,
datasetSplit: {
...bindings.unstructuredOptions.value.datasetSplit,
...snapshot.unstructuredOptions.datasetSplit,
},
}
}
Object.assign(bindings.externalSource, sanitizeExternalSource(snapshot.externalSource || {}), {
password: '',
token: '',
})
bindings.dirty.value = false
nextTick(() => {
bindings.restoringDraft.value = false
// 立即覆盖 v5 及更早草稿,清除其中可能存在的敏感值和大段正文。
writeDraft(false)
})
ElMessage.info('已恢复上次的任务配置,请重新上传或拉取源数据')
} catch {
localStorage.removeItem(DATA_PROCESS_DRAFT_STORAGE_KEY)
}
}
return { draftSnapshot, persistDraft, restoreDraft }
}

View File

@@ -0,0 +1,127 @@
import { reactive, ref, type Ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createResults } from './previewModel'
import type {
GenerationState,
PreviewItem,
ProcessType,
ResultItem,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
interface GenerationBindings {
previewItems: Ref<PreviewItem[]>
processType: Ref<ProcessType>
structuredOptions: Ref<StructuredProcessOptions>
unstructuredOptions: Ref<UnstructuredProcessOptions>
dirty: Ref<boolean>
}
export function useDataProcessGeneration(bindings: GenerationBindings) {
const results = ref<ResultItem[]>([])
const selectedResultId = ref<string | null>(null)
const generation = reactive<GenerationState>({
status: 'idle',
progress: 0,
message: '确认摘要后即可开始生成,过程中可查看实时进度。',
})
let generationTimer: ReturnType<typeof setInterval> | null = null
function stopGenerationTimer() {
if (generationTimer) clearInterval(generationTimer)
generationTimer = null
}
function resetDownstream() {
stopGenerationTimer()
generation.status = 'idle'
generation.progress = 0
generation.message = '确认摘要后即可开始生成,过程中可查看实时进度。'
results.value = []
selectedResultId.value = null
}
function startGeneration() {
stopGenerationTimer()
generation.status = 'running'
generation.progress = 0
generation.message = '正在应用预览修改并生成标准化结果,请稍候。'
generationTimer = setInterval(() => {
generation.progress = Math.min(100, generation.progress + 8)
if (generation.progress < 100) return
stopGenerationTimer()
generation.status = 'success'
results.value = createResults(
bindings.previewItems.value,
bindings.processType.value === 'structured'
? bindings.structuredOptions.value
: bindings.processType.value === 'unstructured'
? bindings.unstructuredOptions.value
: undefined,
)
generation.message = `已完成 ${results.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
selectedResultId.value = results.value[0]?.id ?? null
bindings.dirty.value = true
ElMessage.success('数据处理完成')
}, 180)
}
function stopGeneration() {
stopGenerationTimer()
generation.status = 'failed'
generation.message = '任务已停止,预览修改仍然保留,可以重新生成。'
}
function updateResultField(id: string, field: 'instruction' | 'input' | 'output', value: string) {
const item = results.value.find((entry) => entry.id === id)
if (!item) return
item[field] = value
const valid = item.instruction.trim() && item.output.trim()
item.error = valid ? undefined : 'Instruction 和 Output 不能为空'
const changed = item.instruction !== item.originalInstruction
|| item.input !== item.originalInput
|| item.output !== item.originalOutput
item.status = item.error ? 'invalid' : changed ? 'modified' : 'valid'
bindings.dirty.value = true
}
function restoreResult(id: string) {
const item = results.value.find((entry) => entry.id === id)
if (!item) return
item.instruction = item.originalInstruction
item.input = item.originalInput
item.output = item.originalOutput
item.error = undefined
item.status = 'valid'
bindings.dirty.value = true
}
function validateResults() {
let firstInvalidId: string | null = null
for (const item of results.value) {
if (!item.instruction.trim() || !item.output.trim()) {
item.error = 'Instruction 和 Output 不能为空'
item.status = 'invalid'
firstInvalidId ??= item.id
}
}
if (firstInvalidId) selectedResultId.value = firstInvalidId
return firstInvalidId == null
}
return {
generation,
results,
selectedResultId,
resetDownstream,
restoreResult,
startGeneration,
stopGeneration,
stopGenerationTimer,
updateResultField,
validateResults,
}
}

View File

@@ -223,7 +223,7 @@ onMounted(loadEditData)
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="存储位置"> <el-form-item label="上传方式">
<el-radio-group v-model="form.storage"> <el-radio-group v-model="form.storage">
<el-radio value="local">本地</el-radio> <el-radio value="local">本地</el-radio>
<el-radio value="cloud">云平台</el-radio> <el-radio value="cloud">云平台</el-radio>

View File

@@ -5,21 +5,20 @@ import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue' import DataTablePage from '@/components/DataTablePage.vue'
import { getDatasetList, deleteDataset, downloadDatasetUrl } from '@/api/modules/dataset' import { getDatasetList, deleteDataset, downloadDatasetUrl } from '@/api/modules/dataset'
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants' import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
import type { DatasetItem } from '@/types' import type { DatasetItem, DatasetSource } from '@/types'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const dataList = ref<DatasetItem[]>([]) const dataList = ref<DatasetItem[]>([])
const activeTab = ref('upload') const activeTab = ref<DatasetSource>('upload')
const filteredDataList = computed(() => { const filteredDataList = computed(() => {
if (activeTab.value === 'upload') { if (activeTab.value === 'task') {
return dataList.value return dataList.value.filter((item) => item.source === 'task')
} else {
// 假设数据任务产生的数据集可以通过某个字段区分,目前 mock 数据没有该字段,所以暂为空
return []
} }
return dataList.value.filter((item) => item.source !== 'task')
}) })
async function loadData() { async function loadData() {
@@ -69,7 +68,7 @@ onMounted(loadData)
:class="{ active: activeTab === 'upload' }" :class="{ active: activeTab === 'upload' }"
@click="activeTab = 'upload'" @click="activeTab = 'upload'"
> >
本地上传 上传任务
</button> </button>
<button <button
class="capsule-tab-item" class="capsule-tab-item"
@@ -82,6 +81,7 @@ onMounted(loadData)
</template> </template>
<template #columns> <template #columns>
<el-table-column v-if="activeTab === 'task'" label="任务ID" prop="task_id" align="center" width="100" />
<el-table-column label="数据集名称" prop="name" align="center" /> <el-table-column label="数据集名称" prop="name" align="center" />
<el-table-column label="数据类型" align="center" width="110"> <el-table-column label="数据类型" align="center" width="110">
<template #default="{ row }"> <template #default="{ row }">
@@ -118,7 +118,7 @@ onMounted(loadData)
<template #actions="{ row }"> <template #actions="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-button type="primary" link size="small" @click="handlePreview(row)"> <el-button type="primary" link size="small" @click="handlePreview(row)">
<i class="fa fa-eye" style="margin-right: 4px" />预览 <i class="fa fa-eye" style="margin-right: 4px" />详情
</el-button> </el-button>
<el-button type="success" link size="small" @click="handleDownload(row)"> <el-button type="success" link size="small" @click="handleDownload(row)">
<i class="fa fa-download" style="margin-right: 4px" />下载 <i class="fa fa-download" style="margin-right: 4px" />下载

View File

@@ -1,157 +1,824 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AppConfirmDialog from '@/components/AppConfirmDialog.vue'
import PageCard from '@/components/PageCard.vue' import PageCard from '@/components/PageCard.vue'
import { import {
getDataset, activateDatasetFileVersion,
previewDatasetFile, createDatasetFileVersion,
deleteDataset, deleteDatasetFileVersion,
downloadDatasetUrl,
downloadFileUrl, downloadFileUrl,
getDataset,
getDatasetFileVersionContent,
getDatasetFileVersions,
} from '@/api/modules/dataset' } from '@/api/modules/dataset'
import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants' import { DATASET_TYPE_MAP, STORAGE_MAP } from '@/constants'
import type { DatasetItem, DatasetFile } from '@/types' import { parseDatasetRecords, updateDatasetRecord } from './datasetRecords'
import type { DatasetRecord } from './datasetRecords'
import DatasetVersionBar from './preview/DatasetVersionBar.vue'
import DatasetRecordTable from './preview/DatasetRecordTable.vue'
import DatasetRecordEditorDialog from './preview/DatasetRecordEditorDialog.vue'
import DatasetRawPreview from './preview/DatasetRawPreview.vue'
import type { EditField } from './preview/types'
import type { DatasetFile, DatasetItem, DatasetVersion } from '@/types'
const route = useRoute() const route = useRoute()
const router = useRouter()
const datasetId = route.params.id as string const datasetId = route.params.id as string
const confirmDialogRef = ref<InstanceType<typeof AppConfirmDialog>>()
const loading = ref(false) const loading = ref(false)
const previewLoading = ref(false)
const dataset = ref<DatasetItem | null>(null) const dataset = ref<DatasetItem | null>(null)
const selectedFileId = ref<string>('') const selectedFileId = ref('')
const previewContent = ref('') const previewContent = ref('')
const baseVersionContent = ref('')
const previewError = ref('')
const editorVisible = ref(false)
const editingRecord = ref<DatasetRecord | null>(null)
const editFields = ref<EditField[]>([])
const rawDraft = ref('')
const originalDraft = ref('')
const savingRecord = ref(false)
const savingVersion = ref(false)
const versions = ref<DatasetVersion[]>([])
const selectedVersionId = ref('')
const activeVersionId = ref('')
const loadedVersionId = ref('')
const versionLoading = ref(false)
const activatingVersion = ref(false)
const deletingVersion = ref(false)
const nextVersionNumber = ref(1)
let versionRequestId = 0
const files = computed<DatasetFile[]>(() => dataset.value?.files || []) const files = computed<DatasetFile[]>(() => dataset.value?.files || [])
const selectedFile = computed(() => files.value.find((file) => fileKey(file) === selectedFileId.value))
const totalLines = computed(() => previewContent.value ? previewContent.value.split('\n').length : 0)
const recordResult = computed(() => parseDatasetRecords(previewContent.value, selectedFile.value?.name || ''))
const isRecordFile = computed(() => recordResult.value.supported)
const records = computed(() => recordResult.value.records)
const viewedVersion = computed(() => versions.value.find((item) => item.id === loadedVersionId.value))
const isViewingActiveVersion = computed(() => Boolean(loadedVersionId.value) && loadedVersionId.value === activeVersionId.value)
const currentDraft = computed(() => editingRecord.value?.kind === 'object'
? JSON.stringify(editFields.value)
: rawDraft.value)
const hasEditorDraftChanges = computed(() => editorVisible.value && currentDraft.value !== originalDraft.value)
const hasPendingVersionChanges = computed(() => previewContent.value !== baseVersionContent.value)
const hasUnsavedChanges = computed(() => hasEditorDraftChanges.value || hasPendingVersionChanges.value)
const datasetType = computed(() => {
const value = String(dataset.value?.type || '').toLowerCase()
return DATASET_TYPE_MAP[value] || dataset.value?.type || '-'
})
const storageType = computed(() => {
const value = dataset.value?.storage_type || ''
return STORAGE_MAP[value] || value || '-'
})
const createdAt = computed(() => dataset.value?.create_time
? new Date(dataset.value.create_time).toLocaleString('zh-CN')
: '-')
const previewLines = computed(() => previewContent.value.split('\n').slice(0, 100)) function fileKey(file: DatasetFile) {
const totalLines = computed(() => previewContent.value.split('\n').length) return String(file.id || file.name)
}
async function loadDataset() { async function loadDataset() {
loading.value = true loading.value = true
try { try {
dataset.value = await getDataset(datasetId) dataset.value = await getDataset(datasetId)
if (files.value.length > 0) { const firstFile = files.value[0]
selectedFileId.value = String(files.value[0].id || files.value[0].name) if (firstFile) {
loadPreview() selectedFileId.value = fileKey(firstFile)
await loadVersions(firstFile)
} }
} catch {
// ignore
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadPreview() { async function loadVersions(file: DatasetFile) {
if (!selectedFileId.value) return previewLoading.value = true
previewError.value = ''
const requestId = ++versionRequestId
try { try {
const res = await previewDatasetFile(selectedFileId.value) const result = await getDatasetFileVersions(fileKey(file))
previewContent.value = res.content || '' const activeContent = await getDatasetFileVersionContent(fileKey(file), result.active_version_id)
if (requestId !== versionRequestId) return
versions.value = result.versions
nextVersionNumber.value = result.next_version_number
?? Math.max(0, ...result.versions.map((item) => item.version)) + 1
activeVersionId.value = result.active_version_id
selectedVersionId.value = result.active_version_id
loadedVersionId.value = result.active_version_id
previewContent.value = activeContent.content
baseVersionContent.value = activeContent.content
resetRecordEditor()
} catch { } catch {
previewContent.value = '加载失败' if (requestId === versionRequestId) previewError.value = '版本内容加载失败,请稍后重试'
} finally {
if (requestId === versionRequestId) previewLoading.value = false
} }
} }
function handleDownloadFile(file: any) { async function handleViewedVersionChange(nextVersionId: string) {
const fid = file.id || file.name if (!selectedFile.value || nextVersionId === loadedVersionId.value) return
window.open(downloadFileUrl(datasetId, fid), '_blank') const previousVersionId = loadedVersionId.value
if (!await confirmDiscardChanges('切换版本后,当前样本的未保存修改将丢失。')) {
selectedVersionId.value = previousVersionId
return
}
versionLoading.value = true
const requestId = ++versionRequestId
try {
const result = await getDatasetFileVersionContent(fileKey(selectedFile.value), nextVersionId)
if (requestId !== versionRequestId) return
previewContent.value = result.content
baseVersionContent.value = result.content
loadedVersionId.value = result.version.id
resetRecordEditor()
} catch {
if (requestId === versionRequestId) selectedVersionId.value = previousVersionId
} finally {
if (requestId === versionRequestId) versionLoading.value = false
}
} }
function handleDownloadAll() { async function activateViewedVersion() {
window.open(downloadDatasetUrl(datasetId), '_blank') if (!selectedFile.value || !viewedVersion.value || isViewingActiveVersion.value) return
try {
await ElMessageBox.confirm(
`确定将 V${viewedVersion.value.version} 设为当前版本吗?后续任务将使用该版本。`,
'切换当前版本',
{
type: 'warning',
confirmButtonText: '设为当前版本',
cancelButtonText: '取消',
},
)
} catch {
return
}
activatingVersion.value = true
try {
const result = await activateDatasetFileVersion(
fileKey(selectedFile.value),
viewedVersion.value.id,
activeVersionId.value,
)
activeVersionId.value = result.version.id
previewContent.value = result.content
baseVersionContent.value = result.content
ElMessage.success(`已将 V${result.version.version} 设为当前版本`)
} finally {
activatingVersion.value = false
}
} }
async function handleDelete() { async function deleteViewedVersion() {
await ElMessageBox.confirm('确定要删除这个数据集吗?', '确认删除', { type: 'warning' }) if (
await deleteDataset(datasetId) !selectedFile.value
ElMessage.success('删除成功') || !viewedVersion.value
router.push('/dataset') || isViewingActiveVersion.value
|| deletingVersion.value
) return
const targetVersion = viewedVersion.value
const confirmed = await confirmDialogRef.value?.open({
title: `删除 V${targetVersion.version} 版本?`,
message: `删除后无法恢复。当前版本 V${versions.value.find((item) => item.id === activeVersionId.value)?.version || '-'} 不受影响。`,
confirmText: '删除版本',
cancelText: '取消',
tone: 'danger',
})
if (!confirmed) return
deletingVersion.value = true
versionLoading.value = true
try {
const result = await deleteDatasetFileVersion(
fileKey(selectedFile.value),
targetVersion.id,
activeVersionId.value,
)
const activeContent = await getDatasetFileVersionContent(
fileKey(selectedFile.value),
result.active_version_id,
)
versions.value = result.versions
nextVersionNumber.value = result.next_version_number
?? Math.max(0, ...result.versions.map((item) => item.version)) + 1
activeVersionId.value = result.active_version_id
selectedVersionId.value = result.active_version_id
loadedVersionId.value = result.active_version_id
previewContent.value = activeContent.content
baseVersionContent.value = activeContent.content
resetRecordEditor()
ElMessage.success(`V${targetVersion.version} 版本已删除`)
} finally {
versionLoading.value = false
deletingVersion.value = false
}
} }
onMounted(loadDataset) function openRecordEditor(record: DatasetRecord) {
if (!isViewingActiveVersion.value) {
ElMessage.warning('历史版本为只读,请先设为当前版本')
return
}
editingRecord.value = record
if (record.kind === 'object') {
editFields.value = Object.entries(record.value as Record<string, unknown>).map(([key, value]) => ({
key,
value: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
isJson: typeof value !== 'string',
}))
rawDraft.value = ''
} else {
editFields.value = []
rawDraft.value = record.raw
}
editorVisible.value = true
originalDraft.value = record.kind === 'object' ? JSON.stringify(editFields.value) : rawDraft.value
}
function resetRecordEditor() {
editorVisible.value = false
editingRecord.value = null
editFields.value = []
rawDraft.value = ''
originalDraft.value = ''
}
function updateEditField(index: number, value: string) {
const field = editFields.value[index]
if (field) field.value = value
}
async function confirmDiscardChanges(message = '当前存在尚未保存为版本的修改,放弃后将无法恢复。') {
if (!hasUnsavedChanges.value) return true
try {
await ElMessageBox.confirm(message, '确认放弃修改?', {
type: 'warning',
confirmButtonText: '放弃修改',
cancelButtonText: '继续编辑',
})
return true
} catch {
return false
}
}
async function confirmEditorDiscardChanges() {
if (!hasEditorDraftChanges.value) return true
try {
await ElMessageBox.confirm('当前样本存在未暂存修改,放弃后将无法恢复。', '确认放弃修改?', {
type: 'warning',
confirmButtonText: '放弃修改',
cancelButtonText: '继续编辑',
})
return true
} catch {
return false
}
}
async function closeRecordEditor(done?: () => void) {
if (savingRecord.value) return
if (!await confirmEditorDiscardChanges()) return
resetRecordEditor()
done?.()
}
function buildNextRecordValue() {
if (editingRecord.value?.kind !== 'object') return JSON.parse(rawDraft.value)
const nextValue: Record<string, unknown> = {}
for (const field of editFields.value) {
nextValue[field.key] = field.isJson ? JSON.parse(field.value) : field.value
}
return nextValue
}
async function saveRecord() {
if (!selectedFile.value || !editingRecord.value || savingRecord.value) return
const record = editingRecord.value
let nextValue: unknown
try {
nextValue = buildNextRecordValue()
} catch (error) {
ElMessage.error(`JSON 格式校验失败:${error instanceof Error ? error.message : '内容格式不正确'}`)
return
}
savingRecord.value = true
try {
const nextContent = updateDatasetRecord(
previewContent.value,
selectedFile.value.name,
record.sourceIndex,
nextValue,
)
previewContent.value = nextContent
resetRecordEditor()
ElMessage.success(`${record.displayIndex} 条修改已暂存`)
} finally {
savingRecord.value = false
}
}
async function saveVersion() {
if (
!selectedFile.value
|| !hasPendingVersionChanges.value
|| !isViewingActiveVersion.value
|| savingVersion.value
) return
savingVersion.value = true
try {
const result = await createDatasetFileVersion(
fileKey(selectedFile.value),
previewContent.value,
{
description: '在线编辑数据样本',
baseVersionId: loadedVersionId.value,
expectedCurrentVersionId: activeVersionId.value,
},
)
previewContent.value = result.content
baseVersionContent.value = result.content
versions.value = [result.version, ...versions.value]
nextVersionNumber.value = result.version.version + 1
activeVersionId.value = result.version.id
selectedVersionId.value = result.version.id
loadedVersionId.value = result.version.id
ElMessage.success(`V${result.version.version} 创建成功,已设为当前版本`)
} finally {
savingVersion.value = false
}
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedChanges.value) return
event.preventDefault()
event.returnValue = ''
}
function handleDownloadFile(file: DatasetFile) {
window.open(downloadFileUrl(datasetId, fileKey(file), loadedVersionId.value), '_blank')
}
onBeforeRouteLeave(() => confirmDiscardChanges())
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
loadDataset()
})
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
</script> </script>
<template> <template>
<PageCard title="数据集预览" v-loading="loading"> <PageCard class="dataset-preview-page" v-loading="loading">
<template #extra> <template #header>
<el-button @click="handleDownloadAll"><i class="fa fa-download" /> 打包下载</el-button> <div class="page-header">
<el-button type="danger" @click="handleDelete"><i class="fa fa-trash" /> 删除</el-button> <div class="page-heading">
<el-button @click="router.back()">返回</el-button> <div class="heading-icon" aria-hidden="true"><i class="fa fa-database" /></div>
<div>
<div class="heading-row">
<h1>{{ dataset?.name || '数据集详情' }}</h1>
<el-tag size="small" type="primary">{{ datasetType }}</el-tag>
<el-tag size="small" type="success">{{ storageType }}</el-tag>
</div>
<p>{{ dataset?.description || '逐条查看和修改数据样本' }}</p>
</div>
</div>
</div>
</template> </template>
<!-- 数据集信息 --> <section class="dataset-overview" aria-label="数据集概览">
<el-descriptions :column="3" border style="margin-bottom: 20px"> <div class="overview-item"><span>数据条数</span><strong>{{ (dataset?.count || 0).toLocaleString() }}</strong></div>
<el-descriptions-item label="名称">{{ dataset?.name }}</el-descriptions-item> <div class="overview-item"><span>数据大小</span><strong>{{ dataset?.size || '-' }}</strong></div>
<el-descriptions-item label="类型"> <div class="overview-item overview-item-wide"><span>创建时间</span><strong>{{ createdAt }}</strong></div>
{{ DATASET_TYPE_MAP[String(dataset?.type).toLowerCase()] || dataset?.type }} </section>
</el-descriptions-item>
<el-descriptions-item label="存储位置">
{{ STORAGE_MAP[dataset?.storage_type || ''] || dataset?.storage_type }}
</el-descriptions-item>
<el-descriptions-item label="数据条数">{{ dataset?.count || 0 }}</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ dataset?.create_time ? new Date(dataset.create_time).toLocaleString('zh-CN') : '-' }}
</el-descriptions-item>
<el-descriptions-item label="描述">{{ dataset?.description || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 文件列表 --> <DatasetVersionBar
<div class="file-section"> v-model="selectedVersionId"
<h3 class="section-title">文件列表</h3> :versions="versions"
<el-table :data="files" style="width: 100%"> :viewed-version="viewedVersion"
<el-table-column label="文件名" prop="name" /> :active-version-id="activeVersionId"
<el-table-column label="大小" prop="size" width="120" /> :is-viewing-active-version="isViewingActiveVersion"
<el-table-column label="操作" width="200" align="center"> :has-pending-version-changes="hasPendingVersionChanges"
<template #default="{ row }"> :version-loading="versionLoading"
<el-button link type="primary" @click="selectedFileId = String(row.id || row.name); loadPreview()">预览</el-button> :saving-record="savingRecord"
<el-button link type="success" @click="handleDownloadFile(row)">下载</el-button> :activating-version="activatingVersion"
</template> :deleting-version="deletingVersion"
</el-table-column> @change="handleViewedVersionChange"
</el-table> @activate="activateViewedVersion"
</div> @delete="deleteViewedVersion"
/>
<!-- 预览内容 --> <el-alert
<div class="preview-section"> v-if="viewedVersion && !isViewingActiveVersion"
<h3 class="section-title">内容预览</h3> class="historical-version-alert"
<pre class="preview-pre">{{ previewLines.join('\n') }}</pre> type="info"
<div v-if="totalLines > 100" class="preview-footer"> :closable="false"
... {{ totalLines }} 条记录已显示前 100 ... show-icon
title="你正在查看历史版本,数据默认只读;设为当前版本后才可编辑。"
/>
<section v-if="files.length" class="preview-workspace" aria-label="数据集文件详情">
<div class="content-pane">
<div class="content-toolbar">
<div class="content-title">
<span class="file-icon" aria-hidden="true"><i class="fa fa-file-text-o" /></span>
<div>
<h2>{{ selectedFile?.name }}</h2>
<p>
{{ selectedFile?.size || '未知大小' }} <span aria-hidden="true">·</span>
<template v-if="isRecordFile">{{ records.length.toLocaleString() }} 条记录</template>
<template v-else>{{ totalLines.toLocaleString() }} </template>
</p>
</div>
</div>
<div class="toolbar-actions">
<el-button
v-if="hasPendingVersionChanges && isViewingActiveVersion"
type="primary"
:loading="savingVersion"
@click="saveVersion"
>
<i class="fa fa-save" />保存版本
</el-button>
<el-tooltip content="下载当前文件" placement="top">
<el-button class="download-button" aria-label="下载当前文件" :disabled="!selectedFile" @click="selectedFile && handleDownloadFile(selectedFile)">
<i class="fa fa-download" />下载文件
</el-button>
</el-tooltip>
</div>
</div>
<div class="viewer-shell" v-loading="versionLoading" :aria-busy="previewLoading || versionLoading">
<div v-if="previewLoading" class="viewer-state"><i class="fa fa-circle-o-notch fa-spin" aria-hidden="true" /><span>正在加载文件内容</span></div>
<div v-else-if="previewError" class="viewer-state viewer-state-error" role="alert">
<i class="fa fa-exclamation-circle" aria-hidden="true" /><strong>{{ previewError }}</strong>
<el-button size="small" @click="selectedFile && loadVersions(selectedFile)">重新加载</el-button>
</div>
<div v-else-if="!previewContent" class="viewer-state"><i class="fa fa-file-o" aria-hidden="true" /><span>当前文件暂无可预览内容</span></div>
<DatasetRecordTable
v-else-if="isRecordFile"
:records="records"
:is-viewing-active-version="isViewingActiveVersion"
:version-id="loadedVersionId"
@edit="openRecordEditor"
/>
<DatasetRawPreview
v-else
:content="previewContent"
:file-name="selectedFile?.name"
/>
</div>
<div class="viewer-footer"><span v-if="isRecordFile"><i class="fa fa-list-alt" aria-hidden="true" /> 逐条查看与编辑</span><span v-else><i class="fa fa-eye" aria-hidden="true" /> 文本预览</span><span>UTF-8</span></div>
</div> </div>
</div> </section>
<section v-else-if="!loading" class="empty-files" aria-label="空文件列表">
<span class="empty-icon" aria-hidden="true"><i class="fa fa-folder-open-o" /></span><h2>暂无数据文件</h2><p>该数据集还没有可供预览的文件</p>
</section>
<DatasetRecordEditorDialog
:model-value="editorVisible"
:record="editingRecord"
:edit-fields="editFields"
:raw-draft="rawDraft"
:saving-record="savingRecord"
:has-draft-changes="hasEditorDraftChanges"
:next-version-number="nextVersionNumber"
@close="closeRecordEditor"
@save="saveRecord"
@update:field="updateEditField"
@update:raw-draft="rawDraft = $event"
/>
<AppConfirmDialog ref="confirmDialogRef" />
</PageCard> </PageCard>
</template> </template>
<style scoped> <style scoped lang="scss">
.section-title { .dataset-preview-page {
min-width: 0;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
width: 100%;
min-width: 0;
}
.page-heading {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.heading-icon {
display: grid;
flex: 0 0 44px;
width: 44px;
height: 44px;
place-items: center;
border-radius: 12px;
background: linear-gradient(145deg, #eef2ff, #e0e7ff);
color: var(--primary-color);
font-size: 18px;
}
.heading-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.heading-row h1 {
margin: 0 4px 0 0;
color: #172033;
font-size: 20px;
font-weight: 650;
line-height: 1.35;
}
.page-heading p {
margin: 4px 0 0;
color: #7c8799;
font-size: 13px;
line-height: 1.5;
}
.dataset-overview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 20px;
padding: 16px 20px;
border: 1px solid #edf0f5;
border-radius: 12px;
background: #fbfcfe;
}
.overview-item {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
padding: 0 20px;
border-right: 1px solid #e9edf4;
}
.overview-item:first-child {
padding-left: 0;
}
.overview-item:last-child {
padding-right: 0;
border-right: 0;
}
.overview-item span {
color: #8a94a6;
font-size: 12px;
}
.overview-item strong {
overflow: hidden;
color: #283347;
font-size: 15px;
font-variant-numeric: tabular-nums;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.historical-version-alert {
margin-bottom: 12px;
}
.preview-workspace {
min-height: 520px;
overflow: hidden;
border: 1px solid #e5eaf2;
border-radius: 12px;
background: #fff;
}
.content-title h2 {
margin: 0;
color: #263146;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #606266;
margin: 0 0 12px;
} }
.file-section { .content-pane {
margin-bottom: 24px; display: flex;
flex-direction: column;
min-width: 0;
background: #fff;
} }
.preview-pre { .content-toolbar {
margin: 0; display: flex;
padding: 12px 16px; align-items: center;
background: #f5f7fa; justify-content: space-between;
border: 1px solid #ebeef5; gap: 16px;
border-radius: 4px; min-height: 68px;
font-family: 'SFMono-Regular', Consolas, monospace; padding: 10px 16px;
font-size: 12px; border-bottom: 1px solid #e5eaf2;
line-height: 1.6;
max-height: 500px;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
} }
.preview-footer { .content-title {
margin-top: 8px; display: flex;
font-size: 12px; align-items: center;
color: #909399; gap: 10px;
min-width: 0;
}
.content-title > div {
min-width: 0;
}
.content-title h2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-title p {
display: flex;
gap: 6px;
margin: 3px 0 0;
color: #8a94a6;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.file-icon {
display: grid;
flex: 0 0 34px;
height: 34px;
place-items: center;
border-radius: 8px;
background: #f1f5f9;
color: #69768b;
}
.download-button {
min-height: 36px;
}
.download-button i {
margin-right: 6px;
}
.toolbar-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.toolbar-actions i {
margin-right: 6px;
}
.viewer-shell {
position: relative;
flex: 1;
min-height: 400px;
overflow: hidden;
background: #fbfcfe;
}
.viewer-state {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
height: 100%;
min-height: 400px;
color: #8b96a8;
font-size: 13px;
}
.viewer-state > i {
color: #aeb7c5;
font-size: 24px;
}
.viewer-state-error > i,
.viewer-state-error strong {
color: #d14343;
}
.viewer-state-error strong {
font-weight: 500;
}
.viewer-footer {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 34px;
padding: 6px 14px;
border-top: 1px solid #e5eaf2;
background: #f8fafc;
color: #8a94a6;
font-size: 11px;
}
.viewer-footer i {
margin-right: 5px;
}
.empty-files {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-height: 420px;
border: 1px dashed #dce2eb;
border-radius: 12px;
background: #fbfcfe;
text-align: center; text-align: center;
} }
.empty-icon {
display: grid;
width: 54px;
height: 54px;
margin-bottom: 14px;
place-items: center;
border-radius: 14px;
background: #eef2f7;
color: #8a96a8;
font-size: 22px;
}
.empty-files h2 {
margin: 0;
color: #354056;
font-size: 15px;
}
.empty-files p {
margin: 6px 0 0;
color: #929daf;
font-size: 13px;
}
@media (max-width: 900px) {
.dataset-overview {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
.page-header {
align-items: flex-start;
}
.dataset-overview {
grid-template-columns: 1fr;
}
.overview-item {
padding: 0 0 12px;
border-right: 0;
border-bottom: 1px solid #e9edf4;
}
.overview-item:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.content-toolbar {
align-items: flex-start;
}
.download-button {
width: 44px;
padding: 8px;
font-size: 0;
}
.download-button i {
margin: 0;
font-size: 14px;
}
}
</style> </style>

View File

@@ -0,0 +1,121 @@
export type DatasetRecordKind = 'object' | 'primitive' | 'invalid'
export interface DatasetRecord {
sourceIndex: number
displayIndex: number
raw: string
kind: DatasetRecordKind
value: unknown
error?: string
}
export interface DatasetRecordResult {
supported: boolean
records: DatasetRecord[]
error?: string
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseValue(raw: string, sourceIndex: number, displayIndex: number): DatasetRecord {
try {
const value: unknown = JSON.parse(raw)
return {
sourceIndex,
displayIndex,
raw,
kind: isObjectRecord(value) ? 'object' : 'primitive',
value,
}
} catch (error) {
return {
sourceIndex,
displayIndex,
raw,
kind: 'invalid',
value: raw,
error: error instanceof Error ? error.message : 'JSON 格式不正确',
}
}
}
/** 将 JSONL 或 JSON 文件解析为可逐条编辑的样本,保留原始物理位置。 */
export function parseDatasetRecords(content: string, fileName: string): DatasetRecordResult {
const normalizedName = fileName.toLowerCase()
if (normalizedName.endsWith('.jsonl')) {
const records: DatasetRecord[] = []
content.split('\n').forEach((line, sourceIndex) => {
if (!line.trim()) return
records.push(parseValue(line, sourceIndex, records.length + 1))
})
return { supported: true, records }
}
if (normalizedName.endsWith('.json')) {
try {
const root: unknown = JSON.parse(content)
const values = Array.isArray(root) ? root : [root]
return {
supported: true,
records: values.map((value, sourceIndex) => ({
sourceIndex,
displayIndex: sourceIndex + 1,
raw: JSON.stringify(value),
kind: isObjectRecord(value) ? 'object' : 'primitive',
value,
})),
}
} catch (error) {
return {
supported: true,
records: [{
sourceIndex: 0,
displayIndex: 1,
raw: content,
kind: 'invalid',
value: content,
error: error instanceof Error ? error.message : 'JSON 格式不正确',
}],
}
}
}
return { supported: false, records: [] }
}
/** 仅替换目标样本JSONL 中其他行(包括空行和原格式)保持不变。 */
export function updateDatasetRecord(
content: string,
fileName: string,
sourceIndex: number,
nextValue: unknown,
) {
const normalizedName = fileName.toLowerCase()
if (normalizedName.endsWith('.jsonl')) {
const lines = content.split('\n')
if (sourceIndex < 0 || sourceIndex >= lines.length) throw new Error('目标样本不存在')
lines[sourceIndex] = JSON.stringify(nextValue)
return lines.join('\n')
}
if (normalizedName.endsWith('.json')) {
let root: unknown
try {
root = JSON.parse(content)
} catch {
if (sourceIndex !== 0) throw new Error('目标样本不存在')
return JSON.stringify(nextValue, null, 2)
}
if (Array.isArray(root)) {
if (sourceIndex < 0 || sourceIndex >= root.length) throw new Error('目标样本不存在')
root[sourceIndex] = nextValue
return JSON.stringify(root, null, 2)
}
if (sourceIndex !== 0) throw new Error('目标样本不存在')
return JSON.stringify(nextValue, null, 2)
}
throw new Error('当前文件不支持逐条编辑')
}

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
content: string
fileName?: string
}>()
const lines = computed(() => props.content.split('\n').slice(0, 100))
</script>
<template>
<div class="code-viewer" tabindex="0" :aria-label="`${fileName || ''} 文件内容`">
<div v-for="(line, index) in lines" :key="index" class="code-line">
<span class="line-number" aria-hidden="true">{{ index + 1 }}</span>
<code>{{ line || ' ' }}</code>
</div>
</div>
</template>
<style scoped lang="scss">
.code-viewer {
height: 100%;
max-height: 468px;
overflow: auto;
padding: 12px 0 18px;
outline: none;
}
.code-viewer:focus-visible {
box-shadow: inset 0 0 0 2px var(--el-color-primary-light-5);
}
.code-line {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
min-height: 25px;
color: #344054;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 12px;
line-height: 1.65;
}
.code-line:hover {
background: #f4f6fa;
}
.line-number {
padding-right: 14px;
border-right: 1px solid #edf0f5;
color: #a4adbc;
font-variant-numeric: tabular-nums;
text-align: right;
user-select: none;
}
.code-line code {
display: block;
min-width: max-content;
padding: 0 18px;
white-space: pre;
}
</style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { DatasetRecord } from '../datasetRecords'
import type { EditField } from './types'
defineProps<{
modelValue: boolean
record: DatasetRecord | null
editFields: EditField[]
rawDraft: string
savingRecord: boolean
hasDraftChanges: boolean
nextVersionNumber: number
}>()
const emit = defineEmits<{
close: [done?: () => void]
save: []
'update:field': [index: number, value: string]
'update:rawDraft': [value: string]
}>()
function handleBeforeClose(done: () => void) {
emit('close', done)
}
function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
emit('save')
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="record-editor-dialog"
width="min(720px, 92vw)"
:title="record ? `编辑第 ${record.displayIndex} 条数据` : '编辑数据'"
:close-on-click-modal="false"
:close-on-press-escape="!savingRecord"
:show-close="!savingRecord"
:before-close="handleBeforeClose"
destroy-on-close
>
<div v-if="record" class="record-editor" @keydown="handleKeydown">
<div class="editor-hint">
<i class="fa fa-info-circle" aria-hidden="true" />
当前修改会先暂存完成多条修改后点击文件工具栏中的保存版本创建
V{{ nextVersionNumber }}
</div>
<el-form v-if="record.kind === 'object'" label-position="top">
<el-form-item v-for="(field, index) in editFields" :key="field.key" :label="field.key">
<el-input
:model-value="field.value"
type="textarea"
:rows="field.isJson ? 5 : 3"
:aria-label="`编辑字段 ${field.key}`"
:placeholder="field.isJson ? '请输入合法 JSON' : `请输入 ${field.key}`"
@update:model-value="emit('update:field', index, $event)"
/>
<small v-if="field.isJson" class="field-helper">
该字段为对象数组或数值请保持合法 JSON 格式
</small>
</el-form-item>
</el-form>
<div v-else class="raw-record-editor">
<label for="raw-record-draft">
{{ record.kind === 'invalid' ? '修复 JSON 内容' : 'JSON 内容' }}
</label>
<el-input
id="raw-record-draft"
:model-value="rawDraft"
type="textarea"
:rows="10"
aria-label="编辑当前条 JSON 内容"
placeholder="请输入合法 JSON"
@update:model-value="emit('update:rawDraft', $event)"
/>
</div>
</div>
<template #footer>
<div class="record-editor-actions">
<span>{{ hasDraftChanges ? '有未暂存修改' : '尚未修改' }} · Ctrl/ + S 暂存</span>
<div>
<el-button :disabled="savingRecord" @click="emit('close')">取消</el-button>
<el-button
type="primary"
:loading="savingRecord"
:disabled="!hasDraftChanges"
@click="emit('save')"
>
暂存修改
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.editor-hint {
margin-bottom: 18px;
padding: 10px 12px;
border: 1px solid #dbe2ff;
border-radius: 8px;
background: #f5f7ff;
color: #56627a;
font-size: 13px;
}
.editor-hint i {
margin-right: 6px;
color: var(--primary-color);
}
.field-helper {
margin-top: 5px;
color: #8a94a6;
line-height: 1.5;
}
.raw-record-editor label {
display: block;
margin-bottom: 8px;
color: #344054;
font-size: 13px;
font-weight: 600;
}
.record-editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.record-editor-actions > span {
color: #8a94a6;
font-size: 12px;
}
@media (max-width: 680px) {
.record-editor-actions {
align-items: stretch;
flex-direction: column;
}
.record-editor-actions > div {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
}
</style>

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { DatasetRecord } from '../datasetRecords'
const props = defineProps<{
records: DatasetRecord[]
isViewingActiveVersion: boolean
versionId: string
}>()
const emit = defineEmits<{
edit: [record: DatasetRecord]
}>()
const pageSize = 10
const searchText = ref('')
const currentPage = ref(1)
const allTableFieldKeys = computed(() => {
const keys = new Set<string>()
props.records.forEach((record) => {
if (record.kind !== 'object') return
Object.keys(record.value as Record<string, unknown>).forEach((key) => keys.add(key))
})
const preferred = ['instruction', 'question', 'prompt', 'input', 'context', 'output', 'answer', 'response']
return [...keys].sort((a, b) => {
const rank = (key: string) => {
const index = preferred.indexOf(key)
return index === -1 ? preferred.length : index
}
return rank(a) - rank(b)
})
})
const tableFieldKeys = computed(() => allTableFieldKeys.value.slice(0, 6))
const invalidRecordCount = computed(() => props.records.filter((record) => record.kind === 'invalid').length)
const hasExtraTableFields = computed(() => allTableFieldKeys.value.length > tableFieldKeys.value.length)
const filteredRecords = computed(() => {
const query = searchText.value.trim().toLowerCase()
if (!query) return props.records
return props.records.filter((record) => JSON.stringify(record.value).toLowerCase().includes(query))
})
const pagedRecords = computed(() => {
const start = (currentPage.value - 1) * pageSize
return filteredRecords.value.slice(start, start + pageSize)
})
watch(
() => props.versionId,
() => {
searchText.value = ''
currentPage.value = 1
},
)
function asDatasetRecord(row: unknown) {
return row as DatasetRecord
}
function formatFieldValue(value: unknown) {
if (typeof value === 'string') return value || '(空)'
return JSON.stringify(value)
}
function recordFieldValue(record: DatasetRecord, key: string) {
if (record.kind !== 'object') {
return record.kind === 'invalid' ? record.raw : formatFieldValue(record.value)
}
const data = record.value as Record<string, unknown>
return Object.prototype.hasOwnProperty.call(data, key) ? formatFieldValue(data[key]) : '-'
}
function extraFieldValue(record: DatasetRecord) {
if (record.kind !== 'object') return '-'
const data = record.value as Record<string, unknown>
return allTableFieldKeys.value
.filter((key) => !tableFieldKeys.value.includes(key) && Object.prototype.hasOwnProperty.call(data, key))
.map((key) => `${key}: ${formatFieldValue(data[key])}`)
.join('') || '-'
}
function recordRowClassName({ row }: { row: unknown }) {
return asDatasetRecord(row).kind === 'invalid' ? 'record-table-row-invalid' : ''
}
</script>
<template>
<div class="records-viewer">
<div class="records-toolbar">
<div>
<strong>样本数据</strong>
<span> {{ records.length.toLocaleString() }} </span>
<el-tag v-if="invalidRecordCount" size="small" type="danger">
{{ invalidRecordCount }} 条格式错误
</el-tag>
</div>
<el-input
v-model="searchText"
class="record-search"
clearable
placeholder="搜索样本内容"
aria-label="搜索样本内容"
@input="currentPage = 1"
>
<template #prefix><i class="fa fa-search" /></template>
</el-input>
</div>
<div v-if="!filteredRecords.length" class="viewer-state record-empty-state">
<i class="fa fa-search" aria-hidden="true" />
<span>没有找到匹配的样本</span>
</div>
<div v-else class="record-table-shell" aria-label="数据样本表格">
<el-table
:data="pagedRecords"
row-key="sourceIndex"
stripe
border
class="record-table"
:row-class-name="recordRowClassName"
>
<el-table-column label="序号" width="76" align="center">
<template #default="{ row }">
<div class="table-index">
<strong>{{ asDatasetRecord(row).displayIndex }}</strong>
<small> {{ asDatasetRecord(row).sourceIndex + 1 }} </small>
</div>
</template>
</el-table-column>
<el-table-column
v-for="fieldKey in tableFieldKeys"
:key="fieldKey"
:label="fieldKey"
min-width="220"
>
<template #default="{ row }">
<el-tooltip
:content="recordFieldValue(asDatasetRecord(row), fieldKey)"
placement="top"
:show-after="400"
>
<div class="table-cell-text">
{{ recordFieldValue(asDatasetRecord(row), fieldKey) }}
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column v-if="hasExtraTableFields" label="其他字段" min-width="240">
<template #default="{ row }">
<el-tooltip
:content="extraFieldValue(asDatasetRecord(row))"
placement="top"
:show-after="400"
>
<div class="table-cell-text">{{ extraFieldValue(asDatasetRecord(row)) }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag v-if="asDatasetRecord(row).kind === 'invalid'" size="small" type="danger">
格式错误
</el-tag>
<el-tag v-else-if="asDatasetRecord(row).kind === 'primitive'" size="small" type="warning">
非对象
</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="96" align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
:disabled="!isViewingActiveVersion"
:title="isViewingActiveVersion ? '' : '历史版本为只读,请先设为当前版本'"
:aria-label="`${asDatasetRecord(row).kind === 'invalid' ? '修复' : '编辑'} ${asDatasetRecord(row).displayIndex} 条数据`"
@click="emit('edit', asDatasetRecord(row))"
>
<i :class="asDatasetRecord(row).kind === 'invalid' ? 'fa fa-wrench' : 'fa fa-pencil'" />
{{ asDatasetRecord(row).kind === 'invalid' ? '修复' : '编辑' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="filteredRecords.length > pageSize" class="records-pagination">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredRecords.length"
layout="prev, pager, next"
background
/>
</div>
</div>
</template>
<style scoped lang="scss">
.records-viewer {
min-height: 468px;
padding: 16px;
box-sizing: border-box;
}
.records-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.records-toolbar > div {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.records-toolbar strong {
color: #253047;
font-size: 14px;
}
.records-toolbar span {
color: #8a94a6;
font-size: 12px;
}
.record-search {
width: 240px;
}
.record-table-shell {
overflow: hidden;
border: 1px solid #e4e9f1;
border-radius: 8px;
}
.record-table {
width: 100%;
}
.record-table :deep(.el-table__header th) {
height: 44px;
background: #f5f7fa;
color: #475467;
font-size: 12px;
font-weight: 600;
}
.record-table :deep(.el-table__cell) {
padding: 10px 0;
}
.record-table :deep(.record-table-row-invalid td) {
background: #fff7f7 !important;
}
.table-index {
display: flex;
align-items: center;
flex-direction: column;
gap: 1px;
color: #667085;
font-variant-numeric: tabular-nums;
}
.table-index strong {
font-size: 13px;
font-weight: 600;
}
.table-index small {
color: #98a2b3;
font-size: 10px;
}
.table-cell-text {
display: -webkit-box;
overflow: hidden;
color: #526076;
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.record-table .el-button i {
margin-right: 5px;
}
.records-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.viewer-state {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
height: 100%;
min-height: 400px;
color: #8b96a8;
font-size: 13px;
}
.viewer-state > i {
color: #aeb7c5;
font-size: 24px;
}
.record-empty-state {
min-height: 320px;
}
@media (max-width: 680px) {
.records-toolbar {
align-items: stretch;
flex-direction: column;
}
.record-search {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.record-table :deep(.el-table__row) { transition: none; }
}
</style>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DatasetVersion } from '@/types'
const props = defineProps<{
modelValue: string
versions: DatasetVersion[]
viewedVersion?: DatasetVersion
activeVersionId: string
isViewingActiveVersion: boolean
hasPendingVersionChanges: boolean
versionLoading: boolean
savingRecord: boolean
activatingVersion: boolean
deletingVersion: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [versionId: string]
activate: []
delete: []
}>()
type VersionAction = 'activate' | 'delete'
const actionsDisabled = computed(
() =>
props.versionLoading ||
props.savingRecord ||
props.activatingVersion ||
props.deletingVersion,
)
const deleteDisabledReason = computed(() => {
if (props.viewedVersion?.version === 1) return '初始版本不可删除'
if (props.versions.length <= 1) return '至少需要保留一个版本'
return ''
})
const deleteDisabled = computed(
() => actionsDisabled.value || Boolean(deleteDisabledReason.value),
)
function formatVersionTime(value: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
function versionOptionLabel(version: DatasetVersion) {
const status = version.id === props.activeVersionId ? '当前版本' : '历史版本'
return `V${version.version} · ${status} · ${formatVersionTime(version.create_time)}`
}
function handleVersionChange(versionId: string) {
emit('update:modelValue', versionId)
emit('change', versionId)
}
function handleVersionAction(command: VersionAction) {
if (actionsDisabled.value) return
if (command === 'activate') {
emit('activate')
return
}
if (deleteDisabled.value) return
emit('delete')
}
</script>
<template>
<section class="version-control-bar" aria-label="数据集版本控制">
<div class="version-selector-group">
<label for="dataset-version-select">数据版本</label>
<el-select
id="dataset-version-select"
:model-value="modelValue"
class="version-select"
:loading="versionLoading"
:disabled="versionLoading || savingRecord"
aria-label="选择要查看的数据集版本"
@change="handleVersionChange"
>
<el-option
v-for="version in versions"
:key="version.id"
:label="versionOptionLabel(version)"
:value="version.id"
/>
</el-select>
</div>
<div class="version-status" role="status" aria-live="polite">
<template v-if="viewedVersion">
<el-tag v-if="isViewingActiveVersion" type="success">
V{{ viewedVersion.version }} · 当前版本
</el-tag>
<el-tag v-else type="info">V{{ viewedVersion.version }} · 历史版本只读</el-tag>
<el-tag v-if="hasPendingVersionChanges" type="warning">有待保存修改</el-tag>
<span>
{{ viewedVersion.description || '无版本说明' }} ·
{{ formatVersionTime(viewedVersion.create_time) }}
</span>
</template>
</div>
<el-dropdown
v-if="viewedVersion && !isViewingActiveVersion"
class="version-actions"
trigger="click"
placement="bottom-end"
popper-class="version-actions-popper"
:disabled="actionsDisabled"
@command="handleVersionAction"
>
<el-button
class="version-actions-button"
:loading="activatingVersion || deletingVersion"
:disabled="actionsDisabled"
aria-label="打开历史版本操作菜单"
>
<i
v-if="!activatingVersion && !deletingVersion"
class="fa fa-ellipsis-h"
aria-hidden="true"
/>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="activate"
class="version-action-primary"
:disabled="actionsDisabled"
>
<i class="fa fa-check-circle" aria-hidden="true" />
设为当前版本
</el-dropdown-item>
<el-dropdown-item
command="delete"
class="version-action-danger"
:disabled="deleteDisabled"
:title="deleteDisabledReason || undefined"
divided
>
<i class="fa fa-trash-o" aria-hidden="true" />
删除版本
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</section>
</template>
<style scoped lang="scss">
.version-control-bar {
display: flex;
align-items: center;
gap: 16px;
min-height: 64px;
margin-bottom: 12px;
padding: 10px 14px;
box-sizing: border-box;
border: 1px solid #dfe5ee;
border-radius: 10px;
background: #fff;
}
.version-selector-group {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.version-selector-group label {
color: #344054;
font-size: 13px;
font-weight: 600;
}
.version-select {
width: 300px;
}
.version-status {
display: flex;
align-items: center;
flex: 1;
gap: 10px;
min-width: 0;
}
.version-status > span {
overflow: hidden;
color: #7c8799;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-actions {
flex: 0 0 auto;
line-height: 1;
}
.version-actions-button {
width: 40px;
height: 40px;
flex: 0 0 auto;
padding: 0;
border-radius: 10px;
}
.version-actions-button i {
font-size: 16px;
}
:global(.version-actions-popper .el-dropdown-menu__item) {
gap: 8px;
min-height: 40px;
}
:global(.version-actions-popper .el-dropdown-menu) {
min-width: 168px;
}
:global(.version-actions-popper .version-action-primary:not(.is-disabled) i) {
color: var(--primary-color);
}
:global(.version-actions-popper .version-action-danger:not(.is-disabled)) {
color: var(--el-color-danger);
}
:global(.version-actions-popper .version-action-danger:not(.is-disabled):hover) {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
@media (max-width: 680px) {
.version-control-bar,
.version-selector-group,
.version-status {
align-items: stretch;
flex-direction: column;
}
.version-select,
.version-selector-group {
width: 100%;
}
.version-status {
width: 100%;
min-width: 0;
}
.version-status > span {
white-space: normal;
overflow-wrap: anywhere;
}
.version-actions {
align-self: flex-end;
}
}
</style>

View File

@@ -0,0 +1,5 @@
export interface EditField {
key: string
value: string
isJson: boolean
}

View File

@@ -1,19 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import PageCard from '@/components/PageCard.vue' import PageCard from '@/components/PageCard.vue'
import DimensionFormFields, { type DimensionFormDraft } from './create/DimensionFormFields.vue'
import { import {
createDimension, createDimension,
updateDimension, updateDimension,
getDimension, getDimension,
} from '@/api/modules/eval' } from '@/api/modules/eval'
import { getModelList } from '@/api/modules/model' import { getModelList } from '@/api/modules/model'
import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension' import { EVAL_METHODS } from '@/constants/dimension'
import { DIMENSION_TYPE_MAP } from '@/constants' import type { ModelItem } from '@/types'
import type { DimensionType, ModelItem } from '@/types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -24,9 +22,9 @@ const editId = computed(() => route.params.id as string | undefined)
const evalModels = ref<ModelItem[]>([]) const evalModels = ref<ModelItem[]>([])
const form = reactive({ const form = reactive<DimensionFormDraft & { name: string }>({
name: '', name: '',
type: '' as DimensionType | '', type: '',
description: '', description: '',
eval_model: '', eval_model: '',
eval_method: '' as string | string[], eval_method: '' as string | string[],
@@ -49,44 +47,10 @@ const rules: FormRules = {
], ],
type: [{ required: true, message: '请选择指标类型', trigger: 'change' }], type: [{ required: true, message: '请选择指标类型', trigger: 'change' }],
eval_model: [{ required: true, message: '请选择大模型', trigger: 'change' }], eval_model: [{ required: true, message: '请选择大模型', trigger: 'change' }],
eval_method: [{ required: true, message: '请选择评估方式', trigger: 'change' }],
eval_prompt: [{ required: true, message: '请填写评估 Prompt', trigger: 'blur' }], eval_prompt: [{ required: true, message: '请填写评估 Prompt', trigger: 'blur' }],
} }
const typeOptions = Object.entries(DIMENSION_TYPE_MAP).map(([value, label]) => ({ value, label }))
/** 当前类型下的评估方法选项 */
const currentMethods = computed(() => (form.type ? EVAL_METHODS[form.type] || [] : []))
/** 是否文本相似度类型(多选评估方法) */
const isTextSimilarity = computed(() => form.type === 'text_similarity')
/** 切换指标类型时重置相关字段 */
watch(
() => form.type,
(t) => {
if (!t) return
const methods = EVAL_METHODS[t] || []
const first = methods[0]?.value || ''
form.eval_method = isTextSimilarity.value ? [first] : first
// 切换 Prompt
if (form.type !== 'text_similarity' && EVAL_METHOD_PROMPTS[first]) {
form.eval_prompt = EVAL_METHOD_PROMPTS[first]
}
},
)
/** 评估方法变化时更新 Prompt */
watch(
() => form.eval_method,
(m) => {
if (form.type === 'text_similarity' || !m) return
const method = Array.isArray(m) ? m[0] : m
if (method && EVAL_METHOD_PROMPTS[method]) {
form.eval_prompt = EVAL_METHOD_PROMPTS[method]
}
},
)
async function loadEditData() { async function loadEditData() {
if (!editId.value) return if (!editId.value) return
try { try {
@@ -179,97 +143,7 @@ onMounted(() => {
<el-form-item label="维度名称" prop="name"> <el-form-item label="维度名称" prop="name">
<el-input v-model="form.name" placeholder="请输入维度名称" maxlength="50" show-word-limit /> <el-input v-model="form.name" placeholder="请输入维度名称" maxlength="50" show-word-limit />
</el-form-item> </el-form-item>
<DimensionFormFields v-model="form" :eval-models="evalModels" />
<el-form-item label="指标类型" prop="type">
<el-select v-model="form.type" placeholder="请选择指标类型" style="width: 100%">
<el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" maxlength="200" show-word-limit />
</el-form-item>
<!-- 分类型 / 指标型需要选择大模型和评估方法 -->
<template v-if="form.type === 'classification' || form.type === 'metric'">
<el-form-item label="选择大模型" prop="eval_model">
<el-select v-model="form.eval_model" placeholder="请选择评测模型" filterable style="width: 100%">
<el-option v-for="m in evalModels" :key="m.id" :label="m.name" :value="m.name" />
</el-select>
</el-form-item>
<el-form-item label="评估方式" prop="eval_method">
<el-radio-group v-model="(form.eval_method as string)">
<el-radio v-for="m in currentMethods" :key="m.value" :value="m.value">
{{ m.name }}
<span class="method-desc">{{ m.desc }}</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="评估 Prompt" prop="eval_prompt">
<MdEditor v-model="form.eval_prompt" language="zh-CN" :toolbars-exclude="['github', 'save']" style="height: 320px" />
</el-form-item>
</template>
<!-- 文本相似度多选评估方法 + BLEU/精度配置 -->
<template v-if="form.type === 'text_similarity'">
<el-form-item label="评估方式">
<el-checkbox-group v-model="(form.eval_method as string[])">
<el-checkbox v-for="m in currentMethods" :key="m.value" :value="m.value">
{{ m.name }}
<span class="method-desc">{{ m.desc }}</span>
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="BLEU n-gram">
<el-select v-model="form.bleu_n" style="width: 160px">
<el-option :value="1" label="1-gram" />
<el-option :value="2" label="2-gram" />
<el-option :value="3" label="3-gram" />
<el-option :value="4" label="4-gram" />
</el-select>
</el-form-item>
<el-form-item label="输出精度">
<el-select v-model="form.output_precision" style="width: 160px">
<el-option :value="1" label="1 位小数" />
<el-option :value="2" label="2 位小数" />
<el-option :value="3" label="3 位小数" />
<el-option :value="4" label="4 位小数" />
</el-select>
</el-form-item>
</template>
<!-- 指标型评分范围 + 通过阈值 -->
<template v-if="form.type === 'metric'">
<el-form-item label="评分最小值">
<el-input-number v-model="form.score_min" :min="0" :step="1" style="width: 160px" />
</el-form-item>
<el-form-item label="评分最大值">
<el-input-number v-model="form.score_max" :min="0" :step="1" style="width: 160px" />
</el-form-item>
<el-form-item label="通过阈值">
<div style="width: 100%">
<el-slider
v-model="form.pass_threshold"
:min="form.score_min"
:max="form.score_max"
:step="0.5"
show-input
:show-input-controls="false"
/>
</div>
</el-form-item>
</template>
<el-form-item label="启用该维度">
<el-switch v-model="form.is_active" />
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="form.is_default" />
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit"> <el-button type="primary" :loading="submitting" @click="handleSubmit">
@@ -280,11 +154,3 @@ onMounted(() => {
</el-form> </el-form>
</PageCard> </PageCard>
</template> </template>
<style scoped>
.method-desc {
color: #909399;
font-size: 12px;
margin-left: 6px;
}
</style>

View File

@@ -1,91 +1,207 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue' import PageCard from '@/components/PageCard.vue'
import { startEval } from '@/api/modules/eval' import EvalTaskSetupStep, { type EvalTaskSetupDraft } from './create/EvalTaskSetupStep.vue'
import { getDimensionList } from '@/api/modules/eval' import EvalRuleSetupStep, { type EvalRuleSetupDraft } from './create/EvalRuleSetupStep.vue'
import { getTrainedModels } from '@/api/modules/model' import BasicMetricSetupStep, { type BasicMetricSetupDraft } from './create/BasicMetricSetupStep.vue'
import StartEvalStep from './create/StartEvalStep.vue'
import { createDimension, startEval } from '@/api/modules/eval'
import { getTrainedModels, getModelList } from '@/api/modules/model'
import { getDatasetList } from '@/api/modules/dataset' import { getDatasetList } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system' import { getSystemInfo } from '@/api/modules/system'
import type { Dimension, TrainedModel, DatasetItem, GpuInfo } from '@/types' import type { DatasetItem, Dimension, GpuInfo, ModelItem, TrainedModel } from '@/types'
type StepExposed = { validate: () => Promise<boolean> }
const router = useRouter() const router = useRouter()
const formRef = ref<FormInstance>() const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const currentStep = ref(0)
const taskStepRef = ref<StepExposed>()
const ruleStepRef = ref<StepExposed>()
const basicMetricStepRef = ref<StepExposed>()
const WIZARD_STEPS = [
{ title: '任务配置', description: '选择模型、算力与评测数据' },
{ title: '大模型评测指标', description: '配置评测模型、方式与标准' },
{ title: '基础评测指标', description: '选择 BLEU、ROUGE 等参考指标' },
{ title: '开始评测', description: '确认配置并启动评测任务' },
] as const
const trainedModels = ref<TrainedModel[]>([]) const trainedModels = ref<TrainedModel[]>([])
const evalDatasets = ref<DatasetItem[]>([]) const evalDatasets = ref<DatasetItem[]>([])
const dimensions = ref<Dimension[]>([]) const evalModels = ref<ModelItem[]>([])
const gpus = ref<GpuInfo[]>([]) const gpus = ref<GpuInfo[]>([])
const createdDimensionId = ref<string | number>('')
const form = reactive({ const taskForm = ref<EvalTaskSetupDraft>({
eval_task_name: '', eval_task_name: '',
eval_type: 'custom' as 'custom' | 'baseline', model_id: '',
model_id: '' as string | number,
gpu_id: '', gpu_id: '',
data_source: 'dataset' as 'dataset' | 'inference', data_source: 'dataset',
dataset_id: '' as string | number, dataset_id: '',
dimension_id: '' as string | number, leaderboard: false,
leaderboard: false, // 修复原项目字段名 bug原读 leader 恒 false
}) })
const rules: FormRules = { const ruleForm = ref<EvalRuleSetupDraft>({
eval_task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }], type: '',
model_id: [{ required: true, message: '请选择评测模型', trigger: 'change' }], description: '',
gpu_id: [{ required: true, message: '请选择 GPU', trigger: 'change' }], eval_model: '',
dimension_id: [{ required: true, message: '请选择评测维度', trigger: 'change' }], eval_method: '',
} eval_prompt: '',
is_active: true,
is_default: false,
bleu_n: 1,
output_precision: 3,
score_min: 0,
score_max: 5,
pass_threshold: 3,
})
const showEvalRules = computed(() => form.eval_type === 'custom') const basicMetricForm = ref<BasicMetricSetupDraft>({
const showDataset = computed(() => form.data_source === 'dataset') bleu_enabled: false,
bleu_n: 4,
rouge_enabled: false,
rouge_methods: ['rouge_1', 'rouge_2'],
cosine_enabled: false,
output_precision: 3,
})
/** 选中维度详情 */ watch(
const selectedDimension = computed(() => ruleForm,
dimensions.value.find((d) => d.id == form.dimension_id), () => {
createdDimensionId.value = ''
},
{ deep: true },
) )
async function loadData() { async function loadData() {
try { loading.value = true
const [models, dims, allDs, sys] = await Promise.all([ const results = await Promise.allSettled([
getTrainedModels(), getTrainedModels(),
getDimensionList(), getDatasetList(),
getDatasetList(), getSystemInfo(),
getSystemInfo(), getModelList(),
]) ])
trainedModels.value = models?.models || []
dimensions.value = dims || [] if (results[0].status === 'fulfilled') trainedModels.value = results[0].value?.models || []
evalDatasets.value = (allDs || []).filter((d: DatasetItem) => d.type === 'eval') if (results[1].status === 'fulfilled') {
gpus.value = sys?.gpu || [] evalDatasets.value = (results[1].value || []).filter((dataset) => dataset.type === 'eval')
} catch {
// ignore
} }
if (results[2].status === 'fulfilled') gpus.value = results[2].value?.gpu || []
if (results[3].status === 'fulfilled') {
evalModels.value = (results[3].value || []).filter((model) => model.purpose === 'evaluation')
}
const failedCount = results.filter((result) => result.status === 'rejected').length
if (failedCount > 0) {
ElMessage.error(`部分基础数据加载失败(${failedCount} 项),请刷新页面后重试`)
}
loading.value = false
}
function buildDimensionPayload() {
const draft = ruleForm.value
const payload: Partial<Dimension> = {
name: `Custom_Dim_${Date.now()}`,
type: draft.type,
description: draft.description,
eval_model: draft.eval_model,
eval_method: draft.eval_method,
eval_prompt: draft.eval_prompt,
is_active: draft.is_active,
is_default: draft.is_default,
create_time: new Date().toISOString(),
}
if (draft.type === 'metric') {
payload.score_min = draft.score_min
payload.score_max = draft.score_max
payload.pass_threshold = draft.pass_threshold
}
return payload
}
async function resolveDimensionId() {
if (createdDimensionId.value !== '') return createdDimensionId.value
const created = await createDimension(buildDimensionPayload())
if (created?.id === undefined || created.id === null || created.id === '') {
throw new Error('评测维度已提交,但未返回维度 ID无法启动评测任务')
}
createdDimensionId.value = created.id
return created.id
} }
async function handleSubmit() { async function handleSubmit() {
if (!formRef.value) return if (loading.value || submitting.value) return
await formRef.value.validate(async (valid) => { submitting.value = true
if (!valid) return try {
submitting.value = true const dimensionId = await resolveDimensionId()
try { await startEval({
await startEval({ eval_task_name: taskForm.value.eval_task_name,
eval_task_name: form.eval_task_name, eval_type: 'custom',
eval_type: form.eval_type, model_id: taskForm.value.model_id,
model_id: form.model_id, gpu_id: taskForm.value.gpu_id,
gpu_id: form.gpu_id, dataset_id: taskForm.value.data_source === 'dataset' ? taskForm.value.dataset_id : '',
dataset_id: form.dataset_id, dimension_id: dimensionId,
dimension_id: form.dimension_id, data_source: taskForm.value.data_source,
data_source: form.data_source, leaderboard: taskForm.value.leaderboard,
leaderboard: form.leaderboard, basic_metrics: {
}) bleu: {
ElMessage.success('评测任务已启动') enabled: basicMetricForm.value.bleu_enabled,
router.push('/model-eval') ngram: basicMetricForm.value.bleu_n,
} catch { },
// ignore rouge: {
} finally { enabled: basicMetricForm.value.rouge_enabled,
submitting.value = false methods: basicMetricForm.value.rouge_methods,
},
cosine: {
enabled: basicMetricForm.value.cosine_enabled,
},
output_precision: basicMetricForm.value.output_precision,
},
})
ElMessage.success('评测任务已创建并启动')
router.push('/model-eval')
} catch (error) {
const message = error instanceof Error ? error.message : '创建评测任务失败,请稍后重试'
ElMessage.error(message)
} finally {
submitting.value = false
}
}
async function handleNext() {
if (loading.value || submitting.value) return
if (currentStep.value === 0) {
const taskValid = await taskStepRef.value?.validate()
if (!taskValid) {
ElMessage.warning('请检查并完善任务配置')
return
} }
}) }
if (currentStep.value === 1) {
const ruleValid = await ruleStepRef.value?.validate()
if (!ruleValid) {
ElMessage.warning('请检查并完善大模型评测指标')
return
}
}
if (currentStep.value === 2) {
const basicMetricValid = await basicMetricStepRef.value?.validate()
if (!basicMetricValid) {
ElMessage.warning('请检查并完善基础评测指标')
return
}
}
currentStep.value = Math.min(currentStep.value + 1, WIZARD_STEPS.length - 1)
}
function handleBack() {
if (submitting.value) return
currentStep.value = Math.max(currentStep.value - 1, 0)
} }
function handleCancel() { function handleCancel() {
@@ -96,82 +212,276 @@ onMounted(loadData)
</script> </script>
<template> <template>
<PageCard title="新建评测"> <PageCard
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" style="max-width: 720px"> title="新建评测任务"
<el-form-item label="任务名称" prop="eval_task_name"> subtitle="依次配置任务、大模型评测指标与基础评测指标,确认后开始评测"
<el-input v-model="form.eval_task_name" placeholder="请输入任务名称" maxlength="50" show-word-limit /> >
</el-form-item> <div class="create-wizard-layout">
<main v-loading="loading" class="wizard-main">
<div class="wizard-steps-container" aria-label="评测任务创建步骤">
<div class="custom-wizard-steps">
<template v-for="(step, index) in WIZARD_STEPS" :key="step.title">
<div
v-if="index !== 0"
class="step-connector"
:class="{ 'is-active': currentStep >= index }"
></div>
<div
class="step-item"
:class="{
'is-active': currentStep === index,
'is-completed': currentStep > index,
}"
:aria-current="currentStep === index ? 'step' : undefined"
:aria-label="`${index + 1}. ${step.title}${step.description}`"
>
<div class="step-node">
<div class="step-icon" aria-hidden="true">
<i v-if="currentStep > index" class="fa fa-check" />
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-text">
<div class="step-title">{{ step.title }}</div>
<div class="step-description">{{ step.description }}</div>
</div>
</div>
</div>
</template>
</div>
</div>
<el-form-item label="评测方式"> <div class="wizard-content">
<el-radio-group v-model="form.eval_type"> <EvalTaskSetupStep
<el-radio-button value="custom">自定义评测</el-radio-button> v-if="currentStep === 0"
<el-radio-button value="baseline">基线评测</el-radio-button> ref="taskStepRef"
</el-radio-group> v-model="taskForm"
</el-form-item> :trained-models="trainedModels"
:eval-datasets="evalDatasets"
<el-form-item label="评测模型" prop="model_id"> :gpus="gpus"
<el-select v-model="form.model_id" placeholder="请选择已训练模型" filterable style="width: 100%"> :loading="loading"
<el-option :disabled="submitting"
v-for="m in trainedModels"
:key="m.id"
:label="m.name"
:value="m.id"
:disabled="!m.merged"
/> />
</el-select> <EvalRuleSetupStep
</el-form-item> v-else-if="currentStep === 1"
ref="ruleStepRef"
v-model="ruleForm"
:eval-models="evalModels"
:disabled="submitting"
/>
<BasicMetricSetupStep
v-else-if="currentStep === 2"
ref="basicMetricStepRef"
v-model="basicMetricForm"
:disabled="submitting"
/>
<StartEvalStep
v-else
:task="taskForm"
:llm-metric="ruleForm"
:basic-metrics="basicMetricForm"
:trained-models="trainedModels"
:eval-datasets="evalDatasets"
:eval-models="evalModels"
:gpus="gpus"
/>
</div>
</main>
<el-form-item label="选择 GPU" prop="gpu_id"> <footer class="wizard-footer">
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%"> <el-button
<el-option v-for="(g, idx) in gpus" :key="idx" :label="`${g.name} (GPU ${idx})`" :value="idx" /> v-if="currentStep > 0"
</el-select> class="footer-back"
</el-form-item> :disabled="submitting"
@click="handleBack"
>
<i class="fa fa-arrow-left footer-button-icon" />返回{{ WIZARD_STEPS[currentStep - 1].title }}
</el-button>
<el-button v-else class="footer-back" :disabled="submitting" @click="handleCancel">
取消
</el-button>
<el-form-item label="数据来源"> <el-button
<el-radio-group v-model="form.data_source"> v-if="currentStep < WIZARD_STEPS.length - 1"
<el-radio value="dataset">评测数据集</el-radio> type="primary"
<el-radio value="inference">推理结果集</el-radio> :disabled="loading || submitting"
</el-radio-group> @click="handleNext"
</el-form-item> >
下一步{{ WIZARD_STEPS[currentStep + 1].title }}<i
<el-form-item v-if="showDataset" label="评测数据集" prop="dataset_id"> class="fa fa-arrow-right footer-button-icon is-right"
<el-select v-model="form.dataset_id" placeholder="请选择评测数据集" filterable style="width: 100%"> />
<el-option v-for="d in evalDatasets" :key="d.id" :label="d.name" :value="d.id" /> </el-button>
</el-select> <el-button
</el-form-item> v-else
type="primary"
<!-- 评测规则 custom 显示 --> :loading="submitting"
<template v-if="showEvalRules"> :disabled="loading || submitting"
<el-divider content-position="left">评测规则</el-divider> @click="handleSubmit"
<el-form-item label="评测维度" prop="dimension_id"> >
<el-select 开始评测
v-model="form.dimension_id" </el-button>
placeholder="请选择评测维度" </footer>
filterable </div>
style="width: 100%"
>
<el-option v-for="d in dimensions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
<el-form-item v-if="selectedDimension" label="维度详情">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="评估模型">{{ selectedDimension.eval_model || '-' }}</el-descriptions-item>
<el-descriptions-item label="评估方法">
{{ Array.isArray(selectedDimension.eval_method) ? selectedDimension.eval_method.join(', ') : selectedDimension.eval_method || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-form-item>
</template>
<el-form-item label="加入排行榜">
<el-switch v-model="form.leaderboard" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">启动评测</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</PageCard> </PageCard>
</template> </template>
<style scoped>
.create-wizard-layout {
display: flex;
flex-direction: column;
min-height: 620px;
margin: -20px;
background: #fff;
}
.wizard-main {
flex: 1;
padding: 32px;
}
.wizard-steps-container {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px dashed #e2e8f0;
}
.custom-wizard-steps {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1120px;
margin: 0 auto;
padding: 0 20px;
}
.step-item {
display: flex;
flex: none;
align-items: center;
}
.step-connector {
flex: 1;
height: 2px;
margin: 0 12px;
background: #e2e8f0;
transition: background-color 0.2s ease;
}
.step-connector.is-active {
background: #5146e5;
}
.step-node {
display: flex;
align-items: center;
gap: 12px;
}
.step-icon {
display: flex;
width: 30px;
height: 30px;
flex: 0 0 30px;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 2px solid #cbd5e1;
border-radius: 50%;
background: #fff;
color: #64748b;
font-size: 13px;
font-weight: 650;
transition: all 0.2s ease;
}
.step-title {
color: #64748b;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
.step-description {
margin-top: 3px;
color: #94a3b8;
font-size: 12px;
white-space: nowrap;
}
.step-item.is-active .step-icon {
border-color: #5146e5;
background: #eef2ff;
color: #5146e5;
}
.step-item.is-active .step-title,
.step-item.is-completed .step-title {
color: #1e293b;
}
.step-item.is-completed .step-icon {
border-color: #5146e5;
background: #5146e5;
color: #fff;
}
.wizard-content {
max-width: 920px;
min-height: 400px;
margin: 0 auto;
}
.wizard-footer {
display: flex;
min-height: 64px;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 0 32px;
border-top: 1px solid #e2e8f0;
background: #fff;
box-shadow: 0 -4px 6px -1px rgb(15 23 42 / 2%);
}
.footer-back {
margin-right: auto;
}
.footer-button-icon {
margin-right: 6px;
}
.footer-button-icon.is-right {
margin-right: 0;
margin-left: 6px;
}
@media (max-width: 1100px) {
.step-description {
display: none;
}
}
@media (max-width: 760px) {
.wizard-main {
padding: 24px 20px;
}
.custom-wizard-steps {
padding: 0;
}
.step-connector {
margin: 0 8px;
}
.step-text {
display: none;
}
.wizard-footer {
padding: 0 20px;
}
}
</style>

View File

@@ -0,0 +1,749 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import PageCard from '@/components/PageCard.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import { getEvalDetail } from '@/api/modules/eval'
import type { EvalSampleResult, EvalTaskDetail } from '@/types'
const route = useRoute()
const taskId = route.params.id as string
const loading = ref(true)
const loadError = ref('')
const detail = ref<EvalTaskDetail | null>(null)
const keyword = ref('')
const judgementFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const filteredSamples = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
return (detail.value?.samples || []).filter((sample) => {
const matchesJudgement = !judgementFilter.value || sample.judgement === judgementFilter.value
const searchableText = [
sample.input,
sample.reference_answer,
sample.model_output,
sample.evaluation_reason,
sample.error_type,
].filter(Boolean).join(' ').toLowerCase()
return matchesJudgement && (!normalizedKeyword || searchableText.includes(normalizedKeyword))
})
})
const paginatedSamples = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSamples.value.slice(start, start + pageSize.value)
})
const completionRate = computed(() => {
const total = detail.value?.sample_count || 0
return total ? Math.round(((detail.value?.completed_count || 0) / total) * 100) : 0
})
const passRate = computed(() => {
const completed = detail.value?.completed_count || 0
return completed ? Math.round(((detail.value?.passed_count || 0) / completed) * 100) : 0
})
const overallScore = computed(() => formatScore(detail.value?.overall_score, detail.value?.overall_score_max))
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime())
? value
: date.toLocaleString('zh-CN', { hour12: false })
}
function formatScore(score?: number | null, maxScore?: number) {
if (score == null) return '-'
const decimals = Number.isInteger(score) ? 0 : 2
return `${score.toFixed(decimals)}${maxScore != null ? ` / ${maxScore}` : ''}`
}
function judgementTagType(judgement?: EvalSampleResult['judgement']) {
if (judgement === '正确') return 'success'
if (judgement === '部分正确') return 'warning'
if (judgement === '错误') return 'danger'
return 'info'
}
function resetPage() {
currentPage.value = 1
}
async function loadDetail() {
loading.value = true
loadError.value = ''
try {
detail.value = await getEvalDetail(taskId)
} catch {
detail.value = null
loadError.value = '评测详情加载失败,请稍后重试。'
} finally {
loading.value = false
}
}
onMounted(loadDetail)
</script>
<template>
<PageCard class="eval-detail-page" v-loading="loading">
<template #header>
<div class="page-heading">
<div class="heading-copy">
<div class="heading-row">
<h1>{{ detail?.eval_task_name || '评测任务详情' }}</h1>
<ModelStatusTag v-if="detail" :status="detail.status" />
</div>
<dl class="task-meta">
<div><dt>任务 ID</dt><dd>{{ detail?.id || taskId }}</dd></div>
<div><dt>评测模型</dt><dd>{{ detail?.model_name || '-' }}</dd></div>
<div><dt>测试集</dt><dd>{{ detail?.dataset || '-' }}</dd></div>
<div><dt>评测指标</dt><dd>{{ detail?.metric || '-' }}</dd></div>
</dl>
</div>
</div>
</template>
<div v-if="loadError" class="load-state is-error" role="alert">
<i class="fa fa-exclamation-circle" aria-hidden="true" />
<h2>无法加载评测详情</h2>
<p>{{ loadError }}</p>
<el-button type="primary" @click="loadDetail">重新加载</el-button>
</div>
<template v-else-if="detail">
<section class="overview-grid" aria-label="评测概览">
<div class="overview-item score-hero">
<span>综合得分</span>
<strong>{{ overallScore }}</strong>
<small>大模型综合评分</small>
</div>
<div class="overview-item">
<span>样本通过率</span>
<strong>{{ detail.completed_count ? `${passRate}%` : '-' }}</strong>
<small>通过 {{ detail.passed_count || 0 }} / 已评测 {{ detail.completed_count }}</small>
</div>
<div class="overview-item">
<span>评测进度</span>
<strong>{{ detail.completed_count }} / {{ detail.sample_count }}</strong>
<el-progress :percentage="completionRate" :show-text="false" :stroke-width="5" />
</div>
<div class="overview-item overview-time">
<span>{{ detail.completed_time ? '完成时间' : '创建时间' }}</span>
<strong>{{ formatDateTime(detail.completed_time || detail.create_time) }}</strong>
<small>{{ detail.completed_time ? '评测任务已完成' : `当前进度 ${completionRate}%` }}</small>
</div>
</section>
<section class="overall-review" aria-labelledby="overall-review-title">
<div class="review-copy">
<div class="section-heading">
<div>
<h2 id="overall-review-title">大模型综合评价</h2>
<p>基于全部已评测样本生成的总体结论</p>
</div>
<el-tag v-if="detail.evaluator_model" type="primary" size="small">
评审模型{{ detail.evaluator_model }}
</el-tag>
</div>
<p class="review-text">
{{ detail.overall_evaluation || (detail.status === 'running' ? '评测仍在进行,综合评价将在样本评分完成后生成。' : '暂无综合评价。') }}
</p>
<div class="suggestion-block">
<h3>改进建议</h3>
<ul v-if="detail.improvement_suggestions?.length" class="improvement-list">
<li v-for="suggestion in detail.improvement_suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
<p v-else class="empty-copy">暂无改进建议</p>
</div>
</div>
</section>
<section v-if="detail.dimension_summary?.length" class="dimension-summary" aria-labelledby="dimension-title">
<div class="section-heading compact-heading">
<div>
<h2 id="dimension-title">维度表现</h2>
<p>查看各评测维度的得分与样本通过率</p>
</div>
</div>
<div class="dimension-grid">
<div v-for="dimension in detail.dimension_summary" :key="dimension.name" class="dimension-item">
<div class="dimension-label">
<strong>{{ dimension.name }}</strong>
<span>{{ formatScore(dimension.score, dimension.max_score) }}</span>
</div>
<el-progress :percentage="dimension.pass_rate" :stroke-width="7" />
</div>
</div>
</section>
<section class="sample-section" aria-labelledby="sample-results-title">
<div class="sample-toolbar">
<div>
<h2 id="sample-results-title">测试集样本评测结果</h2>
<p> {{ filteredSamples.length }} 条结果展开行可查看评分依据与子维度分数</p>
</div>
<div class="sample-filters" aria-label="样本筛选">
<el-input
v-model="keyword"
clearable
placeholder="搜索问题、回答或评价"
aria-label="搜索样本"
@input="resetPage"
>
<template #prefix><i class="fa fa-search" aria-hidden="true" /></template>
</el-input>
<el-select
v-model="judgementFilter"
clearable
placeholder="全部判定"
aria-label="按判定筛选"
@change="resetPage"
>
<el-option label="正确" value="正确" />
<el-option label="部分正确" value="部分正确" />
<el-option label="错误" value="错误" />
</el-select>
</div>
</div>
<el-table
v-if="filteredSamples.length"
class="sample-results-table"
:data="paginatedSamples"
row-key="id"
table-layout="fixed"
>
<el-table-column type="expand" width="48">
<template #default="{ row }">
<div class="sample-detail-grid">
<div class="evaluation-reason">
<span>大模型评分依据</span>
<p>{{ row.evaluation_reason || '暂无评分依据。' }}</p>
</div>
<div v-if="row.error_type" class="error-type">
<span>错误类型</span>
<el-tag type="danger" size="small">{{ row.error_type }}</el-tag>
</div>
<div v-if="row.dimension_scores?.length" class="sample-dimensions">
<span>子维度评分</span>
<div class="dimension-score-list">
<span v-for="item in row.dimension_scores" :key="item.name">
{{ item.name }} <strong>{{ formatScore(item.score, item.max_score) }}</strong>
</span>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="#" prop="index" width="56" align="center" />
<el-table-column label="问题 / 输入" min-width="180">
<template #default="{ row }"><p class="cell-copy">{{ row.input }}</p></template>
</el-table-column>
<el-table-column label="标准答案" min-width="170">
<template #default="{ row }"><p class="cell-copy">{{ row.reference_answer || '-' }}</p></template>
</el-table-column>
<el-table-column label="模型回答" min-width="190">
<template #default="{ row }"><p class="cell-copy">{{ row.model_output || '等待生成' }}</p></template>
</el-table-column>
<el-table-column label="得分" width="90" align="center">
<template #default="{ row }">
<span class="sample-score">{{ formatScore(row.score, row.max_score) }}</span>
</template>
</el-table-column>
<el-table-column label="判定" width="96" align="center">
<template #default="{ row }">
<el-tag :type="judgementTagType(row.judgement)" size="small">
{{ row.judgement || (row.status === 'pending' ? '待评测' : '-') }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div v-else class="load-state sample-empty">
<i class="fa fa-list-alt" aria-hidden="true" />
<h3>{{ detail.samples.length ? '没有符合筛选条件的样本' : '暂无样本评测结果' }}</h3>
<p>{{ detail.status === 'running' ? '任务正在运行,结果生成后会显示在这里。' : '请调整筛选条件或稍后重试。' }}</p>
</div>
<el-pagination
v-if="filteredSamples.length > pageSize"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
background
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
:total="filteredSamples.length"
aria-label="样本结果分页"
/>
</section>
</template>
</PageCard>
</template>
<style scoped lang="scss">
.eval-detail-page {
min-width: 0;
}
.page-heading,
.heading-row,
.section-heading,
.sample-toolbar,
.sample-filters,
.dimension-label {
display: flex;
align-items: center;
}
.heading-copy {
width: 100%;
min-width: 0;
}
.heading-row {
gap: 10px;
flex-wrap: wrap;
}
.heading-row h1 {
margin: 0;
color: #303133;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
margin: 10px 0 0;
}
.task-meta > div {
display: flex;
gap: 6px;
min-width: 0;
}
.task-meta dt,
.task-meta dd {
margin: 0;
font-size: 13px;
}
.task-meta dt {
color: #909399;
}
.task-meta dd {
color: #606266;
font-weight: 500;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.overview-item {
min-width: 0;
min-height: 92px;
padding: 16px 20px;
border-right: 1px solid #e4e7ed;
}
.overview-item:last-child {
border-right: 0;
}
.overview-item > span,
.evaluation-reason > span,
.error-type > span,
.sample-dimensions > span {
display: block;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
font-weight: 500;
}
.overview-item > strong {
display: block;
color: #303133;
font-size: 22px;
font-weight: 600;
line-height: 1.3;
font-variant-numeric: tabular-nums;
}
.score-hero > strong {
color: var(--primary-color);
}
.overview-item small {
display: block;
margin-top: 5px;
color: #909399;
font-size: 12px;
}
.overview-item :deep(.el-progress) {
margin-top: 8px;
}
.overview-time > strong {
font-size: 14px;
font-weight: 500;
line-height: 1.6;
}
.overall-review {
margin-top: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
}
.review-copy {
padding: 18px 20px;
}
.section-heading,
.sample-toolbar {
justify-content: space-between;
gap: 16px;
}
.section-heading h2,
.sample-toolbar h2 {
margin: 0;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.section-heading p,
.sample-toolbar p {
margin: 5px 0 0;
color: #909399;
font-size: 13px;
}
.review-text {
max-width: 1100px;
margin: 16px 0;
color: #606266;
font-size: 14px;
line-height: 1.75;
}
.suggestion-block {
padding: 14px 16px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #f8f9fb;
}
.suggestion-block h3 {
margin: 0 0 8px;
color: #303133;
font-size: 14px;
font-weight: 600;
}
.improvement-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 24px;
margin: 0;
padding-left: 18px;
color: #606266;
font-size: 13px;
line-height: 1.6;
}
.dimension-summary,
.sample-section {
margin-top: 16px;
padding: 18px 20px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
}
.compact-heading {
align-items: center;
}
.dimension-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
margin-top: 16px;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
}
.dimension-item {
min-width: 0;
padding: 14px 16px;
border-right: 1px solid #ebeef5;
background: #fafafa;
}
.dimension-item:last-child {
border-right: 0;
}
.dimension-label {
justify-content: space-between;
margin-bottom: 8px;
gap: 8px;
color: #606266;
font-size: 13px;
}
.dimension-label strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dimension-label span {
flex: 0 0 auto;
color: #303133;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sample-toolbar {
align-items: center;
margin-bottom: 14px;
}
.sample-filters {
flex: 0 1 450px;
justify-content: flex-end;
gap: 10px;
}
.sample-filters :deep(.el-input) {
width: min(260px, 100%);
}
.sample-filters :deep(.el-select) {
width: 130px;
}
.sample-results-table {
width: 100%;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.cell-copy {
display: -webkit-box;
overflow: hidden;
margin: 0;
color: #606266;
font-size: 13px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.sample-score {
color: #303133;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sample-detail-grid {
display: grid;
grid-template-columns: minmax(140px, 0.35fr) minmax(320px, 1fr);
gap: 14px 24px;
padding: 16px 48px;
background: #f7f8fa;
}
.evaluation-reason {
grid-column: 1 / -1;
}
.evaluation-reason p {
margin: 0;
color: #606266;
font-size: 13px;
line-height: 1.7;
}
.dimension-score-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dimension-score-list > span {
color: #606266;
font-size: 12px;
}
.dimension-score-list > span:not(:last-child)::after {
margin-left: 8px;
color: #c0c4cc;
content: '/';
}
.dimension-score-list strong {
margin-left: 4px;
color: #303133;
font-variant-numeric: tabular-nums;
}
.load-state {
min-height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
color: #909399;
text-align: center;
}
.load-state > i {
margin-bottom: 12px;
color: #c0c4cc;
font-size: 30px;
}
.load-state h2,
.load-state h3 {
margin: 0;
color: #303133;
}
.load-state p,
.empty-copy {
margin: 8px 0 16px;
color: #909399;
font-size: 13px;
}
.load-state.is-error > i {
color: #ef4444;
}
.sample-empty {
min-height: 210px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
background: #fafafa;
}
@media (max-width: 1100px) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-item:nth-child(2) {
border-right: 0;
}
.overview-item:nth-child(-n + 2) {
border-bottom: 1px solid #e4e7ed;
}
.dimension-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dimension-item:nth-child(2) {
border-right: 0;
}
.dimension-item:nth-child(-n + 2) {
border-bottom: 1px solid #ebeef5;
}
.sample-toolbar {
align-items: stretch;
flex-direction: column;
}
.sample-filters {
flex: 1;
justify-content: flex-start;
}
.sample-detail-grid {
padding: 16px 24px;
}
}
@media (max-width: 720px) {
.task-meta {
flex-direction: column;
gap: 5px;
}
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-item {
padding: 14px;
}
.review-copy,
.dimension-summary,
.sample-section {
padding: 16px;
}
.dimension-grid,
.improvement-list,
.sample-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.dimension-item,
.dimension-item:nth-child(2) {
border-right: 0;
border-bottom: 1px solid #ebeef5;
}
.dimension-item:last-child {
border-bottom: 0;
}
.sample-filters {
flex-direction: column;
align-items: stretch;
}
.sample-filters :deep(.el-input),
.sample-filters :deep(.el-select) {
width: 100%;
}
.evaluation-reason {
grid-column: auto;
}
}
</style>

View File

@@ -6,19 +6,14 @@ import DataTablePage from '@/components/DataTablePage.vue'
import { import {
getEvalList, getEvalList,
deleteEval, deleteEval,
getDimensionList,
deleteDimension,
} from '@/api/modules/eval' } from '@/api/modules/eval'
import { DIMENSION_TYPE_MAP } from '@/constants' import type { EvalTask } from '@/types'
import type { EvalTask, Dimension } from '@/types'
const router = useRouter() const router = useRouter()
const activeTab = ref('tasks') const activeTab = ref('tasks')
const evalLoading = ref(false) const evalLoading = ref(false)
const evalList = ref<EvalTask[]>([]) const evalList = ref<EvalTask[]>([])
const dimLoading = ref(false)
const dimensionList = ref<Dimension[]>([])
// 排行榜(原项目为 mock 数据) // 排行榜(原项目为 mock 数据)
const leaderboard = ref([ const leaderboard = ref([
@@ -38,17 +33,6 @@ async function loadEvalList() {
} }
} }
async function loadDimensions() {
dimLoading.value = true
try {
dimensionList.value = (await getDimensionList()) || []
} catch {
dimensionList.value = []
} finally {
dimLoading.value = false
}
}
async function handleDeleteEval(row: any) { async function handleDeleteEval(row: any) {
await ElMessageBox.confirm('确定要删除这个评测任务吗?', '确认删除', { type: 'warning' }) await ElMessageBox.confirm('确定要删除这个评测任务吗?', '确认删除', { type: 'warning' })
await deleteEval(row.id) await deleteEval(row.id)
@@ -56,36 +40,20 @@ async function handleDeleteEval(row: any) {
loadEvalList() loadEvalList()
} }
async function handleDeleteDimension(row: any) {
await ElMessageBox.confirm('确定要删除这个评测维度吗?', '确认删除', { type: 'warning' })
await deleteDimension(row.id)
ElMessage.success('删除成功')
loadDimensions()
}
function editDimension(row: any) {
router.push(`/model-eval/dimension/${row.id}/edit`)
}
function handleCreateClick() { function handleCreateClick() {
if (activeTab.value === 'tasks') { router.push('/model-eval/create')
router.push('/model-eval/create')
} else if (activeTab.value === 'dimensions') {
router.push('/model-eval/dimension/create')
}
} }
function handleRefresh() { function isActiveTab(tab: string) {
if (activeTab.value === 'tasks') { return activeTab.value === tab
loadEvalList() }
} else if (activeTab.value === 'dimensions') {
loadDimensions() function handleViewDetail(row: any) {
} router.push({ name: 'model-eval-detail', params: { id: row.id } })
} }
onMounted(() => { onMounted(() => {
loadEvalList() loadEvalList()
loadDimensions()
}) })
</script> </script>
@@ -105,19 +73,8 @@ onMounted(() => {
> >
<template #title> <template #title>
<div class="capsule-tabs"> <div class="capsule-tabs">
<button class="capsule-tab-item" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">评测任务</button> <button class="capsule-tab-item" :class="{ active: isActiveTab('tasks') }" @click="activeTab = 'tasks'">评测任务</button>
<button class="capsule-tab-item" :class="{ active: activeTab === 'leaderboard' }" @click="activeTab = 'leaderboard'">排行榜</button> <button class="capsule-tab-item" :class="{ active: isActiveTab('leaderboard') }" @click="activeTab = 'leaderboard'">排行榜</button>
<button class="capsule-tab-item" :class="{ active: activeTab === 'dimensions' }" @click="activeTab = 'dimensions'">评测维度</button>
</div>
</template>
<template #toolbar-extra>
<div class="eval-header-actions">
<el-link class="action-guide" :underlined="false">
<i class="fa fa-file-text-o" style="margin-right: 4px;" />使用指南
</el-link>
<button class="action-refresh" @click="handleRefresh" title="刷新">
<i class="fa fa-refresh" />
</button>
</div> </div>
</template> </template>
<template #columns> <template #columns>
@@ -134,7 +91,12 @@ onMounted(() => {
</el-table-column> </el-table-column>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }">
<el-button link type="danger" size="small" @click="handleDeleteEval(row)">删除</el-button> <el-button link type="primary" size="small" @click="handleViewDetail(row)">
<i class="fa fa-file-text-o" style="margin-right: 4px" />详情
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteEval(row)">
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
</el-button>
</template> </template>
</DataTablePage> </DataTablePage>
@@ -147,19 +109,8 @@ onMounted(() => {
> >
<template #title> <template #title>
<div class="capsule-tabs"> <div class="capsule-tabs">
<button class="capsule-tab-item" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">评测任务</button> <button class="capsule-tab-item" :class="{ active: isActiveTab('tasks') }" @click="activeTab = 'tasks'">评测任务</button>
<button class="capsule-tab-item" :class="{ active: activeTab === 'leaderboard' }" @click="activeTab = 'leaderboard'">排行榜</button> <button class="capsule-tab-item" :class="{ active: isActiveTab('leaderboard') }" @click="activeTab = 'leaderboard'">排行榜</button>
<button class="capsule-tab-item" :class="{ active: activeTab === 'dimensions' }" @click="activeTab = 'dimensions'">评测维度</button>
</div>
</template>
<template #toolbar-extra>
<div class="eval-header-actions">
<el-link class="action-guide" :underlined="false">
<i class="fa fa-file-text-o" style="margin-right: 4px;" />使用指南
</el-link>
<button class="action-refresh" @click="handleRefresh" title="刷新">
<i class="fa fa-refresh" />
</button>
</div> </div>
</template> </template>
<template #columns> <template #columns>
@@ -169,55 +120,6 @@ onMounted(() => {
</template> </template>
</DataTablePage> </DataTablePage>
<!-- 评测维度 -->
<DataTablePage
v-if="activeTab === 'dimensions'"
title=""
:data="dimensionList"
:loading="dimLoading"
:delete-fn="handleDeleteDimension"
create-text="添加维度"
@create="handleCreateClick"
row-key="id"
@refresh="loadDimensions"
>
<template #title>
<div class="capsule-tabs">
<button class="capsule-tab-item" :class="{ active: activeTab === 'tasks' }" @click="activeTab = 'tasks'">评测任务</button>
<button class="capsule-tab-item" :class="{ active: activeTab === 'leaderboard' }" @click="activeTab = 'leaderboard'">排行榜</button>
<button class="capsule-tab-item" :class="{ active: activeTab === 'dimensions' }" @click="activeTab = 'dimensions'">评测维度</button>
</div>
</template>
<template #toolbar-extra>
<div class="eval-header-actions">
<el-link class="action-guide" :underlined="false">
<i class="fa fa-file-text-o" style="margin-right: 4px;" />使用指南
</el-link>
<button class="action-refresh" @click="handleRefresh" title="刷新">
<i class="fa fa-refresh" />
</button>
</div>
</template>
<template #columns>
<el-table-column label="维度名称" prop="name" align="center" />
<el-table-column label="类型" align="center" width="160">
<template #default="{ row }">
{{ DIMENSION_TYPE_MAP[row.type] || row.type }}
</template>
</el-table-column>
<el-table-column label="描述" prop="description" show-overflow-tooltip align="center" />
<el-table-column label="状态" align="center" width="100">
<template #default="{ row }">
<el-tag v-if="row.is_active" type="success" size="small">启用</el-tag>
<el-tag v-else type="info" size="small">禁用</el-tag>
</template>
</el-table-column>
</template>
<template #actions="{ row }">
<el-button link type="primary" size="small" @click="editDimension(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteDimension(row)">删除</el-button>
</template>
</DataTablePage>
</div> </div>
</template> </template>
@@ -264,44 +166,4 @@ onMounted(() => {
} }
} }
.eval-header-actions {
display: flex;
align-items: center;
gap: 16px;
.action-guide {
font-size: 13px;
color: #64748b;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #1e293b;
}
}
.action-refresh {
border: 0;
background: transparent;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #64748b;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s, color 0.2s;
outline: none;
&:hover {
background: #f1f5f9;
color: #1e293b;
}
}
}
</style> </style>

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension'
import { DIMENSION_TYPE_MAP } from '@/constants'
import type { DimensionType, ModelItem } from '@/types'
export interface DimensionFormDraft {
type: DimensionType | ''
description: string
eval_model: string
eval_method: string | string[]
eval_prompt: string
is_active: boolean
is_default: boolean
bleu_n: number
output_precision: number
score_min: number
score_max: number
pass_threshold: number
}
const props = withDefaults(
defineProps<{
evalModels: ModelItem[]
showDescription?: boolean
showStatusSettings?: boolean
allowedTypes?: DimensionType[]
}>(),
{
showDescription: true,
showStatusSettings: true,
allowedTypes: () => ['classification', 'metric', 'text_similarity'],
},
)
const form = defineModel<DimensionFormDraft>({ required: true })
const SCORE_STEP = 0.5
const typeOptions = computed(() =>
Object.entries(DIMENSION_TYPE_MAP)
.filter(([value]) => props.allowedTypes.includes(value as DimensionType))
.map(([value, label]) => ({ value, label })),
)
const currentMethods = computed(() => (form.value.type ? EVAL_METHODS[form.value.type] || [] : []))
/**
* 指标类型变化时初始化对应的评估方式和 Prompt。
* 同步执行可保证编辑页批量回填时,接口返回值最终覆盖默认值。
*/
watch(
() => form.value.type,
(type) => {
if (!type) return
const firstMethod = EVAL_METHODS[type]?.[0]?.value || ''
form.value.eval_method = type === 'text_similarity' ? [firstMethod] : firstMethod
if (type !== 'text_similarity' && EVAL_METHOD_PROMPTS[firstMethod]) {
form.value.eval_prompt = EVAL_METHOD_PROMPTS[firstMethod]
}
},
{ flush: 'sync' },
)
/** 评估方式变化时应用对应的预置 Prompt。 */
watch(
() => form.value.eval_method,
(method) => {
if (form.value.type === 'text_similarity' || !method) return
const selectedMethod = Array.isArray(method) ? method[0] : method
if (selectedMethod && EVAL_METHOD_PROMPTS[selectedMethod]) {
form.value.eval_prompt = EVAL_METHOD_PROMPTS[selectedMethod]
}
},
{ flush: 'sync' },
)
/** 始终保持评分区间合法,并把通过阈值收敛到当前区间内。 */
watch(
() => [form.value.type, form.value.score_min, form.value.score_max] as const,
([type, scoreMin, scoreMax]) => {
if (type !== 'metric') return
if (scoreMax <= scoreMin) {
form.value.score_max = scoreMin + SCORE_STEP
return
}
form.value.pass_threshold = Math.min(
Math.max(form.value.pass_threshold, scoreMin),
scoreMax,
)
},
{ flush: 'sync' },
)
</script>
<template>
<el-form-item label="指标类型" prop="type">
<el-select v-model="form.type" placeholder="请选择指标类型" style="width: 100%">
<el-option v-for="option in typeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item v-if="showDescription" label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" maxlength="200" show-word-limit />
</el-form-item>
<template v-if="form.type === 'classification' || form.type === 'metric'">
<el-form-item label="选择大模型" prop="eval_model">
<el-select v-model="form.eval_model" placeholder="请选择评测模型" filterable style="width: 100%">
<el-option v-for="model in evalModels" :key="model.id" :label="model.name" :value="model.name" />
</el-select>
</el-form-item>
<el-form-item label="评估方式" prop="eval_method">
<el-radio-group v-model="(form.eval_method as string)">
<el-radio v-for="method in currentMethods" :key="method.value" :value="method.value">
{{ method.name }}
<span class="method-desc">{{ method.desc }}</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="评估 Prompt" prop="eval_prompt">
<MdEditor
v-model="form.eval_prompt"
language="zh-CN"
:toolbars-exclude="['github', 'save']"
style="height: 320px"
/>
</el-form-item>
</template>
<template v-if="form.type === 'text_similarity'">
<el-form-item label="评估方式" prop="eval_method">
<el-checkbox-group v-model="(form.eval_method as string[])">
<el-checkbox v-for="method in currentMethods" :key="method.value" :value="method.value">
{{ method.name }}
<span class="method-desc">{{ method.desc }}</span>
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="BLEU n-gram">
<el-select v-model="form.bleu_n" style="width: 160px">
<el-option :value="1" label="1-gram" />
<el-option :value="2" label="2-gram" />
<el-option :value="3" label="3-gram" />
<el-option :value="4" label="4-gram" />
</el-select>
</el-form-item>
<el-form-item label="输出精度">
<el-select v-model="form.output_precision" style="width: 160px">
<el-option :value="1" label="1 位小数" />
<el-option :value="2" label="2 位小数" />
<el-option :value="3" label="3 位小数" />
<el-option :value="4" label="4 位小数" />
</el-select>
</el-form-item>
</template>
<template v-if="form.type === 'metric'">
<el-form-item label="评分最小值" prop="score_min">
<el-input-number
v-model="form.score_min"
:min="0"
:max="Math.max(form.score_max - SCORE_STEP, 0)"
:step="SCORE_STEP"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="评分最大值" prop="score_max">
<el-input-number
v-model="form.score_max"
:min="form.score_min + SCORE_STEP"
:step="SCORE_STEP"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="通过阈值" prop="pass_threshold">
<div style="width: 100%">
<el-slider
v-model="form.pass_threshold"
:min="form.score_min"
:max="form.score_max"
:step="0.5"
show-input
:show-input-controls="false"
/>
</div>
</el-form-item>
</template>
<template v-if="showStatusSettings">
<el-form-item label="启用该维度">
<el-switch v-model="form.is_active" />
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="form.is_default" />
</el-form-item>
</template>
</template>
<style scoped>
.method-desc {
color: #909399;
font-size: 12px;
margin-left: 6px;
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import DimensionFormFields, { type DimensionFormDraft } from './DimensionFormFields.vue'
import type { DimensionType, ModelItem } from '@/types'
export type EvalRuleSetupDraft = DimensionFormDraft
defineProps<{
evalModels: ModelItem[]
disabled?: boolean
}>()
const form = defineModel<EvalRuleSetupDraft>({ required: true })
const newFormRef = ref<FormInstance>()
const LLM_METRIC_TYPES: DimensionType[] = ['classification', 'metric']
const dimensionRules: FormRules<DimensionFormDraft> = {
type: [{ required: true, message: '请选择大模型评测指标类型', trigger: 'change' }],
eval_model: [{ required: true, message: '请选择评测大模型', trigger: 'change' }],
eval_method: [{ required: true, message: '请选择评估方式', trigger: 'change' }],
eval_prompt: [{ required: true, message: '请填写评估 Prompt', trigger: 'blur' }],
}
async function validate() {
if (!newFormRef.value) return false
try {
return !!(await newFormRef.value.validate())
} catch {
return false
}
}
defineExpose({ validate })
</script>
<template>
<div class="rule-step">
<el-form
ref="newFormRef"
:model="form"
:rules="dimensionRules"
label-width="130px"
class="step-form step-form-wide"
:disabled="disabled"
scroll-to-error
>
<DimensionFormFields
v-model="form"
:eval-models="evalModels"
:show-description="false"
:show-status-settings="false"
:allowed-types="LLM_METRIC_TYPES"
/>
</el-form>
</div>
</template>
<style scoped>
.step-form {
max-width: 760px;
}
.step-form-wide {
max-width: 920px;
}
</style>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { DatasetItem, GpuInfo, TrainedModel } from '@/types'
export interface EvalTaskSetupDraft {
eval_task_name: string
model_id: string | number
gpu_id: string | number
data_source: 'dataset' | 'inference'
dataset_id: string | number
leaderboard: boolean
}
defineProps<{
trainedModels: TrainedModel[]
evalDatasets: DatasetItem[]
gpus: GpuInfo[]
loading?: boolean
disabled?: boolean
}>()
const form = defineModel<EvalTaskSetupDraft>({ required: true })
const formRef = ref<FormInstance>()
const rules: FormRules<EvalTaskSetupDraft> = {
eval_task_name: [
{ required: true, message: '请输入任务名称', trigger: 'blur' },
{ max: 50, message: '任务名称不超过 50 个字符', trigger: 'blur' },
],
model_id: [{ required: true, message: '请选择评测模型', trigger: 'change' }],
gpu_id: [{ required: true, message: '请选择 GPU', trigger: 'change' }],
dataset_id: [
{
validator: (_rule, value, callback) => {
if (form.value.data_source === 'dataset' && (value === '' || value === null || value === undefined)) {
callback(new Error('请选择评测数据集'))
return
}
callback()
},
trigger: 'change',
},
],
}
watch(
() => form.value.data_source,
(source) => {
if (source !== 'dataset') {
form.value.dataset_id = ''
formRef.value?.clearValidate('dataset_id')
}
},
)
async function validate() {
if (!formRef.value) return false
try {
const valid = await formRef.value.validate()
return !!valid
} catch {
return false
}
}
defineExpose({ validate })
</script>
<template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
class="step-form"
:disabled="disabled"
scroll-to-error
>
<el-form-item label="任务名称" prop="eval_task_name">
<el-input v-model="form.eval_task_name" placeholder="请输入任务名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="评测模型" prop="model_id">
<el-select
v-model="form.model_id"
placeholder="请选择已训练且已合并的模型"
filterable
style="width: 100%"
:loading="loading"
>
<el-option
v-for="model in trainedModels"
:key="model.id"
:label="model.name"
:value="model.id"
:disabled="!model.merged"
/>
</el-select>
<div class="field-tip">未完成合并的模型不可用于评测</div>
</el-form-item>
<el-form-item label="选择 GPU" prop="gpu_id">
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%" :loading="loading">
<el-option
v-for="(gpu, index) in gpus"
:key="index"
:label="`${gpu.name} (GPU ${index})`"
:value="index"
/>
</el-select>
</el-form-item>
<el-form-item label="数据来源">
<el-radio-group v-model="form.data_source">
<el-radio value="dataset">评测数据集</el-radio>
<el-radio value="inference">推理结果集</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.data_source === 'dataset'" label="评测数据集" prop="dataset_id">
<el-select
v-model="form.dataset_id"
placeholder="请选择评测数据集"
filterable
style="width: 100%"
:loading="loading"
>
<el-option v-for="dataset in evalDatasets" :key="dataset.id" :label="dataset.name" :value="dataset.id" />
</el-select>
</el-form-item>
<el-alert
v-else
title="将使用该模型最近一次可用的推理结果集。"
type="info"
:closable="false"
show-icon
class="source-alert"
/>
<el-form-item label="加入排行榜">
<el-switch v-model="form.leaderboard" />
</el-form-item>
</el-form>
</template>
<style scoped>
.step-form {
max-width: 760px;
}
.field-tip {
color: #909399;
font-size: 12px;
line-height: 20px;
}
.source-alert {
margin: -4px 0 20px 120px;
max-width: 640px;
}
</style>

View File

@@ -13,7 +13,15 @@ import {
import { getModelList } from '@/api/modules/model' import { getModelList } from '@/api/modules/model'
import { getDatasetList } from '@/api/modules/dataset' import { getDatasetList } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system' import { getSystemInfo } from '@/api/modules/system'
import { TEMPLATE_GROUPS, LR_SCHEDULER_OPTIONS } from '@/constants' import { TEMPLATE_GROUPS, LR_SCHEDULER_OPTIONS, QUANTIZATION_BIT_OPTIONS, QUANT_METHOD_OPTIONS, GGUF_FORMAT_OPTIONS } from '@/constants'
import {
DEFAULT_TRAINING_PARAMS,
buildFineTuneCommand,
buildFineTunePayload,
createDefaultFineTuneForm,
toCreateFineTunePayload,
} from './fineTuneFormModel'
import type { FineTuneFormModel } from './fineTuneFormModel'
import type { ModelItem, DatasetItem, GpuInfo } from '@/types' import type { ModelItem, DatasetItem, GpuInfo } from '@/types'
const router = useRouter() const router = useRouter()
@@ -26,28 +34,7 @@ const gpus = ref<GpuInfo[]>([])
const selectedGpus = ref<number[]>([]) const selectedGpus = ref<number[]>([])
const modelDialogVisible = ref(false) const modelDialogVisible = ref(false)
const form = reactive({ const form = reactive(createDefaultFineTuneForm())
name: '',
description: '',
train_type: 'SFT' as 'SFT' | 'DPO' | 'CPT',
base_model: '' as string | number,
template: 'qwen',
train_method: 'lora' as 'lora' | 'full',
train_dataset_id: '' as string | number,
// 训练参数
batch_size: 1,
learning_rate: 0.0001,
n_epochs: 1,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 512,
warmup_ratio: 0.05,
weight_decay: 0.01,
// LoRA 参数
lora_alpha: 16, // 修复原项目 lora_alpha 默认值不一致 bug
lora_dropout: 0.1,
lora_rank: 8,
})
const rules: FormRules = { const rules: FormRules = {
name: [ name: [
@@ -70,32 +57,8 @@ const selectedModel = computed(() => models.value.find((model) => model.id === f
const modelDialogTitle = computed(() => selectedModel.value?.name || '') const modelDialogTitle = computed(() => selectedModel.value?.name || '')
/** 训练命令实时预览 */ /** 训练命令与提交载荷共用同一份表单模型。 */
const commandPreview = computed(() => { const commandPreview = computed(() => buildFineTuneCommand(form, selectedGpus.value))
const gpuIds = selectedGpus.value.length ? selectedGpus.value.join(',') : '0'
let cmd = `CUDA_VISIBLE_DEVICES=${gpuIds} llamafactory-cli train \\\n`
cmd += ` --stage ${form.train_type === 'DPO' ? 'dpo' : form.train_type === 'CPT' ? 'cpt' : 'sft'} \\\n`
cmd += ` --do_train \\\n`
cmd += ` --model_name_or_path <base_model_path> \\\n`
cmd += ` --dataset <dataset> \\\n`
cmd += ` --template ${form.template} \\\n`
cmd += ` --finetuning_type ${form.train_method} \\\n`
cmd += ` --output_dir ./saves/${form.name || 'output'} \\\n`
cmd += ` --per_device_train_batch_size ${form.batch_size} \\\n`
cmd += ` --learning_rate ${form.learning_rate} \\\n`
cmd += ` --num_train_epochs ${form.n_epochs} \\\n`
cmd += ` --save_steps ${form.save_steps} \\\n`
cmd += ` --lr_scheduler_type ${form.lr_scheduler_type} \\\n`
cmd += ` --cutoff_len ${form.max_length} \\\n`
cmd += ` --warmup_ratio ${form.warmup_ratio} \\\n`
cmd += ` --weight_decay ${form.weight_decay}`
if (showLoraParams.value) {
cmd += ` \\\n --lora_alpha ${form.lora_alpha}`
cmd += ` \\\n --lora_dropout ${form.lora_dropout}`
cmd += ` \\\n --lora_rank ${form.lora_rank}`
}
return cmd
})
/** GPU 多选切换 */ /** GPU 多选切换 */
function toggleGpu(index: number) { function toggleGpu(index: number) {
@@ -119,25 +82,26 @@ function handleModelConfirm(modelId: string | number) {
} }
function resetParams() { function resetParams() {
Object.assign(form, { Object.assign(form, DEFAULT_TRAINING_PARAMS)
batch_size: 1,
learning_rate: 0.0001,
n_epochs: 1,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 512,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_alpha: 16,
lora_dropout: 0.1,
lora_rank: 8,
})
} }
const isParamsExpanded = ref(false) const isParamsExpanded = ref(false)
interface ParameterDefinition {
key: keyof FineTuneFormModel
name: string
desc: string
hint: string
type: 'number' | 'select'
min?: number
max?: number
step?: number
precision?: number
options?: Array<{ label: string; value: string | number }>
}
const allParams = computed(() => { const allParams = computed(() => {
const params = [ const params: ParameterDefinition[] = [
{ key: 'batch_size', name: 'batch_size', desc: '批次大小,代表模型训练过程中,模型更新一次参数所需要的数据样本数。', hint: '[1, 64], step:1', type: 'number', min: 1, max: 64, step: 1 }, { key: 'batch_size', name: 'batch_size', desc: '批次大小,代表模型训练过程中,模型更新一次参数所需要的数据样本数。', hint: '[1, 64], step:1', type: 'number', min: 1, max: 64, step: 1 },
{ key: 'learning_rate', name: 'learning_rate', desc: '学习率,代表每次更新数据的增量参数权重比例。', hint: '[0.000001, 1]', type: 'number', min: 0.000001, max: 1, step: 0.00001, precision: 6 }, { key: 'learning_rate', name: 'learning_rate', desc: '学习率,代表每次更新数据的增量参数权重比例。', hint: '[0.000001, 1]', type: 'number', min: 0.000001, max: 1, step: 0.00001, precision: 6 },
{ key: 'n_epochs', name: 'n_epochs', desc: '循环次数,代表模型训练过程中模型学习数据集的次数,可理解为看几遍数据,一般建议的范围是 1-3 遍即可,可依据需求进行调整', hint: '[1, 100], step:1', type: 'number', min: 1, max: 100, step: 1 }, { key: 'n_epochs', name: 'n_epochs', desc: '循环次数,代表模型训练过程中模型学习数据集的次数,可理解为看几遍数据,一般建议的范围是 1-3 遍即可,可依据需求进行调整', hint: '[1, 100], step:1', type: 'number', min: 1, max: 100, step: 1 },
@@ -149,9 +113,9 @@ const allParams = computed(() => {
] ]
if (showLoraParams.value) { if (showLoraParams.value) {
params.push( params.push(
{ key: 'lora_alpha', name: 'lora_alpha', desc: 'LoRA 缩放系数。', hint: '16/32/64/128', type: 'select', options: [{label:'16',value:16},{label:'32',value:32},{label:'64',value:64},{label:'128',value:128}] }, { key: 'lora_alpha', name: 'lora_alpha', desc: 'LoRA 缩放系数。', hint: '16/32/64/128', type: 'select', options: [{ label: '16', value: 16 }, { label: '32', value: 32 }, { label: '64', value: 64 }, { label: '128', value: 128 }] },
{ key: 'lora_rank', name: 'lora_rank', desc: 'LoRA 秩大小,控制低秩矩阵的维度。', hint: '8/16/32/64', type: 'select', options: [{label:'8',value:8},{label:'16',value:16},{label:'32',value:32},{label:'64',value:64}] }, { key: 'lora_rank', name: 'lora_rank', desc: 'LoRA 秩大小,控制低秩矩阵的维度。', hint: '8/16/32/64', type: 'select', options: [{ label: '8', value: 8 }, { label: '16', value: 16 }, { label: '32', value: 32 }, { label: '64', value: 64 }] },
{ key: 'lora_dropout', name: 'lora_dropout', desc: 'LoRA 层的 dropout 比例。', hint: '[0, 1]', type: 'number', min: 0, max: 1, step: 0.05, precision: 2 } { key: 'lora_dropout', name: 'lora_dropout', desc: 'LoRA 层的 dropout 比例。', hint: '[0, 1]', type: 'number', min: 0, max: 1, step: 0.05, precision: 2 },
) )
} }
return params return params
@@ -161,6 +125,15 @@ const visibleParams = computed(() => {
return isParamsExpanded.value ? allParams.value : allParams.value.slice(0, 3) return isParamsExpanded.value ? allParams.value : allParams.value.slice(0, 3)
}) })
function parameterValue(key: keyof FineTuneFormModel) {
const value = form[key]
return typeof value === 'boolean' ? Number(value) : value
}
function updateParameterValue(key: keyof FineTuneFormModel, value: string | number | undefined) {
if (value !== undefined) Reflect.set(form, key, value)
}
async function loadModels() { async function loadModels() {
try { try {
models.value = (await getModelList()) || [] models.value = (await getModelList()) || []
@@ -198,74 +171,32 @@ async function handleSubmit() {
} }
submitting.value = true submitting.value = true
try { try {
// 任务名查重 let check: { exists: boolean }
const check = await checkFineTuneName(form.name).catch(() => ({ exists: false })) try {
if ((check as any).exists) { check = await checkFineTuneName(form.name)
} catch {
ElMessage.error('任务名校验失败,请稍后重试')
return
}
if (check.exists) {
ElMessage.error('任务名称已存在,请更换') ElMessage.error('任务名称已存在,请更换')
submitting.value = false
return return
} }
// 第一步:创建任务记录 const payload = buildFineTunePayload(form, selectedGpus.value)
const taskData = { const createRes = await createFineTune(toCreateFineTunePayload(payload))
name: form.name, const taskId = createRes.id
description: form.description,
base_model: form.base_model,
template: form.template,
train_type: form.train_type,
train_method: form.train_method,
gpus: selectedGpus.value,
train_dataset_id: form.train_dataset_id,
output_model_name: form.name,
batch_size: form.batch_size,
learning_rate: form.learning_rate,
n_epochs: form.n_epochs,
save_steps: form.save_steps,
lr_scheduler_type: form.lr_scheduler_type,
max_length: form.max_length,
warmup_ratio: form.warmup_ratio,
weight_decay: form.weight_decay,
lora_alpha: form.lora_alpha,
lora_dropout: form.lora_dropout,
lora_rank: form.lora_rank,
status: 'pending',
progress: 0,
}
const createRes: any = await createFineTune(taskData)
const taskId = createRes?.id || createRes
// 第二步:启动训练
try { try {
await startFineTune({ await startFineTune({ ...payload, task_id: taskId })
task_id: taskId,
name: form.name,
base_model: form.base_model,
template: form.template,
train_type: form.train_type,
train_method: form.train_method,
train_dataset_id: form.train_dataset_id,
output_model_name: form.name,
gpus: selectedGpus.value,
batch_size: form.batch_size,
learning_rate: form.learning_rate,
n_epochs: form.n_epochs,
save_steps: form.save_steps,
lr_scheduler_type: form.lr_scheduler_type,
max_length: form.max_length,
warmup_ratio: form.warmup_ratio,
weight_decay: form.weight_decay,
lora_alpha: form.lora_alpha,
lora_dropout: form.lora_dropout,
lora_rank: form.lora_rank,
})
ElMessage.success('训练任务已创建并启动') ElMessage.success('训练任务已创建并启动')
} catch (e) { } catch {
// 启动失败,回写状态
await updateFineTune(taskId, { status: 'failed' }) await updateFineTune(taskId, { status: 'failed' })
ElMessage.error('任务已创建,但训练启动失败')
} }
router.push('/fine-tune') router.push('/fine-tune')
} catch { } catch {
// ignore ElMessage.error('训练任务创建失败,请稍后重试')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -386,15 +317,17 @@ onMounted(() => {
<div class="param-col config"> <div class="param-col config">
<el-input-number <el-input-number
v-if="param.type === 'number'" v-if="param.type === 'number'"
v-model="(form as any)[param.key]" :model-value="Number(parameterValue(param.key))"
:min="param.min" :max="param.max" :step="param.step" :precision="param.precision" :min="param.min" :max="param.max" :step="param.step" :precision="param.precision"
controls-position="right" controls-position="right"
style="width: 200px" style="width: 200px"
@update:model-value="updateParameterValue(param.key, $event)"
/> />
<el-select <el-select
v-else-if="param.type === 'select'" v-else-if="param.type === 'select'"
v-model="(form as any)[param.key]" :model-value="parameterValue(param.key)"
style="width: 200px" style="width: 200px"
@update:model-value="updateParameterValue(param.key, $event)"
> >
<el-option v-for="o in param.options" :key="o.value" :label="o.label" :value="o.value" /> <el-option v-for="o in param.options" :key="o.value" :label="o.label" :value="o.value" />
</el-select> </el-select>
@@ -424,6 +357,84 @@ onMounted(() => {
</el-select> </el-select>
</el-form-item> </el-form-item>
<template v-if="form.train_type === 'SFT'">
<el-divider content-position="left">合并模型</el-divider>
<el-form-item label="自动合并权重并保存">
<el-select v-model="form.auto_merge" style="width: 420px;">
<el-option label="否" :value="false" />
<el-option label="是" :value="true" />
</el-select>
</el-form-item>
</template>
<!-- 模型量化 -->
<el-divider content-position="left">模型量化</el-divider>
<!-- 训练时量化QLoRA LoRA 训练时可用 -->
<el-form-item v-if="form.train_method === 'lora'" label="训练时量化">
<el-select v-model="form.quantization_bit" style="width: 420px;">
<el-option
v-for="opt in QUANTIZATION_BIT_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<!-- 训练后导出量化模型 -->
<el-form-item label="导出量化模型">
<el-select v-model="form.export_quantized" style="width: 420px;">
<el-option label="否" :value="false" />
<el-option label="是" :value="true" />
</el-select>
</el-form-item>
<template v-if="form.export_quantized">
<el-form-item label="量化方法">
<el-select v-model="form.quant_method" style="width: 420px;">
<el-option
v-for="opt in QUANT_METHOD_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="量化位数">
<el-input-number
v-model="form.quant_bits"
:min="form.quant_method === 'bnb' ? 4 : 2"
:max="form.quant_method === 'bnb' ? 8 : 16"
:step="1"
controls-position="right"
style="width: 200px"
/>
</el-form-item>
<el-form-item v-if="form.quant_method === 'gptq' || form.quant_method === 'awq'" label="分组大小">
<el-input-number
v-model="form.quant_group_size"
:min="32"
:max="1024"
:step="32"
controls-position="right"
style="width: 200px"
/>
</el-form-item>
<el-form-item v-if="form.quant_method === 'gguf'" label="导出格式">
<el-select v-model="form.export_format" style="width: 420px;">
<el-option
v-for="opt in GGUF_FORMAT_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</template>
<!-- 训练命令预览 --> <!-- 训练命令预览 -->
<el-divider content-position="left">训练命令预览</el-divider> <el-divider content-position="left">训练命令预览</el-divider>
<div class="command-preview-wrapper"> <div class="command-preview-wrapper">
@@ -593,6 +604,13 @@ onMounted(() => {
margin-bottom: 22px; margin-bottom: 22px;
} }
.field-tip {
color: #909399;
font-size: 12px;
line-height: 20px;
margin-top: 4px;
}
.form-actions-wrapper { .form-actions-wrapper {
position: fixed; position: fixed;
bottom: 0; bottom: 0;

View File

@@ -37,7 +37,7 @@ const filteredList = computed(() => {
if (filters.value.trainType.length && !filters.value.trainType.includes(row.train_type)) { if (filters.value.trainType.length && !filters.value.trainType.includes(row.train_type)) {
return false return false
} }
if (filters.value.trainMethod.length && !filters.value.trainMethod.includes(row.train_method)) { if (filters.value.trainMethod.length && (!row.train_method || !filters.value.trainMethod.includes(row.train_method))) {
return false return false
} }
return true return true
@@ -126,6 +126,7 @@ onUnmounted(() => {
@refresh="loadData" @refresh="loadData"
> >
<template #columns> <template #columns>
<el-table-column label="任务ID" prop="id" align="center" width="100" />
<el-table-column <el-table-column
label="任务名称" label="任务名称"
prop="name" prop="name"

View File

@@ -0,0 +1,146 @@
import type { FineTuneStartPayload, FineTuneTask } from '@/types'
export type FineTuneFormModel = {
name: string
description: string
train_type: 'SFT' | 'DPO' | 'CPT'
base_model: string | number
template: string
train_method: 'lora' | 'full'
train_dataset_id: string | number
auto_merge: boolean
batch_size: number
learning_rate: number
n_epochs: number
save_steps: number
lr_scheduler_type: string
max_length: number
warmup_ratio: number
weight_decay: number
lora_alpha: number
lora_dropout: number
lora_rank: number
quantization_bit: number
export_quantized: boolean
quant_method: string
quant_bits: number
quant_group_size: number
export_format: string
}
export const DEFAULT_TRAINING_PARAMS = {
batch_size: 1,
learning_rate: 0.0001,
n_epochs: 1,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 512,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_alpha: 16,
lora_dropout: 0.1,
lora_rank: 8,
quantization_bit: 0,
export_quantized: false,
quant_method: 'bnb',
quant_bits: 4,
quant_group_size: 128,
export_format: 'Q4_K_M',
} as const
export function createDefaultFineTuneForm(): FineTuneFormModel {
return {
name: '',
description: '',
train_type: 'SFT',
base_model: '',
template: 'qwen',
train_method: 'lora',
train_dataset_id: '',
auto_merge: false,
...DEFAULT_TRAINING_PARAMS,
}
}
export function buildFineTunePayload(
form: FineTuneFormModel,
gpus: number[],
): Omit<FineTuneStartPayload, 'task_id'> {
return {
name: form.name,
description: form.description,
base_model: form.base_model,
template: form.template,
train_type: form.train_type,
train_method: form.train_method,
gpus: [...gpus],
train_dataset_id: form.train_dataset_id,
auto_merge: form.train_type === 'SFT' && form.auto_merge,
output_model_name: form.name,
batch_size: form.batch_size,
learning_rate: form.learning_rate,
n_epochs: form.n_epochs,
save_steps: form.save_steps,
lr_scheduler_type: form.lr_scheduler_type,
max_length: form.max_length,
warmup_ratio: form.warmup_ratio,
weight_decay: form.weight_decay,
lora_alpha: form.lora_alpha,
lora_dropout: form.lora_dropout,
lora_rank: form.lora_rank,
quantization_bit: form.train_method === 'lora' ? form.quantization_bit : 0,
export_quantized: form.export_quantized,
quant_method: form.export_quantized ? form.quant_method : '',
quant_bits: form.export_quantized ? form.quant_bits : 0,
quant_group_size: form.export_quantized ? form.quant_group_size : 0,
export_format: form.export_quantized && form.quant_method === 'gguf' ? form.export_format : '',
}
}
export function buildFineTuneCommand(form: FineTuneFormModel, gpus: number[]) {
const gpuIds = gpus.length ? gpus.join(',') : '0'
const stage = form.train_type === 'DPO' ? 'dpo' : form.train_type === 'CPT' ? 'cpt' : 'sft'
const lines = [
`CUDA_VISIBLE_DEVICES=${gpuIds} llamafactory-cli train`,
` --stage ${stage}`,
' --do_train',
' --model_name_or_path <base_model_path>',
' --dataset <dataset>',
` --template ${form.template}`,
` --finetuning_type ${form.train_method}`,
` --output_dir ./saves/${form.name || 'output'}`,
` --per_device_train_batch_size ${form.batch_size}`,
` --learning_rate ${form.learning_rate}`,
` --num_train_epochs ${form.n_epochs}`,
` --save_steps ${form.save_steps}`,
` --lr_scheduler_type ${form.lr_scheduler_type}`,
` --cutoff_len ${form.max_length}`,
` --warmup_ratio ${form.warmup_ratio}`,
` --weight_decay ${form.weight_decay}`,
]
if (form.train_method === 'lora') {
lines.push(
` --lora_alpha ${form.lora_alpha}`,
` --lora_dropout ${form.lora_dropout}`,
` --lora_rank ${form.lora_rank}`,
)
if (form.quantization_bit) lines.push(` --quantization_bit ${form.quantization_bit}`)
}
if (form.export_quantized) {
lines.push(` # 训练后导出量化模型:${form.quant_method} ${form.quant_bits}bit`)
if (form.quant_method === 'gguf') lines.push(` # export_format=${form.export_format}`)
if (form.quant_method === 'gptq' || form.quant_method === 'awq') {
lines.push(` # group_size=${form.quant_group_size}`)
}
}
return lines.join(' \\\n')
}
export function toCreateFineTunePayload(
payload: Omit<FineTuneStartPayload, 'task_id'>,
): Partial<FineTuneTask> {
return { ...payload, status: 'pending', progress: 0 }
}

View File

@@ -30,6 +30,9 @@ const task = ref<CompareTask | null>(null)
const messages = ref<ChatMessage[]>([]) const messages = ref<ChatMessage[]>([])
const inputQuestion = ref('') const inputQuestion = ref('')
const systemPrompt = ref('') const systemPrompt = ref('')
const temperature = ref(0.7)
const top_p = ref(0.95)
const maxTokens = ref(2048)
const contentRef = ref<HTMLElement>() const contentRef = ref<HTMLElement>()
/** 设置面板抽屉 */ /** 设置面板抽屉 */
const showSettings = ref(false) const showSettings = ref(false)
@@ -104,8 +107,9 @@ async function handleSend() {
model_path: '', model_path: '',
system_prompt: systemPrompt.value, system_prompt: systemPrompt.value,
user_question: question, user_question: question,
temperature: 0.7, temperature: temperature.value,
max_tokens: 2048, top_p: top_p.value,
max_tokens: maxTokens.value,
}) })
// 完成后同步最终内容 // 完成后同步最终内容
@@ -288,8 +292,21 @@ onMounted(loadTask)
resize="none" resize="none"
/> />
</el-form-item> </el-form-item>
<el-form-item label="Temperature (温度)">
<el-slider v-model="temperature" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="Top P (核心采样)">
<el-slider v-model="top_p" :min="0" :max="1" :step="0.05" show-input />
</el-form-item>
<el-form-item label="Max Tokens (最大生成长度)">
<el-slider v-model="maxTokens" :min="128" :max="8192" :step="128" show-input />
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="danger" plain @click="handleNewChat" style="width: 100%"> <el-button type="danger" plain @click="handleNewChat" style="width: 100%; margin-top: 20px;">
<i class="fa fa-trash-o" style="margin-right: 4px" />清空当前对话 <i class="fa fa-trash-o" style="margin-right: 4px" />清空当前对话
</el-button> </el-button>
</el-form-item> </el-form-item>

View File

@@ -28,7 +28,7 @@ async function handleLogin() {
try { try {
await auth.login(loginForm.username, loginForm.password) await auth.login(loginForm.username, loginForm.password)
ElMessage.success('登录成功') ElMessage.success('登录成功')
router.push('/fine-tune') router.push('/dashboard')
} catch { } catch {
// 拦截器已提示错误 // 拦截器已提示错误
} finally { } finally {
@@ -40,225 +40,308 @@ async function handleLogin() {
<template> <template>
<div class="login-page"> <div class="login-page">
<div class="login-card"> <section class="login-visual" aria-labelledby="platform-title">
<!-- LOGO 和标题 --> <div class="login-visual-content">
<div class="login-header"> <div class="login-visual-copy">
<img src="/logo.png" alt="Logo" class="login-logo" /> <h1 id="platform-title">远光软件微调平台</h1>
<h1 class="login-title">远光软件微调平</h1> <p>大模型微调评测与推理的一体化工作</p>
<p class="login-subtitle">模型管理平台</p>
</div>
<!-- 登录表单 -->
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="rules"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入账号/手机号/邮箱"
clearable
>
<template #prefix><i class="fa fa-user-o" /></template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
>
<template #prefix><i class="fa fa-lock-o" /></template>
</el-input>
</el-form-item>
<div class="login-options">
<el-checkbox>记住密码</el-checkbox>
<el-link type="primary" :underline="false">忘记密码</el-link>
</div> </div>
<el-button <div class="login-visual-footer" aria-label="平台核心能力">
type="primary" <span>数据准备</span>
class="login-btn" <span aria-hidden="true">·</span>
:loading="loading" <span>模型训练</span>
@click="handleLogin" <span aria-hidden="true">·</span>
<span>效果评测</span>
</div>
</div>
</section>
<main class="login-panel">
<div class="brand-lockup" role="img" aria-label="远光软件">
<span class="brand-logo-crop brand-logo-crop-mark" aria-hidden="true">
<img src="/logo.png" alt="" />
</span>
<span class="brand-logo-crop brand-logo-crop-wordmark" aria-hidden="true">
<img src="/logo.png" alt="" />
</span>
</div>
<div class="login-form-wrap">
<div class="login-heading">
<span>账号登录</span>
<h2>欢迎回来</h2>
<p>登录后继续使用微调平台</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="rules"
label-position="top"
size="large"
@keyup.enter="handleLogin"
> >
<i class="fa fa-sign-in login-btn-icon" /> <el-form-item label="账号" prop="username">
<el-input
</el-button> v-model="loginForm.username"
</el-form> placeholder="请输入账号"
</div> clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<div class="login-options">
<el-checkbox>记住密码</el-checkbox>
<el-link type="primary" :underline="false">忘记密码</el-link>
</div>
<el-button
type="primary"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</div>
<footer>© 2026 远光软件</footer>
</main>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.login-page { .login-page {
min-height: 100vh; height: 100vh;
display: flex; height: 100dvh;
align-items: center; display: grid;
justify-content: center; grid-template-columns: minmax(0, 58fr) minmax(500px, 42fr);
padding: 16px; overflow: hidden;
background-color: #f8fafc; background: #fcfcfe;
/* Beautiful light mesh gradient for AI platform */ }
background-image:
radial-gradient(at 10% 20%, rgba(99, 102, 241, 0.15) 0px, transparent 50%), .login-visual {
radial-gradient(at 80% 0%, rgba(236, 72, 153, 0.15) 0px, transparent 50%), min-height: 0;
radial-gradient(at 40% 60%, rgba(56, 189, 248, 0.15) 0px, transparent 50%), height: 100%;
radial-gradient(at 90% 80%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
background-size: 100% 100%;
position: relative; position: relative;
color: #fff;
background-color: #121127;
background-image: url('@/assets/login-hero-flow.png');
background-size: cover;
background-position: center;
overflow: hidden; overflow: hidden;
} }
/* Add some dynamic floating orbs for extra premium feel */ .login-visual::after {
.login-page::before,
.login-page::after {
content: ''; content: '';
position: absolute; position: absolute;
border-radius: 50%; inset: 0;
filter: blur(60px); background: linear-gradient(180deg, rgba(12, 12, 35, 0.2), transparent 36%, rgba(12, 12, 35, 0.2));
z-index: 0; pointer-events: none;
animation: float 20s infinite ease-in-out alternate;
} }
.login-page::before { .login-visual-content {
width: 400px; min-height: 0;
height: 400px; height: 100%;
background: rgba(99, 102, 241, 0.1);
top: -100px;
left: -100px;
}
.login-page::after {
width: 500px;
height: 500px;
background: rgba(236, 72, 153, 0.08);
bottom: -150px;
right: -150px;
animation-delay: -10s;
}
@keyframes float {
0% { transform: translate(0, 0) rotate(0deg); }
100% { transform: translate(50px, 50px) rotate(10deg); }
}
.login-card {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 100%; display: flex;
max-width: 440px; flex-direction: column;
/* Glassmorphism */ padding: clamp(80px, 10vh, 132px) clamp(56px, 6vw, 112px) clamp(56px, 7vh, 84px);
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
padding: 48px 40px;
box-shadow:
0 20px 40px -10px rgba(0, 0, 0, 0.05),
0 1px 3px rgba(0, 0, 0, 0.02),
inset 0 1px 0 rgba(255, 255, 255, 1);
/* Entrance animation */
transform: translateY(30px);
opacity: 0;
animation: slideUpFade 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
} }
@keyframes slideUpFade { .login-visual-copy {
to { max-width: 680px;
transform: translateY(0); margin-top: clamp(80px, 11vh, 136px);
opacity: 1;
}
}
.login-header { h1 {
text-align: center;
margin-bottom: 40px;
.login-logo {
width: 56px;
height: 56px;
object-fit: contain;
margin-bottom: 20px;
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.05));
}
.login-title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.5px;
margin: 0 0 8px;
/* Gradient text */
background: linear-gradient(135deg, #0f172a 0%, #334155 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-subtitle {
font-size: 15px;
color: #64748b;
font-weight: 500;
margin: 0; margin: 0;
font-size: clamp(38px, 3.8vw, 62px);
line-height: 1.2;
letter-spacing: -0.035em;
font-weight: 700;
text-wrap: balance;
}
p {
margin: 22px 0 0;
color: rgba(226, 232, 240, 0.76);
font-size: clamp(17px, 1.5vw, 24px);
line-height: 1.6;
letter-spacing: 0.01em;
}
}
.login-visual-footer {
width: min(100%, 640px);
margin-top: auto;
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
align-items: center;
gap: 24px;
color: rgba(226, 232, 240, 0.72);
font-size: 16px;
letter-spacing: 0.04em;
span:nth-child(3),
span:nth-child(5) {
text-align: center;
}
span:nth-child(even) {
color: rgba(199, 210, 254, 0.8);
}
}
.login-panel {
min-height: 0;
height: 100%;
position: relative;
display: grid;
place-items: center;
padding: 120px clamp(48px, 5vw, 88px) 96px;
border-left: 1px solid #e8eaf1;
background: #fcfcfe;
}
.brand-lockup {
position: absolute;
top: 42px;
right: clamp(36px, 4vw, 64px);
display: flex;
align-items: center;
gap: 9px;
}
.brand-logo-crop {
position: relative;
display: block;
overflow: hidden;
img {
position: absolute;
left: 0;
width: 100%;
max-width: none;
}
}
.brand-logo-crop-mark {
width: 40px;
height: 38px;
img {
top: 0;
}
}
.brand-logo-crop-wordmark {
width: 96px;
height: 25px;
img {
bottom: 0;
}
}
.login-form-wrap {
width: min(100%, 480px);
}
.login-heading {
margin-bottom: 42px;
> span {
display: block;
margin-bottom: 24px;
color: #4f46e5;
font-size: 16px;
line-height: 1;
font-weight: 600;
}
h2 {
margin: 0;
color: #17182c;
font-size: clamp(38px, 3vw, 48px);
line-height: 1.15;
letter-spacing: -0.035em;
font-weight: 700;
}
p {
margin: 14px 0 0;
color: #8a93a6;
font-size: 16px;
line-height: 1.6;
} }
} }
/* Custom Input Styling */
:deep(.el-form-item) { :deep(.el-form-item) {
margin-bottom: 24px; margin-bottom: 26px;
}
:deep(.el-form-item__label) {
height: auto;
margin-bottom: 10px;
padding: 0;
color: #272a3a;
font-size: 15px;
line-height: 1.4;
font-weight: 600;
} }
:deep(.el-input__wrapper) { :deep(.el-input__wrapper) {
background-color: rgba(255, 255, 255, 0.6) !important; min-height: 54px;
border-radius: 12px !important; padding: 0 18px;
box-shadow: 0 0 0 1px rgba(203, 213, 225, 0.5) inset !important; border-radius: 8px;
padding: 0 16px; background: #fff;
transition: all 0.3s ease; box-shadow: 0 0 0 1px #cfd4df inset;
transition: box-shadow 160ms ease, background-color 160ms ease;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.9) !important; box-shadow: 0 0 0 1px #aeb6c6 inset;
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.6) inset !important;
} }
&.is-focus { &.is-focus {
background-color: #ffffff !important; box-shadow: 0 0 0 1px #4f46e5 inset, 0 0 0 3px rgba(79, 70, 229, 0.12);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2) inset, 0 0 0 1px #4f46e5 inset !important;
} }
} }
:deep(.el-input__inner) { :deep(.el-input__inner) {
height: 48px;
font-size: 15px; font-size: 15px;
color: #1e293b; color: #1e293b;
&::placeholder {
color: #94a3b8;
}
}
:deep(.el-input__prefix) { &::placeholder {
color: #94a3b8; color: #a4acba;
font-size: 16px; }
margin-right: 8px;
} }
.login-options { .login-options {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 28px; margin: -2px 0 30px;
margin-top: -8px;
:deep(.el-checkbox__label) { :deep(.el-checkbox__label) {
color: #64748b; color: #556070;
font-size: 14px;
} }
:deep(.el-link) { :deep(.el-link) {
font-weight: 500; font-weight: 500;
color: #4f46e5; color: #4f46e5;
&:hover { &:hover {
color: #4338ca; color: #4338ca;
} }
@@ -267,30 +350,227 @@ async function handleLogin() {
.login-btn { .login-btn {
width: 100%; width: 100%;
height: 48px; height: 54px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-radius: 12px; border-radius: 8px;
border: none; border: none;
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
color: #fff; color: #fff;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); box-shadow: 0 10px 22px rgba(79, 70, 229, 0.18);
transition: all 0.3s ease; transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
.login-btn-icon {
margin-right: 8px;
font-size: 18px;
}
&:hover { &:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.4); box-shadow: 0 14px 26px rgba(79, 70, 229, 0.24);
background: linear-gradient(135deg, #4338ca 0%, #4f46e5 100%); background: linear-gradient(135deg, #4338ca 0%, #4f46e5 100%);
} }
&:active { &:active {
transform: translateY(1px); transform: translateY(0);
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.3); box-shadow: 0 6px 14px rgba(79, 70, 229, 0.2);
}
}
.login-panel footer {
position: absolute;
right: clamp(36px, 4vw, 64px);
bottom: 34px;
color: #9aa2b2;
font-size: 13px;
}
@media (max-width: 1440px) {
.login-page {
grid-template-columns: minmax(0, 55fr) minmax(500px, 45fr);
}
.login-visual-content {
padding-inline: clamp(52px, 5vw, 72px);
}
.login-visual-copy {
max-width: 580px;
h1 {
font-size: clamp(42px, 3.6vw, 52px);
}
p {
font-size: clamp(16px, 1.45vw, 20px);
}
}
.login-panel {
padding-inline: clamp(44px, 4.2vw, 64px);
}
.login-form-wrap {
max-width: 440px;
}
}
@media (max-width: 1180px) {
.login-page {
grid-template-columns: minmax(0, 52fr) minmax(470px, 48fr);
}
.login-visual-content {
padding-inline: 48px;
}
.login-visual-copy h1 {
font-size: 42px;
}
.login-visual-footer {
gap: 14px;
font-size: 14px;
}
}
@media (max-height: 820px) and (min-width: 901px) {
.login-visual {
background-position: center 54%;
}
.login-visual-content {
padding-top: 48px;
padding-bottom: 38px;
}
.login-visual-copy {
max-width: 560px;
margin-top: clamp(52px, 9vh, 72px);
h1 {
font-size: clamp(38px, 3.4vw, 46px);
}
p {
margin-top: 14px;
font-size: clamp(15px, 1.35vw, 18px);
}
}
.login-visual-footer {
gap: 14px;
font-size: 14px;
}
.login-panel {
padding-top: 78px;
padding-bottom: 56px;
}
.brand-lockup {
top: 24px;
}
.login-heading {
margin-bottom: 26px;
> span {
margin-bottom: 16px;
font-size: 15px;
}
h2 {
font-size: 38px;
}
p {
margin-top: 8px;
font-size: 15px;
}
}
:deep(.el-form-item) {
margin-bottom: 18px;
}
:deep(.el-form-item__label) {
margin-bottom: 7px;
font-size: 14px;
}
:deep(.el-input__wrapper) {
min-height: 48px;
}
.login-options {
margin-bottom: 20px;
}
.login-btn {
height: 50px;
}
.login-panel footer {
bottom: 18px;
}
}
@media (max-width: 900px) {
.login-page {
display: block;
height: auto;
min-height: 100vh;
min-height: 100dvh;
overflow: auto;
}
.login-visual {
display: none;
}
.login-panel {
height: auto;
min-height: 100vh;
min-height: 100dvh;
padding: 116px 32px 84px;
border-left: 0;
}
.login-panel footer {
right: 50%;
transform: translateX(50%);
white-space: nowrap;
}
}
@media (max-width: 560px) {
.login-panel {
display: flex;
align-items: center;
padding: 100px 24px 72px;
}
.brand-lockup {
top: 28px;
right: 24px;
transform: scale(0.88);
transform-origin: top right;
}
.login-heading {
margin-bottom: 32px;
> span {
margin-bottom: 18px;
}
h2 {
font-size: 36px;
}
}
:deep(.el-form-item) {
margin-bottom: 22px;
}
.login-options {
margin-bottom: 26px;
} }
} }
</style> </style>

View File

@@ -59,11 +59,7 @@ async function handleDeleteConfig(row: any) {
ElMessage.success('删除成功') ElMessage.success('删除成功')
} }
/** 删除已训练模型(合并权重) */
async function handleDeleteTrained(row: any) {
await deleteTrainedModel(row.id || row.name, 'merged')
ElMessage.success('删除成功')
}
/** 删除 LoRA 权重 */ /** 删除 LoRA 权重 */
async function handleDeleteWeight(row: any) { async function handleDeleteWeight(row: any) {
@@ -171,8 +167,12 @@ onMounted(loadData)
</el-table-column> </el-table-column>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }">
<el-button type="primary" link size="small" @click="editModel(row)">编辑</el-button> <el-button type="primary" link size="small" @click="editModel(row)">
<el-button type="danger" link size="small" @click="handleDeleteConfig(row)">删除</el-button> <i class="fa fa-edit" style="margin-right: 4px" />编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDeleteConfig(row)">
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
</el-button>
</template> </template>
</DataTablePage> </DataTablePage>
@@ -184,7 +184,6 @@ onMounted(loadData)
:loading="loading" :loading="loading"
searchable searchable
:search-fields="['name']" :search-fields="['name']"
:delete-fn="handleDeleteTrained"
row-key="name" row-key="name"
@refresh="loadTrained" @refresh="loadTrained"
> >
@@ -250,15 +249,7 @@ onMounted(loadData)
> >
<i class="fa fa-download" style="margin-right: 4px" />导出 <i class="fa fa-download" style="margin-right: 4px" />导出
</el-button> </el-button>
<el-button
v-if="row.merged"
type="danger"
link
size="small"
@click="handleDeleteTrained(row)"
>
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
</el-button>
</div> </div>
</template> </template>
</DataTablePage> </DataTablePage>

View File

@@ -1,283 +1,591 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import PageCard from '@/components/PageCard.vue' import PageCard from '@/components/PageCard.vue'
import { getSystemInfo } from '@/api/modules/system' import { getSystemInfo } from '@/api/modules/system'
import type { SystemInfo } from '@/types' import type { GpuInfo, SystemInfo } from '@/types'
const refreshInterval = ref(5000) interface Snapshot {
let timer: ReturnType<typeof setInterval> | null = null time: string
cpu: number
memory: number
disk: number
gpuAverage: number
gpu: Array<{ usage: number; memory: number; temperature: number }>
}
const info = ref<SystemInfo>({}) const info = ref<SystemInfo>({})
const loading = ref(true)
const refreshing = ref(false)
const loadError = ref('')
const lastUpdated = ref<Date | null>(null)
const autoRefresh = ref(true)
const refreshInterval = ref(5000)
const snapshots = ref<Snapshot[]>([])
const drawerVisible = ref(false)
const selectedGpuIndex = ref(0)
async function fetchInfo() { let timer: ReturnType<typeof setTimeout> | null = null
try { let disposed = false
info.value = await getSystemInfo() let requesting = false
} catch {
// ignore const gpus = computed(() => info.value.gpu ?? [])
const selectedGpu = computed(() => gpus.value[selectedGpuIndex.value])
const gpuAverage = computed(() => {
if (!gpus.value.length) return 0
return Math.round(gpus.value.reduce((sum, gpu) => sum + safePercent(gpu.gpu_percent), 0) / gpus.value.length)
})
const gpuMemoryTotal = computed(() =>
gpus.value.reduce((sum, gpu) => sum + Number(gpu.memory_total_gb || 0), 0),
)
const gpuMemoryUsed = computed(() =>
gpus.value.reduce((sum, gpu) => sum + Number(gpu.memory_used_gb || 0), 0),
)
const busyGpuCount = computed(() =>
gpus.value.filter((gpu) => gpuStatus(gpu).key === 'busy').length,
)
const systemHealth = computed(() => {
const hasOffline = gpus.value.some((gpu) => gpuStatus(gpu).key === 'offline')
const hasWarning =
safePercent(info.value.cpu?.percent) >= 90 ||
safePercent(info.value.memory?.percent) >= 90 ||
safePercent(info.value.disk?.percent) >= 90 ||
gpus.value.some((gpu) => gpuStatus(gpu).key === 'warning')
if (hasOffline) return { label: '存在离线设备', className: 'is-danger' }
if (hasWarning) return { label: '资源需要关注', className: 'is-warning' }
return { label: '平台运行正常', className: 'is-healthy' }
})
const resourceChartOption = computed(() => ({
animationDuration: 300,
color: ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b'],
tooltip: { trigger: 'axis', valueFormatter: (value: number) => `${value}%` },
legend: { top: 0, right: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: '#606266' } },
grid: { left: 14, right: 14, top: 42, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: snapshots.value.map((item) => item.time),
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: { color: '#909399', hideOverlap: true },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%', color: '#909399' },
splitLine: { lineStyle: { color: '#ebeef5', type: 'dashed' } },
},
series: [
chartSeries('CPU', snapshots.value.map((item) => item.cpu)),
chartSeries('内存', snapshots.value.map((item) => item.memory)),
chartSeries('磁盘', snapshots.value.map((item) => item.disk)),
chartSeries('GPU 平均', snapshots.value.map((item) => item.gpuAverage)),
],
}))
const gpuChartOption = computed(() => {
const records = snapshots.value
.map((item) => ({ time: item.time, gpu: item.gpu[selectedGpuIndex.value] }))
.filter((item) => item.gpu)
return {
animationDuration: 300,
color: ['#4f46e5', '#0ea5e9', '#f59e0b'],
tooltip: { trigger: 'axis', valueFormatter: (value: number) => `${value}%` },
legend: { top: 0, right: 0, itemWidth: 10, itemHeight: 10 },
grid: { left: 12, right: 12, top: 40, bottom: 6, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: records.map((item) => item.time),
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: { color: '#909399', hideOverlap: true },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%', color: '#909399' },
splitLine: { lineStyle: { color: '#ebeef5', type: 'dashed' } },
},
series: [
chartSeries('利用率', records.map((item) => item.gpu.usage)),
chartSeries('显存', records.map((item) => item.gpu.memory)),
],
}
})
function chartSeries(name: string, data: number[]) {
return {
name,
type: 'line',
data,
showSymbol: false,
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.05 },
} }
} }
function startTimer() { function safePercent(value?: number) {
stopTimer() return Math.min(100, Math.max(0, Number(value || 0)))
timer = setInterval(fetchInfo, refreshInterval.value)
} }
function stopTimer() { function memoryPercent(gpu?: GpuInfo) {
if (!gpu) return 0
if (gpu.memory_percent != null) return safePercent(gpu.memory_percent)
if (!gpu.memory_total_gb) return 0
return Math.round((gpu.memory_used_gb / gpu.memory_total_gb) * 100)
}
function gpuStatus(gpu: GpuInfo) {
if (gpu.status === 'offline') return { key: 'offline', label: '离线', className: 'is-danger' }
if (gpu.status === 'warning' || gpu.temperature >= 80 || memoryPercent(gpu) >= 90) {
return { key: 'warning', label: '需关注', className: 'is-warning' }
}
if (gpu.status === 'busy' || gpu.gpu_percent >= 10) {
return { key: 'busy', label: '运行中', className: 'is-busy' }
}
return { key: 'idle', label: '空闲', className: 'is-idle' }
}
function progressColor(value: number) {
if (value >= 90) return '#f56c6c'
if (value >= 75) return '#e6a23c'
return '#4f46e5'
}
function recordSnapshot(data: SystemInfo) {
const gpuList = data.gpu ?? []
const average = gpuList.length
? Math.round(gpuList.reduce((sum, gpu) => sum + safePercent(gpu.gpu_percent), 0) / gpuList.length)
: 0
const now = new Date()
snapshots.value = [
...snapshots.value,
{
time: now.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }),
cpu: safePercent(data.cpu?.percent),
memory: safePercent(data.memory?.percent),
disk: safePercent(data.disk?.percent),
gpuAverage: average,
gpu: gpuList.map((gpu) => ({
usage: safePercent(gpu.gpu_percent),
memory: memoryPercent(gpu),
temperature: safePercent(gpu.temperature),
})),
},
].slice(-60)
}
async function fetchInfo() {
if (requesting || disposed) return
requesting = true
refreshing.value = true
clearTimer()
try {
const data = await getSystemInfo()
info.value = data
lastUpdated.value = new Date()
loadError.value = ''
recordSnapshot(data)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '监控数据更新失败,请稍后重试'
} finally {
loading.value = false
refreshing.value = false
requesting = false
scheduleNext()
}
}
function scheduleNext() {
clearTimer()
if (!disposed && autoRefresh.value && document.visibilityState === 'visible') {
timer = setTimeout(fetchInfo, refreshInterval.value)
}
}
function clearTimer() {
if (timer) { if (timer) {
clearInterval(timer) clearTimeout(timer)
timer = null timer = null
} }
} }
function changeInterval(val: number) { function changeInterval() {
refreshInterval.value = val scheduleNext()
startTimer()
} }
/** 温度颜色 */ function changeAutoRefresh() {
function tempColor(t?: number) { if (autoRefresh.value) scheduleNext()
if (t == null) return '' else clearTimer()
if (t >= 80) return '#f56c6c'
if (t >= 70) return '#e6a23c'
return '#303133'
} }
onMounted(() => { function handleVisibilityChange() {
fetchInfo() if (document.visibilityState === 'hidden') clearTimer()
startTimer() else if (autoRefresh.value) fetchInfo()
}
function openGpuDetail(index: number) {
selectedGpuIndex.value = index
drawerVisible.value = true
nextTick(() => document.querySelector<HTMLElement>('.gpu-detail-drawer .el-drawer__body')?.focus())
}
function handleGpuKeydown(event: KeyboardEvent, index: number) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openGpuDetail(index)
}
}
function formatNumber(value?: number, digits = 1) {
return Number(value || 0).toFixed(digits).replace(/\.0$/, '')
}
function formatRate(value?: number) {
return value == null ? '--' : formatNumber(value)
}
function formatUptime(seconds?: number) {
if (!seconds) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return `${days ? `${days}` : ''}${hours}小时 ${minutes}分钟`
}
function formatLastUpdated(date: Date | null) {
return date ? date.toLocaleTimeString('zh-CN', { hour12: false }) : '等待首次更新'
}
onMounted(fetchInfo)
onMounted(() => document.addEventListener('visibilitychange', handleVisibilityChange))
onUnmounted(() => {
disposed = true
clearTimer()
document.removeEventListener('visibilitychange', handleVisibilityChange)
}) })
onUnmounted(stopTimer)
</script> </script>
<template> <template>
<PageCard title="平台性能"> <PageCard title="平台性能" subtitle="实时监控计算、存储、网络与 GPU 资源池状态">
<template #extra> <template #extra>
<span style="font-size: 13px; color: #909399">刷新频率</span> <div class="monitor-actions">
<el-select v-model="refreshInterval" size="small" style="width: 90px" @change="changeInterval"> <div class="health-state" :class="systemHealth.className">
<el-option :value="1000" label="1秒" /> <span class="health-dot" aria-hidden="true" />
<el-option :value="3000" label="3秒" /> {{ systemHealth.label }}
<el-option :value="5000" label="5秒" /> </div>
</el-select> <span class="update-time">更新于 {{ formatLastUpdated(lastUpdated) }}</span>
<el-switch
v-model="autoRefresh"
inline-prompt
active-text="自动"
inactive-text="暂停"
aria-label="自动刷新"
@change="changeAutoRefresh"
/>
<el-select
v-model="refreshInterval"
class="interval-select"
size="small"
aria-label="刷新频率"
:disabled="!autoRefresh"
@change="changeInterval"
>
<el-option :value="1000" label="1 秒" />
<el-option :value="3000" label="3 秒" />
<el-option :value="5000" label="5 秒" />
<el-option :value="10000" label="10 秒" />
</el-select>
<el-button :loading="refreshing" aria-label="立即刷新平台性能数据" @click="fetchInfo">
<i class="fa fa-refresh" aria-hidden="true" />
刷新
</el-button>
</div>
</template> </template>
<!-- CPU / 内存 / 磁盘 --> <el-alert
<el-row :gutter="16" style="margin-bottom: 16px"> v-if="loadError"
<!-- CPU --> class="load-alert"
<el-col :span="8"> type="warning"
<el-card shadow="never"> :closable="false"
<div class="metric-header"> show-icon
<div> title="最新数据获取失败,当前仍展示上一次成功采集的数据"
<i class="fa fa-microchip metric-icon cpu-icon" /> :description="loadError"
<div class="metric-info"> />
<div class="metric-title">CPU 使用率</div>
<div class="metric-sub">{{ info.cpu?.cores || 0 }} 核心</div>
</div>
</div>
<div class="metric-value">{{ info.cpu?.percent || 0 }}%</div>
</div>
<el-progress :percentage="info.cpu?.percent || 0" :stroke-width="10" :show-text="false" color="#1890ff" />
</el-card>
</el-col>
<!-- 内存 --> <div v-loading="loading" class="performance-content">
<el-col :span="8"> <section class="overview-grid" aria-label="硬件资源概览">
<el-card shadow="never"> <article class="overview-card">
<div class="metric-header"> <div class="metric-heading">
<div> <span class="metric-icon is-cpu"><i class="fa fa-microchip" /></span>
<i class="fa fa-database metric-icon mem-icon" /> <div><h3>CPU</h3><p>{{ info.cpu?.model || `${info.cpu?.cores || 0} 个逻辑核心` }}</p></div>
<div class="metric-info">
<div class="metric-title">内存使用</div>
<div class="metric-sub">总计: {{ info.memory?.total_gb || 0 }} GB</div>
</div>
</div>
<div class="metric-value">{{ info.memory?.percent || 0 }}%</div>
</div> </div>
<el-progress :percentage="info.memory?.percent || 0" :stroke-width="10" :show-text="false" color="#e6a23c" /> <strong class="metric-number">{{ safePercent(info.cpu?.percent) }}<small>%</small></strong>
<div class="metric-detail"> <el-progress :percentage="safePercent(info.cpu?.percent)" :stroke-width="6" :show-text="false" :color="progressColor(safePercent(info.cpu?.percent))" />
<span>已用 {{ info.memory?.used_gb || 0 }} GB</span> </article>
<span>可用 {{ info.memory?.available_gb || 0 }} GB</span>
<span>缓存 {{ info.memory?.cached_gb || 0 }} GB</span>
</div>
</el-card>
</el-col>
<!-- 磁盘 --> <article class="overview-card">
<el-col :span="8"> <div class="metric-heading">
<el-card shadow="never"> <span class="metric-icon is-memory"><i class="fa fa-database" /></span>
<div class="metric-header"> <div><h3>内存</h3><p>{{ formatNumber(info.memory?.used_gb) }} / {{ formatNumber(info.memory?.total_gb) }} GB</p></div>
<div>
<i class="fa fa-hdd-o metric-icon disk-icon" />
<div class="metric-info">
<div class="metric-title">磁盘使用</div>
<div class="metric-sub">总计: {{ info.disk?.total_gb || 0 }} GB</div>
</div>
</div>
<div class="metric-value">{{ info.disk?.percent || 0 }}%</div>
</div> </div>
<el-progress :percentage="info.disk?.percent || 0" :stroke-width="10" :show-text="false" color="#67c23a" /> <strong class="metric-number">{{ safePercent(info.memory?.percent) }}<small>%</small></strong>
<div class="metric-detail"> <el-progress :percentage="safePercent(info.memory?.percent)" :stroke-width="6" :show-text="false" :color="progressColor(safePercent(info.memory?.percent))" />
<span>已用 {{ info.disk?.used_gb || 0 }} GB</span> </article>
<span>可用 {{ (info.disk?.total_gb || 0) - (info.disk?.used_gb || 0) }} GB</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- GPU --> <article class="overview-card">
<el-card shadow="never" style="margin-bottom: 16px"> <div class="metric-heading">
<template #header> <span class="metric-icon is-disk"><i class="fa fa-hdd-o" /></span>
<div class="section-header"> <div><h3>磁盘</h3><p>{{ formatNumber(info.disk?.used_gb) }} / {{ formatNumber(info.disk?.total_gb) }} GB</p></div>
<i class="fa fa-microchip" style="color: #f56c6c" /> </div>
<span>GPU 监控{{ info.gpu?.length || 0 }} </span> <strong class="metric-number">{{ safePercent(info.disk?.percent) }}<small>%</small></strong>
<el-progress :percentage="safePercent(info.disk?.percent)" :stroke-width="6" :show-text="false" :color="progressColor(safePercent(info.disk?.percent))" />
</article>
<article class="overview-card">
<div class="metric-heading">
<span class="metric-icon is-network"><i class="fa fa-exchange" /></span>
<div><h3>网络吞吐</h3><p>实时接收 / 发送</p></div>
</div>
<div class="network-values">
<strong><i class="fa fa-arrow-down" /> {{ formatRate(info.network?.download_mb_s) }} <small>MB/s</small></strong>
<strong><i class="fa fa-arrow-up" /> {{ formatRate(info.network?.upload_mb_s) }} <small>MB/s</small></strong>
</div>
</article>
</section>
<section class="panel resource-panel" aria-labelledby="resource-trend-title">
<div class="panel-heading">
<div><h2 id="resource-trend-title">系统资源趋势</h2><p>展示最近 60 次前端采集快照</p></div>
<span class="sample-count">{{ snapshots.length }} / 60 个采样点</span>
</div> </div>
</template> <VChart class="resource-chart" :option="resourceChartOption" autoresize aria-label="CPU内存磁盘和GPU平均利用率趋势图" />
<el-row :gutter="12"> </section>
<el-col v-for="(gpu, idx) in info.gpu" :key="idx" :span="6">
<el-card shadow="never" class="gpu-card">
<div class="gpu-name">{{ gpu.name }}</div>
<div class="gpu-usage">{{ gpu.gpu_percent }}%</div>
<el-progress :percentage="gpu.gpu_percent" :stroke-width="8" :show-text="false" />
<div class="gpu-meta">
<div>显存: {{ gpu.memory_used_gb }}/{{ gpu.memory_total_gb }} GB</div>
<div>温度: <span :style="{ color: tempColor(gpu.temperature) }">{{ gpu.temperature }}°C</span></div>
<div>功耗: {{ gpu.power_w }} W</div>
<div>风扇: {{ gpu.fan_speed || 0 }}%</div>
</div>
</el-card>
</el-col>
</el-row>
</el-card>
<!-- 系统信息 --> <section class="gpu-section" aria-labelledby="gpu-pool-title">
<el-row :gutter="16"> <div class="section-heading">
<el-col :span="12"> <div>
<el-card shadow="never"> <h2 id="gpu-pool-title">GPU 资源池</h2>
<template #header> <p> {{ gpus.length }} 张卡{{ busyGpuCount }} 张运行中 · 显存 {{ formatNumber(gpuMemoryUsed) }} / {{ formatNumber(gpuMemoryTotal) }} GB</p>
<div class="section-header">
<i class="fa fa-globe" style="color: #909399" />
<span>网络流量</span>
</div>
</template>
<div class="metric-detail">
<span>总流入: {{ info.network?.download_mb || 0 }} GB</span>
<span>总流出: {{ info.network?.upload_mb || 0 }} GB</span>
</div> </div>
</el-card> <div class="gpu-summary"><span>平均利用率</span><strong>{{ gpuAverage }}%</strong></div>
</el-col> </div>
<el-col :span="12">
<el-card shadow="never"> <el-empty v-if="!gpus.length && !loading" description="当前未检测到 GPU 设备" />
<template #header> <div v-else class="gpu-grid">
<div class="section-header"> <article
<i class="fa fa-info-circle" style="color: #909399" /> v-for="(gpu, index) in gpus"
<span>系统信息</span> :key="gpu.uuid || gpu.id || index"
class="gpu-card"
role="button"
tabindex="0"
:aria-label="`查看 GPU ${index} 详情`"
@click="openGpuDetail(index)"
@keydown="handleGpuKeydown($event, index)"
>
<div class="gpu-card-heading">
<div class="gpu-identity"><span class="gpu-index">GPU {{ index }}</span><h3>{{ gpu.name }}</h3></div>
<span class="status-pill" :class="gpuStatus(gpu).className"><i />{{ gpuStatus(gpu).label }}</span>
</div> </div>
</template> <div class="gpu-primary">
<div class="sys-info"> <strong>{{ safePercent(gpu.gpu_percent) }}<small>%</small></strong>
<div class="sys-row"><span>操作系统</span><span>{{ info.system?.os || '-' }}</span></div> <span>计算利用率</span>
<div class="sys-row"><span>进程数</span><span>{{ info.system?.process_count || 0 }}</span></div> </div>
</div> <el-progress :percentage="safePercent(gpu.gpu_percent)" :stroke-width="7" :show-text="false" :color="progressColor(safePercent(gpu.gpu_percent))" />
</el-card> <dl class="gpu-metrics">
</el-col> <div><dt>显存</dt><dd>{{ formatNumber(gpu.memory_used_gb) }} / {{ formatNumber(gpu.memory_total_gb) }} GB</dd></div>
</el-row> <div><dt>温度</dt><dd :class="{ 'is-hot': gpu.temperature >= 80, 'is-warm': gpu.temperature >= 70 && gpu.temperature < 80 }">{{ gpu.temperature }}°C</dd></div>
<div><dt>功耗</dt><dd>{{ formatNumber(gpu.power_w, 0) }} W</dd></div>
<div><dt>风扇</dt><dd>{{ gpu.fan_speed ?? '-' }}{{ gpu.fan_speed != null ? '%' : '' }}</dd></div>
</dl>
<div class="gpu-card-footer">查看设备详情 <i class="fa fa-angle-right" /></div>
</article>
</div>
</section>
<section class="panel host-panel" aria-labelledby="host-info-title">
<div class="panel-heading compact"><div><h2 id="host-info-title">主机信息</h2><p>节点运行环境与基础状态</p></div></div>
<dl class="host-info-grid">
<div><dt>操作系统</dt><dd>{{ info.system?.os || '-' }}</dd></div>
<div><dt>运行时长</dt><dd>{{ formatUptime(info.system?.uptime_seconds) }}</dd></div>
<div><dt>运行进程</dt><dd>{{ info.system?.process_count ?? '-' }}</dd></div>
<div><dt>GPU 驱动</dt><dd>{{ gpus[0]?.driver_version || '-' }}</dd></div>
</dl>
</section>
</div>
</PageCard> </PageCard>
<el-drawer v-model="drawerVisible" class="gpu-detail-drawer" size="min(720px, 92vw)" destroy-on-close>
<template #header>
<div v-if="selectedGpu" class="drawer-heading">
<span class="gpu-index">GPU {{ selectedGpuIndex }}</span>
<div><h2>{{ selectedGpu.name }}</h2><p>{{ selectedGpu.uuid || '设备详细运行信息' }}</p></div>
<span class="status-pill" :class="gpuStatus(selectedGpu).className"><i />{{ gpuStatus(selectedGpu).label }}</span>
</div>
</template>
<template v-if="selectedGpu">
<section class="drawer-metrics" aria-label="GPU 关键指标">
<div><span>计算利用率</span><strong>{{ safePercent(selectedGpu.gpu_percent) }}%</strong></div>
<div><span>显存占用</span><strong>{{ memoryPercent(selectedGpu) }}%</strong><small>{{ formatNumber(selectedGpu.memory_used_gb) }} / {{ formatNumber(selectedGpu.memory_total_gb) }} GB</small></div>
<div><span>温度</span><strong>{{ selectedGpu.temperature }}°C</strong></div>
<div><span>实时功耗</span><strong>{{ formatNumber(selectedGpu.power_w, 0) }} W</strong><small v-if="selectedGpu.power_limit_w">上限 {{ selectedGpu.power_limit_w }} W</small></div>
</section>
<section class="drawer-section" aria-labelledby="gpu-trend-title">
<div class="drawer-section-heading"><h3 id="gpu-trend-title">设备趋势</h3><span>最近 60 次采样</span></div>
<VChart class="gpu-detail-chart" :option="gpuChartOption" autoresize aria-label="单卡利用率和显存占用趋势图" />
</section>
<section class="drawer-section" aria-labelledby="gpu-process-title">
<div class="drawer-section-heading"><h3 id="gpu-process-title">GPU 进程</h3><span>{{ selectedGpu.processes?.length || 0 }} </span></div>
<el-table :data="selectedGpu.processes || []" stripe empty-text="当前没有占用此 GPU 的进程">
<el-table-column prop="pid" label="PID" width="90" />
<el-table-column prop="task_name" label="任务" min-width="130" show-overflow-tooltip />
<el-table-column prop="name" label="进程" min-width="130" show-overflow-tooltip />
<el-table-column prop="user" label="用户" width="100" />
<el-table-column label="显存" width="110" align="right">
<template #default="{ row }">{{ row.memory_used_gb != null ? `${formatNumber(row.memory_used_gb)} GB` : '-' }}</template>
</el-table-column>
</el-table>
</section>
<section class="drawer-section device-properties" aria-labelledby="gpu-property-title">
<div class="drawer-section-heading"><h3 id="gpu-property-title">设备属性</h3></div>
<dl>
<div><dt>风扇转速</dt><dd>{{ selectedGpu.fan_speed != null ? `${selectedGpu.fan_speed}%` : '-' }}</dd></div>
<div><dt>核心频率</dt><dd>{{ selectedGpu.clock_mhz ? `${selectedGpu.clock_mhz} MHz` : '-' }}</dd></div>
<div><dt>驱动版本</dt><dd>{{ selectedGpu.driver_version || '-' }}</dd></div>
<div><dt>设备标识</dt><dd>{{ selectedGpu.uuid || selectedGpu.id || `GPU-${selectedGpuIndex}` }}</dd></div>
</dl>
</section>
</template>
</el-drawer>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.metric-header { .monitor-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
display: flex; .health-state { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 500; color: #606266; }
align-items: center; .health-dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; box-shadow: 0 0 0 3px rgba(16, 185, 129, .12); }
justify-content: space-between; .health-state.is-warning .health-dot { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, .12); }
margin-bottom: 12px; .health-state.is-danger .health-dot { background: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, .12); }
.update-time { color: #909399; font-size: 12px; }
.interval-select { width: 78px; }
.load-alert { margin-bottom: 16px; }
.performance-content { min-height: 320px; }
.overview-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
.overview-card, .panel, .gpu-card { border: 1px solid #e4e7ed; border-radius: 8px; background: #fff; }
.overview-card { position: relative; padding: 18px; overflow: hidden; }
.metric-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.metric-heading h3, .metric-heading p { margin: 0; }
.metric-heading h3 { color: #303133; font-size: 14px; font-weight: 600; }
.metric-heading p { margin-top: 2px; color: #909399; font-size: 12px; }
.metric-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 7px; font-size: 15px; }
.metric-icon.is-cpu { color: #4f46e5; background: #eef2ff; }
.metric-icon.is-memory { color: #0ea5e9; background: #ecfeff; }
.metric-icon.is-disk { color: #10b981; background: #ecfdf5; }
.metric-icon.is-network { color: #f59e0b; background: #fffbeb; }
.metric-number { display: block; margin-bottom: 10px; color: #1f2937; font-size: 29px; line-height: 1; }
.metric-number small, .network-values small, .gpu-primary small { margin-left: 2px; color: #909399; font-size: 13px; font-weight: 500; }
.network-values { display: flex; flex-wrap: wrap; gap: 12px 18px; min-height: 38px; align-items: center; }
.network-values strong { color: #374151; font-size: 17px; }
.network-values i { margin-right: 3px; color: #10b981; font-size: 12px; }
.network-values strong:last-child i { color: #f59e0b; }
.panel { padding: 18px 20px; }
.resource-panel { margin-bottom: 24px; }
.panel-heading, .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.panel-heading h2, .panel-heading p, .section-heading h2, .section-heading p { margin: 0; }
.panel-heading h2, .section-heading h2 { color: #303133; font-size: 15px; font-weight: 600; }
.panel-heading p, .section-heading p { margin-top: 4px; color: #909399; font-size: 12px; }
.sample-count { color: #909399; font-size: 12px; }
.resource-chart { height: 260px; margin-top: 8px; }
.gpu-section { margin-bottom: 24px; }
.gpu-summary { min-width: 100px; text-align: right; }
.gpu-summary span { display: block; color: #909399; font-size: 12px; }
.gpu-summary strong { color: #303133; font-size: 22px; }
.gpu-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 14px; }
.gpu-card { padding: 16px; cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
.gpu-card:hover, .gpu-card:focus-visible { border-color: #a5b4fc; box-shadow: 0 5px 16px rgba(79, 70, 229, .08); outline: none; transform: translateY(-1px); }
.gpu-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.gpu-identity { min-width: 0; }
.gpu-index { display: inline-block; margin-bottom: 4px; padding: 2px 7px; border-radius: 4px; color: #4338ca; background: #eef2ff; font-size: 11px; font-weight: 600; }
.gpu-identity h3 { overflow: hidden; margin: 0; color: #303133; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { display: inline-flex; flex: none; align-items: center; gap: 5px; padding: 3px 7px; border-radius: 999px; color: #047857; background: #ecfdf5; font-size: 11px; }
.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-pill.is-busy { color: #4338ca; background: #eef2ff; }
.status-pill.is-warning { color: #b45309; background: #fffbeb; }
.status-pill.is-danger { color: #b91c1c; background: #fef2f2; }
.gpu-primary { display: flex; align-items: baseline; justify-content: space-between; margin: 17px 0 8px; }
.gpu-primary strong { color: #1f2937; font-size: 25px; }
.gpu-primary span { color: #909399; font-size: 11px; }
.gpu-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; margin: 15px 0 0; }
.gpu-metrics div { min-width: 0; }
.gpu-metrics dt { margin-bottom: 2px; color: #909399; font-size: 11px; }
.gpu-metrics dd { overflow: hidden; margin: 0; color: #606266; font-size: 12px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.gpu-metrics dd.is-warm { color: #b45309; }
.gpu-metrics dd.is-hot { color: #dc2626; }
.gpu-card-footer { margin: 14px -16px -16px; padding: 9px 16px; border-top: 1px solid #f0f2f5; color: #909399; font-size: 11px; text-align: right; }
.host-panel { padding-bottom: 4px; }
.host-info-grid { display: grid; grid-template-columns: 2fr 1.2fr 1fr 1fr; margin: 15px 0 0; }
.host-info-grid > div { min-width: 0; padding: 0 18px 14px; border-left: 1px solid #ebeef5; }
.host-info-grid > div:first-child { padding-left: 0; border-left: 0; }
.host-info-grid dt { margin-bottom: 5px; color: #909399; font-size: 12px; }
.host-info-grid dd { overflow: hidden; margin: 0; color: #303133; font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.drawer-heading { display: flex; width: 100%; align-items: center; gap: 12px; }
.drawer-heading .gpu-index { margin: 0; }
.drawer-heading div { min-width: 0; flex: 1; }
.drawer-heading h2, .drawer-heading p { margin: 0; }
.drawer-heading h2 { color: #303133; font-size: 16px; }
.drawer-heading p { overflow: hidden; margin-top: 3px; color: #909399; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.drawer-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.drawer-metrics > div { min-width: 0; padding: 13px; border: 1px solid #e4e7ed; border-radius: 7px; background: #fafafa; }
.drawer-metrics span, .drawer-metrics small { display: block; overflow: hidden; color: #909399; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.drawer-metrics strong { display: block; margin: 6px 0 2px; color: #303133; font-size: 20px; }
.drawer-section { margin-top: 22px; }
.drawer-section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.drawer-section-heading h3 { margin: 0; color: #303133; font-size: 14px; }
.drawer-section-heading span { color: #909399; font-size: 11px; }
.gpu-detail-chart { height: 230px; border: 1px solid #ebeef5; border-radius: 7px; }
.device-properties dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; border: 1px solid #ebeef5; border-radius: 7px; }
.device-properties dl > div { display: flex; min-width: 0; justify-content: space-between; gap: 14px; padding: 11px 13px; border-bottom: 1px solid #ebeef5; }
.device-properties dl > div:nth-last-child(-n + 2) { border-bottom: 0; }
.device-properties dt { color: #909399; font-size: 12px; }
.device-properties dd { overflow: hidden; margin: 0; color: #303133; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
> div:first-child { :deep(.gpu-detail-drawer .el-drawer__header) { margin-bottom: 0; padding-bottom: 17px; border-bottom: 1px solid #ebeef5; }
display: flex; :deep(.gpu-detail-drawer .el-drawer__body) { outline: none; }
align-items: center;
gap: 10px; @media (max-width: 1280px) {
} .overview-grid, .gpu-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
.metric-icon { @media (max-width: 860px) {
font-size: 20px; .monitor-actions { justify-content: flex-start; }
} .update-time { width: 100%; }
.cpu-icon { .host-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
color: #1890ff; .host-info-grid > div:nth-child(3) { padding-left: 0; border-left: 0; }
} .drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.mem-icon {
color: #e6a23c;
}
.disk-icon {
color: #67c23a;
} }
.metric-title { @media (max-width: 620px) {
font-size: 14px; .overview-grid, .gpu-grid { grid-template-columns: 1fr; }
font-weight: 500; .resource-chart { height: 230px; }
color: #303133; .section-heading { flex-direction: column; }
} .gpu-summary { text-align: left; }
.metric-sub { .host-info-grid, .device-properties dl { grid-template-columns: 1fr; }
font-size: 12px; .host-info-grid > div { padding-left: 0; border-left: 0; }
color: #909399; .device-properties dl > div { border-bottom: 1px solid #ebeef5 !important; }
} .device-properties dl > div:last-child { border-bottom: 0 !important; }
.metric-value {
font-size: 24px;
font-weight: 500;
color: #303133;
}
.metric-detail {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 13px;
color: #606266;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #303133;
}
.gpu-card {
.gpu-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gpu-usage {
font-size: 18px;
font-weight: 600;
color: #f56c6c;
margin-bottom: 8px;
}
.gpu-meta {
margin-top: 8px;
font-size: 11px;
color: #909399;
line-height: 1.8;
}
}
.sys-info {
.sys-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ebeef5;
font-size: 13px;
&:last-child {
border-bottom: none;
}
span:first-child {
color: #909399;
}
}
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import PageCard from '@/components/PageCard.vue'
import { DATASET_TYPE_MAP, STORAGE_MAP, TRAIN_METHOD_MAP, TRAIN_TYPE_MAP } from '@/constants'
import type { DatasetItem, FineTuneTask } from '@/types'
defineProps<{
task: FineTuneTask | null
dataset: DatasetItem | null
baseModelName: string
}>()
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<PageCard class="task-overview" title="任务信息" subtitle="模型、数据集与运行配置">
<div class="overview-layout" aria-label="训练任务信息">
<el-descriptions class="task-descriptions task-profile" :column="2" border>
<el-descriptions-item label="任务 ID">{{ task?.id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(task?.create_time) }}</el-descriptions-item>
<el-descriptions-item label="基座模型">{{ baseModelName }}</el-descriptions-item>
<el-descriptions-item label="输出模型">{{ task?.output_model_name || '暂未生成' }}</el-descriptions-item>
<el-descriptions-item label="训练方式">
{{ task?.train_type ? (TRAIN_TYPE_MAP[task.train_type] || task.train_type) : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="训练方法">
{{ task?.train_method ? (TRAIN_METHOD_MAP[task.train_method] || task.train_method) : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="训练数据集" class-name="dataset-profile">
{{ dataset?.name || (task?.train_dataset_id ? '正在加载' : '未配置') }}
</el-descriptions-item>
<el-descriptions-item label="数据类型">
{{ dataset ? (DATASET_TYPE_MAP[dataset.type] || dataset.type || '未配置') : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="数据条数">
{{ dataset?.count?.toLocaleString('zh-CN') ?? '未配置' }}{{ dataset?.count != null ? ' 条' : '' }}
</el-descriptions-item>
<el-descriptions-item label="数据大小">{{ dataset?.size || '未配置' }}</el-descriptions-item>
<el-descriptions-item label="训练开始时间" class-name="runtime-panel">
{{ formatDateTime(task?.create_time) }}
</el-descriptions-item>
<el-descriptions-item label="训练时长">{{ task?.train_duration || '未配置' }}</el-descriptions-item>
<el-descriptions-item label="存储位置">
{{ STORAGE_MAP[dataset?.storage_type || ''] || dataset?.storage_type || '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="使用 GPU">
{{ task?.gpus?.length ? task.gpus.join('、') : '未配置' }}
</el-descriptions-item>
</el-descriptions>
</div>
</PageCard>
</template>
<style scoped lang="scss">
.task-overview {
margin-bottom: 0;
border: 1px solid #e4e7ed !important;
border-radius: 8px !important;
box-shadow: none !important;
}
.overview-layout { width: 100%; }
.task-descriptions :deep(.el-descriptions__label) {
width: 132px;
color: #606266;
font-weight: 500;
background: #f7f8fa !important;
}
.task-descriptions :deep(.el-descriptions__content) {
color: #303133;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.task-descriptions :deep(.el-descriptions__cell) { padding: 12px 16px !important; }
@media (max-width: 700px) {
.task-descriptions :deep(.el-descriptions__body),
.task-descriptions :deep(.el-descriptions__table),
.task-descriptions :deep(.el-descriptions__tbody),
.task-descriptions :deep(.el-descriptions__row),
.task-descriptions :deep(.el-descriptions__cell) { display: block; width: 100%; box-sizing: border-box; }
.task-descriptions :deep(.el-descriptions__label) { width: 100%; border-bottom: 0 !important; }
}
</style>

View File

@@ -0,0 +1,165 @@
import type { EChartsOption } from 'echarts'
import type { FineTuneTask, TrainingLogFile } from '@/types'
export interface TrainingMetricData {
loss: number[]
gradNorm: number[]
lr: number[]
epoch: number[]
}
export interface TrainingSummary {
epoch: string
trainLoss: string
runtime: string
}
export interface ParsedTrainingLog {
metrics: TrainingMetricData
summary: TrainingSummary
}
const NUMBER_SOURCE = '[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?'
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function extractNumber(source: string, key: string) {
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*:\\s*(${NUMBER_SOURCE})`, 'i'))
return match ? Number(match[1]) : undefined
}
function extractSummaryValue(source: string, key: string) {
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*(?:=|:)\\s*(${NUMBER_SOURCE})`, 'i'))
return match?.[1] || ''
}
/** 根据任务精确选择日志PID 优先,任务名仅作为明确兜底。 */
export function resolveTrainingLogFile(
files: TrainingLogFile[],
task: Pick<FineTuneTask, 'process_id' | 'name'>,
) {
const processId = task.process_id
if (processId != null) {
const pidMatch = files.find((file) => file.pid === processId)
if (pidMatch) return pidMatch
const pidPattern = new RegExp(`(?:^|[^0-9])(?:pid)?${processId}(?:[^0-9]|$)`, 'i')
const filenameMatch = files.find((file) => pidPattern.test(file.file))
if (filenameMatch) return filenameMatch
}
const taskName = task.name.trim()
if (!taskName) return undefined
return files.find((file) => file.name.includes(taskName) || file.file.includes(taskName))
}
/** 解析日志中的逐步指标。字段顺序和常见数值格式均不受限制。 */
export function parseTrainingMetrics(text: string): TrainingMetricData {
const metrics: TrainingMetricData = { loss: [], gradNorm: [], lr: [], epoch: [] }
const blocks = text.match(/\{[^{}\r\n]*\}/g) || []
for (const block of blocks) {
const loss = extractNumber(block, 'loss')
const gradNorm = extractNumber(block, 'grad_norm')
const learningRate = extractNumber(block, 'learning_rate')
const epoch = extractNumber(block, 'epoch')
if (loss == null || gradNorm == null || learningRate == null) continue
metrics.loss.push(loss)
metrics.gradNorm.push(gradNorm)
metrics.lr.push(learningRate)
if (epoch != null) metrics.epoch.push(epoch)
}
return metrics
}
/** 每次都返回新对象,日志截断或切换时不会残留上一轮汇总。 */
export function parseTrainingSummary(text: string): TrainingSummary {
const emptySummary: TrainingSummary = { epoch: '', trainLoss: '', runtime: '' }
const startMatch = /\*{5}\s*train metrics\s*\*{5}/i.exec(text)
if (!startMatch) return emptySummary
const tail = text.slice(startMatch.index + startMatch[0].length)
const endMatch = /\*{5}\s*train metrics end\s*\*{5}/i.exec(tail)
const body = endMatch ? tail.slice(0, endMatch.index) : tail
return {
epoch: extractSummaryValue(body, 'epoch'),
trainLoss: extractSummaryValue(body, 'train_loss'),
runtime: extractSummaryValue(body, 'train_runtime'),
}
}
export function parseTrainingLog(text: string): ParsedTrainingLog {
return {
metrics: parseTrainingMetrics(text),
summary: parseTrainingSummary(text),
}
}
/** 构建单条训练指标曲线。 */
export function buildMetricChartOption(
label: string,
data: number[],
color: string,
logScale = false,
): EChartsOption {
return {
grid: { top: 24, right: 20, bottom: 56, left: 56 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderWidth: 0,
textStyle: { color: '#fff', fontSize: 12 },
},
xAxis: {
type: 'category',
boundaryGap: false,
name: 'Step',
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8', fontSize: 11 },
splitLine: { show: false },
},
yAxis: {
type: logScale ? 'log' : 'value',
name: label,
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#94a3b8', fontSize: 11 },
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
dataZoom: data.length > 30
? [
{ type: 'inside', start: 0, end: 100 },
{ type: 'slider', height: 16, bottom: 8, borderColor: 'transparent', fillerColor: 'rgba(79,70,229,0.08)', handleStyle: { color: '#4f46e5' } },
]
: [],
series: [
{
name: label,
type: 'line',
data,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}55` },
{ offset: 1, color: `${color}05` },
],
},
},
},
],
}
}

View File

@@ -16,7 +16,7 @@ function handleDefaultTool(id: string) {
if (id === 'data-generate') { if (id === 'data-generate') {
ElMessage.info('数据生成工具开发中...') ElMessage.info('数据生成工具开发中...')
} else if (id === 'json2jsonl') { } else if (id === 'json2jsonl') {
ElMessage.info('JSON转JSONL 工具开发中...') router.push('/data-convert')
} else if (id === 'md-convert') { } else if (id === 'md-convert') {
ElMessage.info('转换Markdown 工具开发中...') ElMessage.info('转换Markdown 工具开发中...')
} }