Compare commits
34 Commits
89881a4307
...
bff07b7a31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bff07b7a31 | ||
|
|
91a7c84d7c | ||
|
|
5c469a1778 | ||
|
|
b64d730616 | ||
|
|
0f8f41057a | ||
|
|
03d4c9fbd8 | ||
|
|
6b48b8fac3 | ||
|
|
0d82930181 | ||
|
|
4dde761348 | ||
|
|
08a567062d | ||
|
|
862a7972cf | ||
|
|
687831870c | ||
|
|
71ce26fff6 | ||
|
|
c0f5f4a30a | ||
|
|
fecaba040b | ||
|
|
10b06c749f | ||
|
|
c5221e1624 | ||
|
|
cc113db1aa | ||
|
|
ddf47eb8fe | ||
|
|
9e16f6358d | ||
|
|
771c258929 | ||
|
|
ba465b5016 | ||
|
|
ee50350dff | ||
|
|
a2d65a1123 | ||
|
|
1455ee7f97 | ||
|
|
bc3eddfe84 | ||
|
|
a438684575 | ||
|
|
57c3947615 | ||
|
|
73b2131dd4 | ||
|
|
f06b25c228 | ||
|
|
45b7206090 | ||
|
|
9bb26f3b24 | ||
|
|
79843a4b3b | ||
|
|
f5b1873ee5 |
167
design-qa.md
167
design-qa.md
@@ -158,3 +158,170 @@ 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
|
||||||
|
|||||||
@@ -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: 移除数据处理状态切换"
|
||||||
|
```
|
||||||
182
docs/superpowers/plans/2026-07-10-dataset-task-mock-data.md
Normal file
182
docs/superpowers/plans/2026-07-10-dataset-task-mock-data.md
Normal 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 数据"
|
||||||
|
```
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 数据处理任务状态切换移除设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
移除数据处理任务列表左上角的“全部任务”“处理中”“已完成”状态切换组件。
|
||||||
|
页面默认展示全部任务,用户仍可通过任务状态列查看每条任务的当前状态。
|
||||||
|
|
||||||
|
## 改动范围
|
||||||
|
|
||||||
|
- 删除 `DataProcessListView.vue` 中的状态切换按钮及标题插槽。
|
||||||
|
- 删除仅服务于该组件的 `activeTab` 状态和 `filteredDataList` 计算逻辑。
|
||||||
|
- 将任务表格的数据源直接绑定为完整的 `dataList`。
|
||||||
|
- 删除仅服务于状态切换组件的胶囊样式。
|
||||||
|
|
||||||
|
## 保留行为
|
||||||
|
|
||||||
|
- 保留任务名称搜索。
|
||||||
|
- 保留任务状态列及状态标签。
|
||||||
|
- 保留新建任务、详情、删除和分页等现有行为。
|
||||||
|
- 不修改任务 Mock 数据及后端接口设计。
|
||||||
|
|
||||||
|
## 验证标准
|
||||||
|
|
||||||
|
- 页面不再出现“全部任务”“处理中”“已完成”三个切换按钮。
|
||||||
|
- 表格默认展示所有状态的任务。
|
||||||
|
- 页面不存在遗留的状态切换变量、筛选分支或专用样式。
|
||||||
|
- 前端类型检查和相关回归检查通过。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 数据预览详情页头部操作移除设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
移除数据预览详情页右上角的“打包下载”“删除”“返回”三个按钮,
|
||||||
|
让该页面仅承担数据集文件与内容预览职责。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 删除 `DatasetPreviewView.vue` 的页头额外操作区域及其三个按钮。
|
||||||
|
- 删除仅被这三个按钮使用的整包下载、删除和路由返回逻辑及依赖。
|
||||||
|
- 保留文件列表中的单文件“预览”和“下载”操作。
|
||||||
|
- 保留数据集信息、文件列表与内容预览区域。
|
||||||
|
|
||||||
|
## 方案比较
|
||||||
|
|
||||||
|
1. 直接删除页面头部按钮和失效逻辑(采用):无隐藏交互与无用依赖,页面职责清晰。
|
||||||
|
2. 使用 CSS 隐藏按钮:按钮逻辑仍留在页面中,键盘和后续维护可能误暴露功能。
|
||||||
|
3. 增加运行时开关:为固定的展示要求引入无必要状态和分支。
|
||||||
|
|
||||||
|
## 实现设计
|
||||||
|
|
||||||
|
- 移除 `PageCard` 的 `extra` 插槽。
|
||||||
|
- 从组件中移除 `useRouter`、`ElMessage`、`ElMessageBox`、`deleteDataset`、
|
||||||
|
`downloadDatasetUrl` 及对应处理函数。
|
||||||
|
- 保留 `handleDownloadFile` 与 `downloadFileUrl`,确保文件级下载仍可用。
|
||||||
|
- 新增回归检查,断言详情页不再含这三个页头按钮或其对应处理函数,
|
||||||
|
并断言文件级预览、下载操作仍存在。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. 数据预览详情页不显示“打包下载”“删除”“返回”按钮。
|
||||||
|
2. 文件列表中的“预览”和“下载”按钮继续显示并可用。
|
||||||
|
3. 页面不保留已删除头部操作的事件处理函数与未使用依赖。
|
||||||
|
4. 针对性回归检查和生产构建通过。
|
||||||
@@ -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 |
BIN
docs/superpowers/specs/assets/service-dashboard-compact-1440.png
Normal file
BIN
docs/superpowers/specs/assets/service-dashboard-compact-1440.png
Normal file
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 |
@@ -8,8 +8,18 @@
|
|||||||
"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: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:training-log-layout": "node scripts/regression-training-log-layout.mjs",
|
"test:training-log-layout": "node scripts/regression-training-log-layout.mjs",
|
||||||
"test:page-surface": "node scripts/regression-page-surface.mjs"
|
"test:page-surface": "node scripts/regression-page-surface.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
54
frontend/scripts/regression-dashboard.mjs
Normal file
54
frontend/scripts/regression-dashboard.mjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 按需注册列表')
|
||||||
|
|
||||||
|
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*10px;/, '服务看板应使用紧凑的区块间距')
|
||||||
|
assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*1 1 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]*?flex:\s*1 1 auto;[\s\S]*?height:\s*auto;[\s\S]*?min-height:\s*230px;/, '训练统计图应随可用空间自动拉伸')
|
||||||
|
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.training-chart\s*\{[\s\S]*?min-height:\s*200px;/, '较矮桌面视口应保留图表最小可读高度')
|
||||||
|
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(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*hidden;/, '服务看板容器应避免产生页面纵向滚动条')
|
||||||
|
assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?overflow:\s*hidden;/, '服务看板页面画布应约束在可用视口内')
|
||||||
|
|
||||||
|
console.log('服务看板回归检查通过')
|
||||||
56
frontend/scripts/regression-data-convert.mjs
Normal file
56
frontend/scripts/regression-data-convert.mjs
Normal 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 原型回归检查通过')
|
||||||
55
frontend/scripts/regression-data-process-detail.mjs
Normal file
55
frontend/scripts/regression-data-process-detail.mjs
Normal 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 回归检查通过')
|
||||||
19
frontend/scripts/regression-data-process-list.mjs
Normal file
19
frontend/scripts/regression-data-process-list.mjs
Normal 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('数据处理任务列表状态切换移除回归检查通过')
|
||||||
@@ -4,18 +4,44 @@ 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')
|
||||||
|
|
||||||
|
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: '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(viewSource, /localStorage\.setItem\(DRAFT_STORAGE_KEY/, '草稿没有持久化')
|
||||||
assert.match(viewSource, /localStorage\.getItem\(DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
|
assert.match(viewSource, /localStorage\.getItem\(DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
|
||||||
@@ -23,6 +49,7 @@ assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
|
|||||||
|
|
||||||
const expectedComponents = [
|
const expectedComponents = [
|
||||||
'TaskSetupStep.vue',
|
'TaskSetupStep.vue',
|
||||||
|
'SourceUploadStep.vue',
|
||||||
'PreviewCompareStep.vue',
|
'PreviewCompareStep.vue',
|
||||||
'GenerationStep.vue',
|
'GenerationStep.vue',
|
||||||
'ResultEditorStep.vue',
|
'ResultEditorStep.vue',
|
||||||
@@ -47,14 +74,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' \| '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 +94,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 +122,397 @@ 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 taskSetupSource = await readFile(taskSetupPath, 'utf8')
|
||||||
|
const generationOptionsPath = path.join(createDir, 'GenerationOptionsPanel.vue')
|
||||||
|
const generationControlSource = await readFile(generationOptionsPath, 'utf8')
|
||||||
|
const sourceUploadPath = path.join(createDir, 'SourceUploadStep.vue')
|
||||||
|
const sourceUploadSource = await readFile(sourceUploadPath, 'utf8')
|
||||||
|
|
||||||
|
for (const marker of ['<el-upload', '源数据上传', '数据源配置', 'uploadedFiles']) {
|
||||||
|
assert.ok(!taskSetupSource.includes(marker), `第一步仍包含上传职责:${marker}`)
|
||||||
|
}
|
||||||
|
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 === 'upload'\) return '继续:数据预览'/, '第二步主按钮没有指向数据预览')
|
||||||
|
assert.match(viewSource, /const DRAFT_SCHEMA_VERSION = 5/, '生成配置扩展后必须升级草稿版本')
|
||||||
|
|
||||||
|
const nextFromCreateStart = viewSource.indexOf('async function nextFromCreate()')
|
||||||
|
const nextFromUploadStart = viewSource.indexOf('function nextFromUpload()', nextFromCreateStart)
|
||||||
|
const selectPreviewFileStart = viewSource.indexOf('function selectPreviewFile(', nextFromUploadStart)
|
||||||
|
assert.ok(nextFromCreateStart >= 0 && nextFromUploadStart > nextFromCreateStart, '缺少创建步骤与上传步骤的独立跳转函数')
|
||||||
|
const nextFromCreateSource = viewSource.slice(nextFromCreateStart, nextFromUploadStart)
|
||||||
|
const nextFromUploadSource = viewSource.slice(nextFromUploadStart, selectPreviewFileStart)
|
||||||
|
assert.match(nextFromCreateSource, /taskSetupRef\.value\?\.validate\(\)/, '创建步骤继续前没有校验任务配置')
|
||||||
|
assert.match(nextFromCreateSource, /goToStep\('upload'\)/, '创建步骤校验通过后没有进入上传文件')
|
||||||
|
assert.doesNotMatch(nextFromCreateSource, /uploadedFiles|buildPreviewItems/, '创建步骤仍在校验文件或提前生成预览')
|
||||||
|
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(viewSource, /currentStepId:\s*currentStepId\.value/, '草稿没有保存稳定步骤标识')
|
||||||
|
assert.match(viewSource, /const LEGACY_V3_STEP_IDS:[^=]*= \['create', 'preview', 'generate', 'results'\]/, 'v3 草稿缺少旧步骤索引映射')
|
||||||
|
assert.match(viewSource, /snapshotSchemaVersion === 3[\s\S]*?LEGACY_V3_STEP_IDS/, 'v3 草稿没有迁移到五步索引')
|
||||||
|
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(taskSetupSource.includes(option), `结构化预处理缺少选项:${option}`)
|
||||||
|
}
|
||||||
|
assert.ok(taskSetupSource.includes('生成选项'), '结构化配置缺少生成选项分类')
|
||||||
|
assert.ok(taskSetupSource.includes('语义丰富表达'), '生成选项缺少语义丰富表达开关')
|
||||||
|
assert.ok(taskSetupSource.includes('使用大模型将问答表述得更自然、柔和'), '语义丰富表达缺少辅助说明')
|
||||||
|
for (const splitName of ['训练集', '验证集', '测试集']) {
|
||||||
|
assert.ok(taskSetupSource.includes(splitName), `生成选项缺少数据集划分:${splitName}`)
|
||||||
|
}
|
||||||
|
assert.match(taskSetupSource, /const splitTotal = computed/, '数据集划分缺少比例总和计算')
|
||||||
|
assert.match(taskSetupSource, /splitTotal\.value !== 100/, '数据集划分缺少总和 100% 校验')
|
||||||
|
assert.ok(taskSetupSource.includes('训练集、验证集和测试集比例总和必须为 100%'), '数据集划分缺少就地错误提示')
|
||||||
|
for (const splitField of ['train', 'validation', 'test']) {
|
||||||
|
assert.match(
|
||||||
|
taskSetupSource,
|
||||||
|
new RegExp(`structuredOptions\\.datasetSplit\\.${splitField}[\\s\\S]*?:min="0"[\\s\\S]*?:max="100"[\\s\\S]*?:step="1"[\\s\\S]*?:precision="0"`),
|
||||||
|
`数据集划分字段 ${splitField} 缺少 0~100 的整数限制`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert.match(taskSetupSource, /<el-switch[\s\S]*structuredOptions\.semanticEnrichment/, '语义丰富表达必须使用开关控件')
|
||||||
|
assert.match(taskSetupSource, /<el-input-number[\s\S]*structuredOptions\.qaPairsPerRow[\s\S]*:min="1"[\s\S]*:max="5"/, '每行生成数量必须限制在 1 到 5')
|
||||||
|
assert.match(viewSource, /const structuredOptions = ref<StructuredProcessOptions>/, '父页面缺少结构化配置状态')
|
||||||
|
assert.match(viewSource, /datasetSplit:\s*\{ train: 80, validation: 10, test: 10 \}/, '数据集划分默认值必须为 80/10/10')
|
||||||
|
assert.match(viewSource, /structuredOptions:\s*\{[\s\S]*\.\.\.structuredOptions\.value/, '结构化配置没有写入草稿')
|
||||||
|
assert.match(viewSource, /structuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.structuredOptions/, '结构化配置没有从草稿恢复')
|
||||||
|
assert.match(viewSource, /v-model:structured-options="structuredOptions"/, '父页面没有双向绑定结构化配置')
|
||||||
|
assert.match(viewSource, /createResults\([\s\S]*structuredOptions\.value/, '每行生成数量没有接入结果生成逻辑')
|
||||||
|
|
||||||
|
for (const field of [
|
||||||
|
'generationModelId',
|
||||||
|
'generationPrompt',
|
||||||
|
'qualityFilterEnabled',
|
||||||
|
'filterLowQuality',
|
||||||
|
'filterShortContent',
|
||||||
|
'minOutputLength',
|
||||||
|
]) {
|
||||||
|
assert.ok(typesSource.includes(field), `生成控制配置缺少字段:${field}`)
|
||||||
|
assert.ok(viewSource.includes(field), `父页面默认值或草稿状态缺少字段:${field}`)
|
||||||
|
}
|
||||||
|
assert.match(taskSetupSource, /GenerationOptionsPanel/, '生成选项没有复用统一的大模型与质量筛选组件')
|
||||||
|
assert.match(taskSetupSource, /:options="structuredOptions"/, '结构化生成选项未接入统一配置组件')
|
||||||
|
assert.match(taskSetupSource, /:options="unstructuredOptions"/, '非结构化生成选项未接入统一配置组件')
|
||||||
|
assert.match(taskSetupSource, /<h3>大模型<\/h3>/, '大模型必须作为与生成选项平级的独立分类')
|
||||||
|
assert.match(taskSetupSource, /section="model"/, '独立大模型分类没有挂载模型配置')
|
||||||
|
assert.match(taskSetupSource, /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-option-row\s*\{[\s\S]*?display:\s*flex[\s\S]*?border:\s*1px solid #e2e5ec/, '大模型配置没有与上方生成选项使用一致的配置行样式')
|
||||||
|
assert.match(viewSource, /const DEFAULT_GENERATION_PROMPT\s*=\s*['"][^'"]{40,}['"]/, '大模型配置缺少可直接使用的默认提示语')
|
||||||
|
assert.equal((viewSource.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, /generationValidationMessage/, '生成模型与质量规则缺少继续前校验')
|
||||||
|
assert.match(viewSource, /useModelsStore/, '创建页没有加载模型列表')
|
||||||
|
assert.match(viewSource, /model\.type === 'LLM'/, '数据生成模型列表没有排除非大模型')
|
||||||
|
assert.match(viewSource, /:generation-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(taskSetupSource.includes('智能预处理'), '简化后缺少智能预处理总开关')
|
||||||
|
assert.ok(taskSetupSource.includes('敏感信息脱敏'), '简化后缺少脱敏开关')
|
||||||
|
assert.match(taskSetupSource, /const smartPreprocessEnabled = computed/, '智能预处理没有映射到内部处理项')
|
||||||
|
assert.match(taskSetupSource, /function updateSmartPreprocess/, '智能预处理开关缺少更新逻辑')
|
||||||
|
assert.match(taskSetupSource, /function updateDesensitize/, '脱敏开关缺少更新逻辑')
|
||||||
|
|
||||||
|
assert.ok(taskSetupSource.includes('切分选项'), '非结构化配置缺少切分选项分类')
|
||||||
|
for (const method of ['自动语义切分', '按标题和段落', '按固定长度', '自定义分隔符']) {
|
||||||
|
assert.ok(taskSetupSource.includes(method), `切分方式缺少选项:${method}`)
|
||||||
|
}
|
||||||
|
for (const label of ['切片长度', '重叠长度', '最小切片长度', '保护表格、代码和列表']) {
|
||||||
|
assert.ok(taskSetupSource.includes(label), `切分选项缺少配置:${label}`)
|
||||||
|
}
|
||||||
|
assert.ok(taskSetupSource.includes('高级设置'), '切分选项缺少渐进式高级设置入口')
|
||||||
|
assert.match(taskSetupSource, /:aria-expanded="advancedChunkSettingsOpen"/, '高级设置入口缺少展开状态的可访问性标记')
|
||||||
|
assert.match(taskSetupSource, /v-if="advancedChunkSettingsOpen"/, '高级设置内容没有默认收起')
|
||||||
|
assert.match(taskSetupSource, /watch\(\(\) => props\.unstructuredOptions\.chunkMethod,[\s\S]*?method === 'custom'[\s\S]*?advancedChunkSettingsOpen\.value = true/, '选择自定义分隔符时没有自动展开高级设置')
|
||||||
|
assert.match(taskSetupSource, /if \(chunkValidationMessage\.value\) \{[\s\S]*?advancedChunkSettingsOpen\.value = true[\s\S]*?return false/, '高级切分配置校验失败时没有重新展开定位')
|
||||||
|
assert.match(taskSetupSource, /const preserveSpecialContentEnabled = computed/, '特殊内容保护没有合并为单一开关')
|
||||||
|
assert.match(taskSetupSource, /function updateSpecialContentProtection/, '特殊内容保护开关缺少更新逻辑')
|
||||||
|
assert.match(taskSetupSource, /unstructuredOptions\.chunkSize[\s\S]*?:min="200"[\s\S]*?:max="2000"/, '切片长度必须限制在 200 到 2000 Token')
|
||||||
|
assert.match(taskSetupSource, /unstructuredOptions\.chunkOverlap[\s\S]*?:min="0"[\s\S]*?:max="500"/, '重叠长度必须限制在 0 到 500 Token')
|
||||||
|
assert.match(taskSetupSource, /unstructuredOptions\.minChunkSize[\s\S]*?:min="20"[\s\S]*?:max="500"/, '最小切片长度必须限制在 20 到 500 Token')
|
||||||
|
|
||||||
|
for (const label of ['语义丰富表达', '每个切片生成数量', '数据集划分']) {
|
||||||
|
assert.ok(taskSetupSource.includes(label), `非结构化生成选项缺少:${label}`)
|
||||||
|
}
|
||||||
|
for (const removedLabel of ['上下文范围', '问题类型', '跳过无法回答的内容']) {
|
||||||
|
assert.ok(!taskSetupSource.includes(removedLabel), `简化后仍显示低频选项:${removedLabel}`)
|
||||||
|
}
|
||||||
|
assert.match(taskSetupSource, /unstructuredOptions\.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(taskSetupSource.includes('Token 数为轻量估算值'), '切片长度缺少 Token 估算说明')
|
||||||
|
assert.match(taskSetupSource, /\.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(viewSource, /chunkMethod:\s*'semantic'/, '非结构化默认切分方式必须为自动语义切分')
|
||||||
|
assert.match(viewSource, /chunkSize:\s*800/, '默认切片长度必须为 800 Token')
|
||||||
|
assert.match(viewSource, /chunkOverlap:\s*100/, '默认重叠长度必须为 100 Token')
|
||||||
|
assert.match(viewSource, /minChunkSize:\s*100/, '默认最小切片长度必须为 100 Token')
|
||||||
|
assert.match(viewSource, /qaPairsPerChunk:\s*1/, '默认每个切片必须生成 1 个问答对')
|
||||||
|
assert.match(viewSource, /unstructuredOptions:\s*\{[\s\S]*\.\.\.unstructuredOptions\.value/, '非结构化配置没有写入草稿')
|
||||||
|
assert.match(viewSource, /unstructuredOptions\.value = \{[\s\S]*restoredUnstructuredOptions\.chunkSize/, '非结构化配置没有从草稿恢复')
|
||||||
|
assert.match(viewSource, /v-model:unstructured-options="unstructuredOptions"/, '父页面没有双向绑定非结构化配置')
|
||||||
|
assert.match(viewSource, /const DRAFT_SCHEMA_VERSION = \d+/, '草稿缺少版本标识')
|
||||||
|
assert.match(viewSource, /schemaVersion:\s*DRAFT_SCHEMA_VERSION/, '草稿快照没有写入版本标识')
|
||||||
|
assert.match(viewSource, /requiresPreviewMigration/, '旧草稿没有失效旧切片预览')
|
||||||
|
assert.doesNotMatch(
|
||||||
|
viewSource,
|
||||||
|
/snapshot\.unstructuredOptions\s*&&\s*!requiresPreviewMigration/,
|
||||||
|
'草稿版本迁移不应丢失仍受支持的非结构化配置',
|
||||||
|
)
|
||||||
|
assert.match(viewSource, /const restoredUnstructuredOptions = snapshot\.unstructuredOptions/, '旧草稿没有通过白名单恢复仍受支持的配置')
|
||||||
|
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 +720,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,51 +764,48 @@ 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 || ''
|
||||||
@@ -408,4 +821,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('数据处理五步向导回归检查通过')
|
||||||
|
|||||||
122
frontend/scripts/regression-dataset-preview.mjs
Normal file
122
frontend/scripts/regression-dataset-preview.mjs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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 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 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,
|
||||||
|
getActiveDatasetVersion,
|
||||||
|
} = await import(`data:text/javascript;base64,${Buffer.from(versionsModuleCode).toString('base64')}`)
|
||||||
|
|
||||||
|
function expect(condition, message) {
|
||||||
|
if (!condition) throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(source.includes('class="preview-workspace"'), '详情页应提供数据文件工作区')
|
||||||
|
expect(!source.includes('class="file-pane"'), '单文件数据集详情不应展示文件选择侧栏')
|
||||||
|
expect(source.includes('class="code-line"'), '内容查看器应提供逐行展示')
|
||||||
|
expect(source.includes('class="line-number"'), '内容查看器应显示行号')
|
||||||
|
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
|
||||||
|
expect(source.includes('previewError'), '内容加载失败时应提供错误状态')
|
||||||
|
expect(source.includes('class="records-viewer"'), '详情页应将结构化文件展示为样本列表')
|
||||||
|
expect(source.includes('class="record-table"'), '样本数据应使用企业级表格展示')
|
||||||
|
expect(source.includes('fixed="right"'), '逐条编辑操作列应固定在表格右侧')
|
||||||
|
expect(source.includes('v-for="fieldKey in tableFieldKeys"'), '企业表格应根据数据结构动态生成字段列')
|
||||||
|
expect(source.includes('openRecordEditor(asDatasetRecord(row))'), '每条样本应提供独立编辑入口')
|
||||||
|
expect(source.includes('暂存修改'), '单条编辑应先暂存到当前页面')
|
||||||
|
expect(source.includes('hasPendingVersionChanges && isViewingActiveVersion'), '存在暂存修改时才应显示保存版本按钮')
|
||||||
|
expect(source.includes('保存版本'), '文件工具栏应提供保存版本按钮')
|
||||||
|
expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本')
|
||||||
|
expect(source.includes('class="version-control-bar"'), '详情页应提供独立版本控制区')
|
||||||
|
expect(source.includes('设为当前版本'), '历史版本应支持显式切换为当前版本')
|
||||||
|
expect(source.includes('历史版本(只读)'), '历史版本应明确展示只读状态')
|
||||||
|
expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本')
|
||||||
|
expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容')
|
||||||
|
expect(source.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(source.includes("event.key.toLowerCase() === 's'"), '在线编辑器应支持快捷键保存')
|
||||||
|
expect(source.includes('loadVersions(selectedFile)'), '版本内容加载失败后应支持重新加载')
|
||||||
|
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
|
||||||
|
expect(!source.includes('handleDelete'), '页面不应保留删除逻辑')
|
||||||
|
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('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(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', '切换版本不得污染原状态或历史快照')
|
||||||
|
let invalidVersionRejected = false
|
||||||
|
try {
|
||||||
|
activateDatasetVersion(v2State, 'missing')
|
||||||
|
} catch {
|
||||||
|
invalidVersionRejected = true
|
||||||
|
}
|
||||||
|
expect(invalidVersionRejected, '不存在的版本不得切换为当前版本')
|
||||||
|
|
||||||
|
console.log('dataset preview regression checks passed')
|
||||||
42
frontend/scripts/regression-dataset-task-tab.mjs
Normal file
42
frontend/scripts/regression-dataset-task-tab.mjs
Normal 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 数据与页签分流回归检查通过')
|
||||||
39
frontend/scripts/regression-default-dashboard.mjs
Normal file
39
frontend/scripts/regression-default-dashboard.mjs
Normal 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')
|
||||||
158
frontend/scripts/regression-eval-create-wizard.mjs
Normal file
158
frontend/scripts/regression-eval-create-wizard.mjs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
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,
|
||||||
|
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/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,
|
||||||
|
/任务配置[\s\S]*?评测规则/,
|
||||||
|
'评测创建向导必须按“任务配置、评测规则”定义两个步骤',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
ruleStepSource,
|
||||||
|
/:show-description=["']false["']/,
|
||||||
|
'新建评测任务第二步不应显示重复的规则描述输入框',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
dimensionFieldsSource,
|
||||||
|
/v-if=["']showDescription["'][^>]*label=["']描述["']/,
|
||||||
|
'共享维度字段必须支持按场景隐藏描述输入框',
|
||||||
|
)
|
||||||
|
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]*?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(createSource, /['"]baseline['"]/, '向导缺少基线评测分支')
|
||||||
|
assert.match(
|
||||||
|
`${createSource}\n${ruleStepSource}`,
|
||||||
|
/已有维度|existing/,
|
||||||
|
'评测规则步骤缺少使用已有维度的分支',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
`${createSource}\n${ruleStepSource}`,
|
||||||
|
/新建(?:评测)?(?:维度|规则)|create/,
|
||||||
|
'评测规则步骤缺少新建维度的分支',
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
/eval_type[\s\S]*?baseline[\s\S]*?(?:dimension_id|createDimension)/,
|
||||||
|
'基线评测必须有明确的不创建自定义维度分支',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
createSource,
|
||||||
|
/ruleMode[\s\S]*?['"]existing['"]/,
|
||||||
|
'已有维度模式必须有独立的提交分支',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.match(createSource, /:loading=["']submitting["']/, '最终提交按钮缺少 loading 状态')
|
||||||
|
assert.match(createSource, /:disabled=["'][^"']*submitting/, '提交期间必须禁用重复操作')
|
||||||
|
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('评测创建向导回归检查通过')
|
||||||
99
frontend/scripts/regression-eval-detail.mjs
Normal file
99
frontend/scripts/regression-eval-detail.mjs
Normal 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('评测详情页回归检查通过')
|
||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
|
|
||||||
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')
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -18,10 +19,16 @@ 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(source.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((source.match(/auto_merge: form\.train_type === 'SFT' && form\.auto_merge/g) || []).length === 2, 'Auto merge should be sent for SFT when creating and starting the task')
|
||||||
|
|
||||||
console.log('fine-tune create UI regression checks passed')
|
console.log('fine-tune create UI regression checks passed')
|
||||||
|
|||||||
41
frontend/scripts/regression-hardware-dashboard.mjs
Normal file
41
frontend/scripts/regression-hardware-dashboard.mjs
Normal 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('平台性能页回归检查通过')
|
||||||
@@ -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,58 @@ 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 },
|
||||||
|
)
|
||||||
|
|
||||||
/** 下载文件 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) =>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
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 } 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}`)
|
||||||
|
|
||||||
@@ -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>) =>
|
||||||
|
|||||||
366
frontend/src/components/AppConfirmDialog.vue
Normal file
366
frontend/src/components/AppConfirmDialog.vue
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<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'
|
||||||
|
|
||||||
@@ -9,7 +9,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'
|
||||||
// 维度管理归到模型评测
|
// 维度管理归到模型评测
|
||||||
@@ -62,10 +62,33 @@ const menuGroups = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleSelect(key: string) {
|
/**
|
||||||
|
* 传给 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 = menuGroups.flatMap((g) => g.items)
|
const all = menuGroups.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,7 +108,7 @@ 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)"
|
||||||
|
|||||||
@@ -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 }[] }[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ onUnmounted(() => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1181px) {
|
||||||
|
.layout-content:has(.dashboard-view) {
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
|
.page-canvas {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-canvas {
|
.page-canvas {
|
||||||
|
|||||||
@@ -12,14 +12,23 @@ import {
|
|||||||
mockTrainedModels,
|
mockTrainedModels,
|
||||||
mockLocalModels,
|
mockLocalModels,
|
||||||
mockDatasets,
|
mockDatasets,
|
||||||
|
mockDatasetPreviews,
|
||||||
mockFineTuneList,
|
mockFineTuneList,
|
||||||
mockCompareList,
|
mockCompareList,
|
||||||
mockEvalList,
|
mockEvalList,
|
||||||
|
mockEvalDetails,
|
||||||
mockDimensions,
|
mockDimensions,
|
||||||
mockLogFiles,
|
mockLogFiles,
|
||||||
mockTrainingLogFiles,
|
mockTrainingLogFiles,
|
||||||
mockLogContent,
|
mockLogContent,
|
||||||
} from './data'
|
} from './data'
|
||||||
|
import {
|
||||||
|
activateDatasetVersion,
|
||||||
|
appendDatasetVersion,
|
||||||
|
createInitialVersionState,
|
||||||
|
getActiveDatasetVersion,
|
||||||
|
} from './datasetVersions'
|
||||||
|
import type { StoredDatasetVersion, StoredDatasetVersionState } from './datasetVersions'
|
||||||
|
|
||||||
/** 模拟网络延迟 */
|
/** 模拟网络延迟 */
|
||||||
function delay(ms = 200): Promise<void> {
|
function delay(ms = 200): Promise<void> {
|
||||||
@@ -55,6 +64,41 @@ 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) return state
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
|
||||||
/** 通过路径 + method 匹配 mock 响应 */
|
/** 通过路径 + method 匹配 mock 响应 */
|
||||||
async function handleMock(config: AxiosRequestConfig) {
|
async function handleMock(config: AxiosRequestConfig) {
|
||||||
await delay(150) // 模拟网络延迟
|
await delay(150) // 模拟网络延迟
|
||||||
@@ -129,7 +173,80 @@ 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({
|
||||||
|
versions: state.versions.map(versionMetadata).sort((a, b) => b.version - a.version),
|
||||||
|
active_version_id: state.active_version_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)
|
if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)
|
||||||
@@ -223,6 +340,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 })
|
||||||
|
|||||||
@@ -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,166 @@ 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: 'idle',
|
||||||
{ 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: 0,
|
||||||
{ 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: 0,
|
||||||
{ 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: 0,
|
||||||
|
temperature: 32,
|
||||||
|
power_w: 38,
|
||||||
|
power_limit_w: 400,
|
||||||
|
fan_speed: 0,
|
||||||
|
clock_mhz: 1410,
|
||||||
|
driver_version: '535.86.10',
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
uuid: 'GPU-MOCK-A800-01',
|
||||||
|
name: 'NVIDIA A800',
|
||||||
|
status: 'busy',
|
||||||
|
gpu_percent: 28,
|
||||||
|
memory_used_gb: 22.5,
|
||||||
|
memory_total_gb: 80,
|
||||||
|
memory_percent: 28.1,
|
||||||
|
temperature: 52,
|
||||||
|
power_w: 165,
|
||||||
|
power_limit_w: 400,
|
||||||
|
fan_speed: 32,
|
||||||
|
clock_mhz: 1410,
|
||||||
|
driver_version: '535.86.10',
|
||||||
|
processes: [
|
||||||
|
{ pid: 18421, name: 'python', task_name: '指令微调任务', user: 'trainer', memory_used_gb: 18.6 },
|
||||||
|
{ pid: 18503, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 3.9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
@@ -99,22 +250,101 @@ 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' },
|
||||||
|
].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: 103942, 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: 349102, 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: 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' },
|
{ 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, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' },
|
||||||
{ 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' },
|
{ id: 593021, 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' },
|
||||||
{ 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' },
|
{ id: 201948, 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' },
|
||||||
{ 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' },
|
{ id: 940212, 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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ============ 模型推理/对比 ============
|
// ============ 模型推理/对比 ============
|
||||||
@@ -170,6 +400,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' },
|
||||||
|
|||||||
64
frontend/src/mock/datasetVersions.ts
Normal file
64
frontend/src/mock/datasetVersions.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export interface StoredDatasetVersion {
|
||||||
|
id: string
|
||||||
|
version: number
|
||||||
|
create_time: string
|
||||||
|
description?: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDatasetVersionState {
|
||||||
|
versions: StoredDatasetVersion[]
|
||||||
|
active_version_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加不可变版本快照,并自动把新版本设为当前版本。 */
|
||||||
|
export function appendDatasetVersion(
|
||||||
|
state: StoredDatasetVersionState,
|
||||||
|
content: string,
|
||||||
|
createTime: string,
|
||||||
|
description = '在线编辑',
|
||||||
|
): StoredDatasetVersionState {
|
||||||
|
const nextNumber = Math.max(0, ...state.versions.map((item) => item.version)) + 1
|
||||||
|
const version: StoredDatasetVersion = {
|
||||||
|
id: `v${nextNumber}`,
|
||||||
|
version: nextNumber,
|
||||||
|
create_time: createTime,
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
versions: [...state.versions, version],
|
||||||
|
active_version_id: version.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅移动当前版本指针,不修改任何历史快照。 */
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 } from 'echarts/charts'
|
||||||
import {
|
import {
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
use([
|
use([
|
||||||
CanvasRenderer,
|
CanvasRenderer,
|
||||||
|
BarChart,
|
||||||
LineChart,
|
LineChart,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
|
|||||||
@@ -11,13 +11,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: '服务看板' },
|
||||||
},
|
},
|
||||||
// 模型调优
|
// 模型调优
|
||||||
@@ -46,11 +46,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 +133,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,7 +187,7 @@ 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: '数据类型转换' },
|
||||||
},
|
},
|
||||||
// 系统设置
|
// 系统设置
|
||||||
@@ -202,7 +213,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
redirect: '/fine-tune',
|
redirect: '/dashboard',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -219,7 +230,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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,18 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
// ============ 训练任务 ============
|
// ============ 训练任务 ============
|
||||||
export type TrainType = 'SFT' | 'DPO' | 'CPT'
|
export type TrainType = 'SFT' | 'DPO' | 'CPT'
|
||||||
export type TrainMethodType = 'lora' | 'full'
|
export type TrainMethodType = 'lora' | 'full'
|
||||||
@@ -92,6 +107,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,6 +121,18 @@ export interface FineTuneTask {
|
|||||||
lora_alpha?: number
|
lora_alpha?: number
|
||||||
lora_dropout?: number
|
lora_dropout?: number
|
||||||
lora_rank?: number
|
lora_rank?: number
|
||||||
|
/** 训练时量化位数(QLoRA):0=不量化 / 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
|
||||||
@@ -151,6 +179,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 +193,47 @@ export interface EvalTask {
|
|||||||
create_time?: string
|
create_time?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个测试样本的评测结果 */
|
||||||
|
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 +253,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 +268,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 +300,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
|
||||||
|
|||||||
753
frontend/src/views/dashboard/DashboardView.vue
Normal file
753
frontend/src/views/dashboard/DashboardView.vue
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
<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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
height: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-section,
|
||||||
|
.chart-section,
|
||||||
|
.service-section,
|
||||||
|
.tasks-section {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 1 1 auto;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section,
|
||||||
|
.service-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: auto;
|
||||||
|
min-height: 230px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
||||||
|
.dashboard-view {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-middle {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section,
|
||||||
|
.service-section {
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.training-chart {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dashboard-view * {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
305
frontend/src/views/data-convert/DataConvertView.vue
Normal file
305
frontend/src/views/data-convert/DataConvertView.vue
Normal 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>
|
||||||
@@ -1,29 +1,96 @@
|
|||||||
<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 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, createResults, DEFAULT_SOURCE_TEXT } from './create/previewModel'
|
||||||
import type { GenerationState, PreviewItem, ProcessType, ResultItem, StepId } from './create/types'
|
import { useModelsStore } from '@/stores/models'
|
||||||
|
import type {
|
||||||
|
ExternalDataSource,
|
||||||
|
GenerationState,
|
||||||
|
PreviewItem,
|
||||||
|
ProcessType,
|
||||||
|
ResultItem,
|
||||||
|
StepId,
|
||||||
|
StructuredProcessOptions,
|
||||||
|
UnstructuredProcessOptions,
|
||||||
|
} 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 confirmDialogRef = ref<InstanceType<typeof AppConfirmDialog>>()
|
||||||
const DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
|
const DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
|
||||||
|
const DRAFT_SCHEMA_VERSION = 5
|
||||||
|
const PREVIEW_MODEL_VERSION = 'document-chunk-v2'
|
||||||
|
const DEFAULT_GENERATION_PROMPT = '你是一名专业的数据生成助手。请根据输入内容生成准确、完整、可直接用于模型训练的问答数据。仅输出符合目标格式的内容,答案应事实清晰、语言自然,不要添加分析过程、说明或无关内容。'
|
||||||
|
|
||||||
const WIZARD_STEPS = [
|
const WIZARD_STEPS = [
|
||||||
{ id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' },
|
{ id: 'create', 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 LEGACY_V3_STEP_IDS: ReadonlyArray<StepId> = ['create', 'preview', 'generate', 'results']
|
||||||
|
|
||||||
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>('unstructured')
|
||||||
|
const structuredOptions = ref<StructuredProcessOptions>({
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
const unstructuredOptions = ref<UnstructuredProcessOptions>({
|
||||||
|
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,
|
||||||
|
})
|
||||||
interface UploadedDataFile {
|
interface UploadedDataFile {
|
||||||
uid: number | string
|
uid: number | string
|
||||||
name: string
|
name: string
|
||||||
@@ -34,6 +101,18 @@ interface UploadedDataFile {
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
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 fileSize = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.size, 0))
|
||||||
const fileCount = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.count, 0))
|
const fileCount = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.count, 0))
|
||||||
@@ -70,7 +149,8 @@ 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 === '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,12 +169,34 @@ const previousStepLabel = computed(() => currentStep.value > 0
|
|||||||
? WIZARD_STEPS[currentStep.value - 1].title
|
? WIZARD_STEPS[currentStep.value - 1].title
|
||||||
: '')
|
: '')
|
||||||
|
|
||||||
|
function isStepId(value: unknown): value is StepId {
|
||||||
|
return typeof value === 'string' && WIZARD_STEPS.some((step) => step.id === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep(stepId: StepId) {
|
||||||
|
const nextStepIndex = WIZARD_STEPS.findIndex((step) => step.id === stepId)
|
||||||
|
if (nextStepIndex >= 0) currentStep.value = nextStepIndex
|
||||||
|
}
|
||||||
|
|
||||||
function draftSnapshot() {
|
function draftSnapshot() {
|
||||||
return {
|
return {
|
||||||
|
schemaVersion: DRAFT_SCHEMA_VERSION,
|
||||||
currentStep: currentStep.value,
|
currentStep: currentStep.value,
|
||||||
|
currentStepId: currentStepId.value,
|
||||||
task: { ...task },
|
task: { ...task },
|
||||||
processType: processType.value,
|
processType: processType.value,
|
||||||
|
structuredOptions: {
|
||||||
|
...structuredOptions.value,
|
||||||
|
preprocessOptions: [...structuredOptions.value.preprocessOptions],
|
||||||
|
datasetSplit: { ...structuredOptions.value.datasetSplit },
|
||||||
|
},
|
||||||
|
unstructuredOptions: {
|
||||||
|
...unstructuredOptions.value,
|
||||||
|
preprocessOptions: [...unstructuredOptions.value.preprocessOptions],
|
||||||
|
datasetSplit: { ...unstructuredOptions.value.datasetSplit },
|
||||||
|
},
|
||||||
uploadedFiles: uploadedFiles.value,
|
uploadedFiles: uploadedFiles.value,
|
||||||
|
externalSource: { ...externalSource },
|
||||||
previewSignature: previewSignature.value,
|
previewSignature: previewSignature.value,
|
||||||
previewItems: previewItems.value,
|
previewItems: previewItems.value,
|
||||||
selectedPreviewFileId: selectedPreviewFileId.value,
|
selectedPreviewFileId: selectedPreviewFileId.value,
|
||||||
@@ -106,6 +208,94 @@ function draftSnapshot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewAffectingOptions() {
|
||||||
|
if (processType.value === 'structured') {
|
||||||
|
return {
|
||||||
|
preprocessOptions: structuredOptions.value.preprocessOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.content}`)
|
||||||
|
.join('|')
|
||||||
|
return `${PREVIEW_MODEL_VERSION}:${processType.value}:${JSON.stringify(previewAffectingOptions())}:${filesSignature}`
|
||||||
|
}
|
||||||
|
|
||||||
function persistDraft() {
|
function persistDraft() {
|
||||||
if (restoringDraft.value) return
|
if (restoringDraft.value) return
|
||||||
try {
|
try {
|
||||||
@@ -115,7 +305,7 @@ function persistDraft() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type DraftSnapshot = ReturnType<typeof draftSnapshot> & {
|
type DraftSnapshot = Partial<ReturnType<typeof draftSnapshot>> & {
|
||||||
fileName?: string
|
fileName?: string
|
||||||
fileSize?: number
|
fileSize?: number
|
||||||
fileCount?: number
|
fileCount?: number
|
||||||
@@ -128,12 +318,113 @@ function restoreDraft() {
|
|||||||
if (!raw) return
|
if (!raw) return
|
||||||
const snapshot = JSON.parse(raw) as DraftSnapshot
|
const snapshot = JSON.parse(raw) as DraftSnapshot
|
||||||
if (!snapshot?.uploadedFiles && (!snapshot?.fileName || !snapshot?.sourceText)) return
|
if (!snapshot?.uploadedFiles && (!snapshot?.fileName || !snapshot?.sourceText)) return
|
||||||
|
const snapshotSchemaVersion = Number(snapshot.schemaVersion) || 0
|
||||||
|
const requiresPreviewMigration = snapshotSchemaVersion < 3
|
||||||
|
const legacyStepId = snapshotSchemaVersion === 3
|
||||||
|
? LEGACY_V3_STEP_IDS[Math.min(Math.max(Number(snapshot.currentStep) || 0, 0), LEGACY_V3_STEP_IDS.length - 1)]
|
||||||
|
: undefined
|
||||||
|
const indexedStepId = WIZARD_STEPS[Math.min(
|
||||||
|
Math.max(Number(snapshot.currentStep) || 0, 0),
|
||||||
|
WIZARD_STEPS.length - 1,
|
||||||
|
)]?.id
|
||||||
|
const restoredStepId = isStepId(snapshot.currentStepId)
|
||||||
|
? snapshot.currentStepId
|
||||||
|
: legacyStepId || indexedStepId || 'create'
|
||||||
|
|
||||||
restoringDraft.value = true
|
restoringDraft.value = true
|
||||||
currentStep.value = Math.min(Math.max(Number(snapshot.currentStep) || 0, 0), WIZARD_STEPS.length - 1)
|
goToStep(requiresPreviewMigration ? 'create' : restoredStepId)
|
||||||
task.name = snapshot.task?.name || ''
|
task.name = snapshot.task?.name || ''
|
||||||
task.description = snapshot.task?.description || ''
|
task.description = snapshot.task?.description || ''
|
||||||
processType.value = snapshot.processType === 'structured' ? 'structured' : 'unstructured'
|
processType.value = snapshot.processType === 'structured' || snapshot.processType === 'external'
|
||||||
|
? snapshot.processType
|
||||||
|
: 'unstructured'
|
||||||
|
if (snapshot.structuredOptions) {
|
||||||
|
structuredOptions.value = {
|
||||||
|
...structuredOptions.value,
|
||||||
|
...snapshot.structuredOptions,
|
||||||
|
preprocessOptions: Array.isArray(snapshot.structuredOptions.preprocessOptions)
|
||||||
|
? snapshot.structuredOptions.preprocessOptions
|
||||||
|
: structuredOptions.value.preprocessOptions,
|
||||||
|
datasetSplit: {
|
||||||
|
...structuredOptions.value.datasetSplit,
|
||||||
|
...snapshot.structuredOptions.datasetSplit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapshot.unstructuredOptions) {
|
||||||
|
const restoredUnstructuredOptions = snapshot.unstructuredOptions
|
||||||
|
const restoredDatasetSplit = restoredUnstructuredOptions.datasetSplit
|
||||||
|
unstructuredOptions.value = {
|
||||||
|
...unstructuredOptions.value,
|
||||||
|
preprocessOptions: Array.isArray(restoredUnstructuredOptions.preprocessOptions)
|
||||||
|
? restoredUnstructuredOptions.preprocessOptions
|
||||||
|
: unstructuredOptions.value.preprocessOptions,
|
||||||
|
chunkMethod: restoredUnstructuredOptions.chunkMethod || unstructuredOptions.value.chunkMethod,
|
||||||
|
chunkSize: Number.isFinite(restoredUnstructuredOptions.chunkSize)
|
||||||
|
? restoredUnstructuredOptions.chunkSize
|
||||||
|
: unstructuredOptions.value.chunkSize,
|
||||||
|
chunkOverlap: Number.isFinite(restoredUnstructuredOptions.chunkOverlap)
|
||||||
|
? restoredUnstructuredOptions.chunkOverlap
|
||||||
|
: unstructuredOptions.value.chunkOverlap,
|
||||||
|
minChunkSize: Number.isFinite(restoredUnstructuredOptions.minChunkSize)
|
||||||
|
? restoredUnstructuredOptions.minChunkSize
|
||||||
|
: unstructuredOptions.value.minChunkSize,
|
||||||
|
customDelimiter: typeof restoredUnstructuredOptions.customDelimiter === 'string'
|
||||||
|
? restoredUnstructuredOptions.customDelimiter
|
||||||
|
: unstructuredOptions.value.customDelimiter,
|
||||||
|
preserveTables: typeof restoredUnstructuredOptions.preserveTables === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.preserveTables
|
||||||
|
: unstructuredOptions.value.preserveTables,
|
||||||
|
preserveCodeBlocks: typeof restoredUnstructuredOptions.preserveCodeBlocks === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.preserveCodeBlocks
|
||||||
|
: unstructuredOptions.value.preserveCodeBlocks,
|
||||||
|
preserveLists: typeof restoredUnstructuredOptions.preserveLists === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.preserveLists
|
||||||
|
: unstructuredOptions.value.preserveLists,
|
||||||
|
semanticEnrichment: typeof restoredUnstructuredOptions.semanticEnrichment === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.semanticEnrichment
|
||||||
|
: unstructuredOptions.value.semanticEnrichment,
|
||||||
|
qaPairsPerChunk: Number.isFinite(restoredUnstructuredOptions.qaPairsPerChunk)
|
||||||
|
? restoredUnstructuredOptions.qaPairsPerChunk
|
||||||
|
: unstructuredOptions.value.qaPairsPerChunk,
|
||||||
|
generationModelId: restoredUnstructuredOptions.generationModelId ?? unstructuredOptions.value.generationModelId,
|
||||||
|
generationPrompt: typeof restoredUnstructuredOptions.generationPrompt === 'string'
|
||||||
|
? restoredUnstructuredOptions.generationPrompt
|
||||||
|
: unstructuredOptions.value.generationPrompt,
|
||||||
|
temperature: Number.isFinite(restoredUnstructuredOptions.temperature)
|
||||||
|
? restoredUnstructuredOptions.temperature
|
||||||
|
: unstructuredOptions.value.temperature,
|
||||||
|
maxTokens: Number.isFinite(restoredUnstructuredOptions.maxTokens)
|
||||||
|
? restoredUnstructuredOptions.maxTokens
|
||||||
|
: unstructuredOptions.value.maxTokens,
|
||||||
|
jsonMode: typeof restoredUnstructuredOptions.jsonMode === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.jsonMode
|
||||||
|
: unstructuredOptions.value.jsonMode,
|
||||||
|
qualityFilterEnabled: typeof restoredUnstructuredOptions.qualityFilterEnabled === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.qualityFilterEnabled
|
||||||
|
: unstructuredOptions.value.qualityFilterEnabled,
|
||||||
|
filterLowQuality: typeof restoredUnstructuredOptions.filterLowQuality === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.filterLowQuality
|
||||||
|
: unstructuredOptions.value.filterLowQuality,
|
||||||
|
filterShortContent: typeof restoredUnstructuredOptions.filterShortContent === 'boolean'
|
||||||
|
? restoredUnstructuredOptions.filterShortContent
|
||||||
|
: unstructuredOptions.value.filterShortContent,
|
||||||
|
minOutputLength: Number.isFinite(restoredUnstructuredOptions.minOutputLength)
|
||||||
|
? restoredUnstructuredOptions.minOutputLength
|
||||||
|
: unstructuredOptions.value.minOutputLength,
|
||||||
|
datasetSplit: {
|
||||||
|
train: Number.isFinite(restoredDatasetSplit?.train)
|
||||||
|
? restoredDatasetSplit.train
|
||||||
|
: unstructuredOptions.value.datasetSplit.train,
|
||||||
|
validation: Number.isFinite(restoredDatasetSplit?.validation)
|
||||||
|
? restoredDatasetSplit.validation
|
||||||
|
: unstructuredOptions.value.datasetSplit.validation,
|
||||||
|
test: Number.isFinite(restoredDatasetSplit?.test)
|
||||||
|
? restoredDatasetSplit.test
|
||||||
|
: unstructuredOptions.value.datasetSplit.test,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (snapshot.uploadedFiles) {
|
if (snapshot.uploadedFiles) {
|
||||||
uploadedFiles.value = Array.isArray(snapshot.uploadedFiles) ? snapshot.uploadedFiles : []
|
uploadedFiles.value = Array.isArray(snapshot.uploadedFiles) ? snapshot.uploadedFiles : []
|
||||||
@@ -148,37 +439,64 @@ function restoreDraft() {
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
previewSignature.value = snapshot.previewSignature || ''
|
previewSignature.value = requiresPreviewMigration ? '' : snapshot.previewSignature || ''
|
||||||
|
if (snapshot.externalSource) {
|
||||||
|
Object.assign(externalSource, snapshot.externalSource)
|
||||||
|
}
|
||||||
const defaultSourceFileId = String(uploadedFiles.value[0]?.uid ?? '')
|
const defaultSourceFileId = String(uploadedFiles.value[0]?.uid ?? '')
|
||||||
previewItems.value = Array.isArray(snapshot.previewItems)
|
previewItems.value = !requiresPreviewMigration && Array.isArray(snapshot.previewItems)
|
||||||
? snapshot.previewItems.map((item) => ({ ...item, sourceFileId: item.sourceFileId ?? defaultSourceFileId }))
|
? snapshot.previewItems.map((item) => ({ ...item, sourceFileId: item.sourceFileId ?? defaultSourceFileId }))
|
||||||
: []
|
: []
|
||||||
selectedPreviewFileId.value = snapshot.selectedPreviewFileId || defaultSourceFileId || null
|
selectedPreviewFileId.value = snapshot.selectedPreviewFileId || defaultSourceFileId || null
|
||||||
selectedPreviewIdsByFile.value = snapshot.selectedPreviewIdsByFile || {}
|
selectedPreviewIdsByFile.value = requiresPreviewMigration ? {} : snapshot.selectedPreviewIdsByFile || {}
|
||||||
selectedPreviewId.value = snapshot.selectedPreviewId
|
selectedPreviewId.value = requiresPreviewMigration
|
||||||
|| selectedPreviewIdsByFile.value[selectedPreviewFileId.value ?? '']
|
? null
|
||||||
|| activePreviewItems.value[0]?.id
|
: snapshot.selectedPreviewId
|
||||||
|| null
|
|| selectedPreviewIdsByFile.value[selectedPreviewFileId.value ?? '']
|
||||||
results.value = Array.isArray(snapshot.results) ? snapshot.results : []
|
|| activePreviewItems.value[0]?.id
|
||||||
selectedResultId.value = snapshot.selectedResultId || results.value[0]?.id || null
|
|| null
|
||||||
Object.assign(generation, snapshot.generation || {})
|
results.value = !requiresPreviewMigration && Array.isArray(snapshot.results) ? snapshot.results : []
|
||||||
|
selectedResultId.value = requiresPreviewMigration ? null : snapshot.selectedResultId || results.value[0]?.id || null
|
||||||
|
if (!requiresPreviewMigration) {
|
||||||
|
const restoredGeneration = snapshot.generation || {}
|
||||||
|
Object.assign(generation, restoredGeneration)
|
||||||
|
if (generation.status === 'running') {
|
||||||
|
generation.status = 'idle'
|
||||||
|
generation.progress = 0
|
||||||
|
generation.message = '草稿已恢复,请重新开始生成。'
|
||||||
|
}
|
||||||
|
}
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
nextTick(() => { restoringDraft.value = false })
|
nextTick(() => { restoringDraft.value = false })
|
||||||
ElMessage.info('已恢复上次保存的草稿')
|
ElMessage.info(requiresPreviewMigration
|
||||||
|
? '已恢复任务信息,切分规则已更新,请重新生成预览'
|
||||||
|
: '已恢复上次保存的草稿')
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
[currentStep, task, processType, structuredOptions, unstructuredOptions, uploadedFiles, externalSource, previewSignature,
|
||||||
previewItems, selectedPreviewFileId, selectedPreviewId, selectedPreviewIdsByFile,
|
previewItems, selectedPreviewFileId, selectedPreviewId, selectedPreviewIdsByFile,
|
||||||
results, selectedResultId, generation],
|
results, selectedResultId, generation],
|
||||||
persistDraft,
|
persistDraft,
|
||||||
@@ -235,11 +553,50 @@ 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
|
||||||
|
}
|
||||||
|
externalPulling.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
externalPulling.value = false
|
||||||
|
externalConnected.value = true
|
||||||
|
ElMessage.success('数据源连接测试成功')
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePullData() {
|
||||||
|
if (!externalSource.url.trim()) {
|
||||||
|
ElMessage.warning('请先填写数据源地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
externalPulling.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
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,6 +609,17 @@ function handleRemoveFile(uid: string | number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSourceDataForProcessTypeChange() {
|
||||||
|
uploadedFiles.value = []
|
||||||
|
previewSignature.value = ''
|
||||||
|
previewItems.value = []
|
||||||
|
selectedPreviewFileId.value = null
|
||||||
|
selectedPreviewId.value = null
|
||||||
|
selectedPreviewIdsByFile.value = {}
|
||||||
|
externalConnected.value = false
|
||||||
|
resetDownstream()
|
||||||
|
}
|
||||||
|
|
||||||
function resetDownstream() {
|
function resetDownstream() {
|
||||||
stopGenerationTimer()
|
stopGenerationTimer()
|
||||||
generation.status = 'idle'
|
generation.status = 'idle'
|
||||||
@@ -264,15 +632,24 @@ function 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('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 +659,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 +683,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 +693,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 +713,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,6 +734,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,8 +755,15 @@ function startGeneration() {
|
|||||||
|
|
||||||
stopGenerationTimer()
|
stopGenerationTimer()
|
||||||
generation.status = 'success'
|
generation.status = 'success'
|
||||||
generation.message = `已完成 ${previewItems.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
|
results.value = createResults(
|
||||||
results.value = createResults(previewItems.value)
|
previewItems.value,
|
||||||
|
processType.value === 'structured'
|
||||||
|
? structuredOptions.value
|
||||||
|
: processType.value === 'unstructured'
|
||||||
|
? unstructuredOptions.value
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
generation.message = `已完成 ${results.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
|
||||||
selectedResultId.value = results.value[0]?.id ?? null
|
selectedResultId.value = results.value[0]?.id ?? null
|
||||||
dirty.value = true
|
dirty.value = true
|
||||||
ElMessage.success('数据处理完成')
|
ElMessage.success('数据处理完成')
|
||||||
@@ -431,17 +818,21 @@ async function handlePrimaryAction() {
|
|||||||
await nextFromCreate()
|
await nextFromCreate()
|
||||||
return
|
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()
|
||||||
}
|
}
|
||||||
@@ -477,29 +868,36 @@ 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(stopGenerationTimer)
|
||||||
onMounted(restoreDraft)
|
onMounted(() => {
|
||||||
|
restoreDraft()
|
||||||
|
modelsStore.load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -516,6 +914,7 @@ onMounted(restoreDraft)
|
|||||||
'is-active': currentStep === index,
|
'is-active': currentStep === index,
|
||||||
'is-completed': currentStep > index
|
'is-completed': currentStep > index
|
||||||
}"
|
}"
|
||||||
|
:aria-current="currentStep === index ? 'step' : undefined"
|
||||||
>
|
>
|
||||||
<div v-if="index !== 0" class="step-connector"></div>
|
<div v-if="index !== 0" class="step-connector"></div>
|
||||||
<div class="step-node">
|
<div class="step-node">
|
||||||
@@ -538,10 +937,24 @@ 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"
|
||||||
|
:generation-models="generationModels"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 +987,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,6 +1019,8 @@ onMounted(restoreDraft)
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AppConfirmDialog ref="confirmDialogRef" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -759,4 +1174,47 @@ onMounted(restoreDraft)
|
|||||||
.wizard-primary-action {
|
.wizard-primary-action {
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-footer {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.step-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-connector {
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-primary-action {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
425
frontend/src/views/data-process/DataProcessDetailView.vue
Normal file
425
frontend/src/views/data-process/DataProcessDetailView.vue
Normal 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>
|
||||||
@@ -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,7 +89,7 @@ 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="新建数据处理任务"
|
||||||
@@ -106,33 +97,8 @@ function formatDateTime(value?: string) {
|
|||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
<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 advancedModelSettingsOpen = ref(false)
|
||||||
|
|
||||||
|
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>{{ content }}</code> 引用当前正在处理的数据内容。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="advanced-settings-toggle"
|
||||||
|
:aria-expanded="advancedModelSettingsOpen"
|
||||||
|
@click="advancedModelSettingsOpen = !advancedModelSettingsOpen"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>高级设置</strong>
|
||||||
|
<small>温度、最大输出长度与格式约束</small>
|
||||||
|
</span>
|
||||||
|
<i class="fa" :class="advancedModelSettingsOpen ? 'fa-chevron-up' : 'fa-chevron-down'" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="advancedModelSettingsOpen" class="advanced-settings-panel">
|
||||||
|
<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>
|
||||||
|
</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: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-toggle {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #344054;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e5ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8f7ff;
|
||||||
|
border-color: #b7b2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #8a93a3;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #7b8495;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-settings-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: -8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e5ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 20px;
|
||||||
|
padding: 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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
565
frontend/src/views/data-process/create/SourceUploadStep.vue
Normal file
565
frontend/src/views/data-process/create/SourceUploadStep.vue
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { UploadFile } from 'element-plus'
|
||||||
|
import type { ExternalDataSource, ProcessType } from './types'
|
||||||
|
|
||||||
|
interface UploadedSourceFile {
|
||||||
|
uid: string | number
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
processType: ProcessType
|
||||||
|
uploadedFiles: UploadedSourceFile[]
|
||||||
|
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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,75 @@
|
|||||||
export type ProcessType = 'structured' | 'unstructured'
|
export type ProcessType = 'structured' | 'unstructured' | 'external'
|
||||||
|
|
||||||
export type StepId = 'create' | 'preview' | 'generate' | 'results'
|
export type StepId = 'create' | '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 SourceLine {
|
export interface SourceLine {
|
||||||
number: number
|
number: number
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />下载
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
121
frontend/src/views/dataset/datasetRecords.ts
Normal file
121
frontend/src/views/dataset/datasetRecords.ts
Normal 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('当前文件不支持逐条编辑')
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -1,91 +1,184 @@
|
|||||||
<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 { createDimension, getDimensionList, 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 WIZARD_STEPS = [
|
||||||
|
{ title: '任务配置', description: '选择模型、算力与评测数据' },
|
||||||
|
{ 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 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' }],
|
ruleMode: 'new',
|
||||||
model_id: [{ required: true, message: '请选择评测模型', trigger: 'change' }],
|
dimension_id: '',
|
||||||
gpu_id: [{ required: true, message: '请选择 GPU', trigger: 'change' }],
|
newDimension: {
|
||||||
dimension_id: [{ required: true, message: '请选择评测维度', trigger: 'change' }],
|
type: '',
|
||||||
}
|
description: '',
|
||||||
|
eval_model: '',
|
||||||
|
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')
|
watch(
|
||||||
const showDataset = computed(() => form.data_source === 'dataset')
|
() => ruleForm.value.newDimension,
|
||||||
|
() => {
|
||||||
/** 选中维度详情 */
|
createdDimensionId.value = ''
|
||||||
const selectedDimension = computed(() =>
|
},
|
||||||
dimensions.value.find((d) => d.id == form.dimension_id),
|
{ 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(),
|
getDimensionList(),
|
||||||
getDatasetList(),
|
getDatasetList(),
|
||||||
getSystemInfo(),
|
getSystemInfo(),
|
||||||
])
|
getModelList(),
|
||||||
trainedModels.value = models?.models || []
|
])
|
||||||
dimensions.value = dims || []
|
|
||||||
evalDatasets.value = (allDs || []).filter((d: DatasetItem) => d.type === 'eval')
|
if (results[0].status === 'fulfilled') trainedModels.value = results[0].value?.models || []
|
||||||
gpus.value = sys?.gpu || []
|
if (results[1].status === 'fulfilled') dimensions.value = results[1].value || []
|
||||||
} catch {
|
if (results[2].status === 'fulfilled') {
|
||||||
// ignore
|
evalDatasets.value = (results[2].value || []).filter((dataset) => dataset.type === 'eval')
|
||||||
}
|
}
|
||||||
|
if (results[3].status === 'fulfilled') gpus.value = results[3].value?.gpu || []
|
||||||
|
if (results[4].status === 'fulfilled') {
|
||||||
|
evalModels.value = (results[4].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.newDimension
|
||||||
|
const payload: Partial<Dimension> = {
|
||||||
|
name: `Custom_Dim_${Date.now()}`,
|
||||||
|
type: draft.type,
|
||||||
|
description: draft.description,
|
||||||
|
eval_model: draft.type === 'text_similarity' ? undefined : draft.eval_model,
|
||||||
|
eval_method: draft.eval_method,
|
||||||
|
eval_prompt: draft.type === 'text_similarity' ? undefined : draft.eval_prompt,
|
||||||
|
is_active: draft.is_active,
|
||||||
|
is_default: draft.is_default,
|
||||||
|
create_time: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (draft.type === 'text_similarity') {
|
||||||
|
payload.bleu_n = draft.bleu_n
|
||||||
|
payload.output_precision = draft.output_precision
|
||||||
|
}
|
||||||
|
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 (ruleForm.value.ruleMode === 'baseline') return ''
|
||||||
|
if (ruleForm.value.ruleMode === 'existing') return ruleForm.value.dimension_id
|
||||||
|
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) => {
|
const ruleValid = await ruleStepRef.value?.validate()
|
||||||
if (!valid) return
|
if (!ruleValid) {
|
||||||
submitting.value = true
|
ElMessage.warning('请检查并完善评测规则')
|
||||||
try {
|
return
|
||||||
await startEval({
|
}
|
||||||
eval_task_name: form.eval_task_name,
|
|
||||||
eval_type: form.eval_type,
|
submitting.value = true
|
||||||
model_id: form.model_id,
|
try {
|
||||||
gpu_id: form.gpu_id,
|
const dimensionId = await resolveDimensionId()
|
||||||
dataset_id: form.dataset_id,
|
await startEval({
|
||||||
dimension_id: form.dimension_id,
|
eval_task_name: taskForm.value.eval_task_name,
|
||||||
data_source: form.data_source,
|
eval_type: ruleForm.value.ruleMode === 'baseline' ? 'baseline' : 'custom',
|
||||||
leaderboard: form.leaderboard,
|
model_id: taskForm.value.model_id,
|
||||||
})
|
gpu_id: taskForm.value.gpu_id,
|
||||||
ElMessage.success('评测任务已启动')
|
dataset_id: taskForm.value.data_source === 'dataset' ? taskForm.value.dataset_id : '',
|
||||||
router.push('/model-eval')
|
dimension_id: dimensionId,
|
||||||
} catch {
|
data_source: taskForm.value.data_source,
|
||||||
// ignore
|
leaderboard: taskForm.value.leaderboard,
|
||||||
} finally {
|
})
|
||||||
submitting.value = false
|
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
|
||||||
|
const taskValid = await taskStepRef.value?.validate()
|
||||||
|
if (!taskValid) {
|
||||||
|
ElMessage.warning('请检查并完善任务配置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentStep.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (submitting.value) return
|
||||||
|
currentStep.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
@@ -96,82 +189,251 @@ onMounted(loadData)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageCard title="新建评测">
|
<PageCard title="新建评测任务" subtitle="完成任务配置与评测规则设置后启动评测">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" style="max-width: 720px">
|
<div class="create-wizard-layout">
|
||||||
<el-form-item label="任务名称" prop="eval_task_name">
|
<main v-loading="loading" class="wizard-main">
|
||||||
<el-input v-model="form.eval_task_name" placeholder="请输入任务名称" maxlength="50" show-word-limit />
|
<div class="wizard-steps-container" aria-label="评测任务创建步骤">
|
||||||
</el-form-item>
|
<div class="custom-wizard-steps">
|
||||||
|
<div
|
||||||
|
v-for="(step, index) in WIZARD_STEPS"
|
||||||
|
:key="step.title"
|
||||||
|
class="step-item"
|
||||||
|
:class="{
|
||||||
|
'is-active': currentStep === index,
|
||||||
|
'is-completed': currentStep > index,
|
||||||
|
}"
|
||||||
|
:aria-current="currentStep === index ? 'step' : undefined"
|
||||||
|
>
|
||||||
|
<div v-if="index !== 0" class="step-connector"></div>
|
||||||
|
<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>
|
||||||
|
</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"
|
||||||
|
:gpus="gpus"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="submitting"
|
||||||
|
/>
|
||||||
|
<EvalRuleSetupStep
|
||||||
|
v-else
|
||||||
|
ref="ruleStepRef"
|
||||||
|
v-model="ruleForm"
|
||||||
|
:dimensions="dimensions"
|
||||||
|
:eval-models="evalModels"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="submitting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<el-form-item label="评测模型" prop="model_id">
|
<footer class="wizard-footer">
|
||||||
<el-select v-model="form.model_id" placeholder="请选择已训练模型" filterable style="width: 100%">
|
<el-button
|
||||||
<el-option
|
v-if="currentStep === 1"
|
||||||
v-for="m in trainedModels"
|
class="footer-back"
|
||||||
:key="m.id"
|
:disabled="submitting"
|
||||||
:label="m.name"
|
@click="handleBack"
|
||||||
:value="m.id"
|
>
|
||||||
:disabled="!m.merged"
|
<i class="fa fa-arrow-left footer-button-icon" />返回:任务配置
|
||||||
/>
|
</el-button>
|
||||||
</el-select>
|
<el-button v-else class="footer-back" :disabled="submitting" @click="handleCancel">
|
||||||
</el-form-item>
|
取消
|
||||||
|
</el-button>
|
||||||
|
|
||||||
<el-form-item label="选择 GPU" prop="gpu_id">
|
<el-button
|
||||||
<el-select v-model="form.gpu_id" placeholder="请选择 GPU" style="width: 100%">
|
v-if="currentStep === 0"
|
||||||
<el-option v-for="(g, idx) in gpus" :key="idx" :label="`${g.name} (GPU ${idx})`" :value="idx" />
|
type="primary"
|
||||||
</el-select>
|
:disabled="loading || submitting"
|
||||||
</el-form-item>
|
@click="handleNext"
|
||||||
|
>
|
||||||
<el-form-item label="数据来源">
|
下一步:评测规则<i class="fa fa-arrow-right footer-button-icon is-right" />
|
||||||
<el-radio-group v-model="form.data_source">
|
</el-button>
|
||||||
<el-radio value="dataset">评测数据集</el-radio>
|
<el-button
|
||||||
<el-radio value="inference">推理结果集</el-radio>
|
v-else
|
||||||
</el-radio-group>
|
type="primary"
|
||||||
</el-form-item>
|
:loading="submitting"
|
||||||
|
:disabled="loading || submitting"
|
||||||
<el-form-item v-if="showDataset" label="评测数据集" prop="dataset_id">
|
@click="handleSubmit"
|
||||||
<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-select>
|
</el-button>
|
||||||
</el-form-item>
|
</footer>
|
||||||
|
</div>
|
||||||
<!-- 评测规则(仅 custom 显示) -->
|
|
||||||
<template v-if="showEvalRules">
|
|
||||||
<el-divider content-position="left">评测规则</el-divider>
|
|
||||||
<el-form-item label="评测维度" prop="dimension_id">
|
|
||||||
<el-select
|
|
||||||
v-model="form.dimension_id"
|
|
||||||
placeholder="请选择评测维度"
|
|
||||||
filterable
|
|
||||||
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: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:first-child {
|
||||||
|
flex: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-connector {
|
||||||
|
flex: 1;
|
||||||
|
height: 2px;
|
||||||
|
margin: 0 20px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.is-active .step-connector,
|
||||||
|
.step-item.is-completed .step-connector {
|
||||||
|
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: 760px) {
|
||||||
|
.wizard-main {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-wizard-steps {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-connector {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-description {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-footer {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
749
frontend/src/views/eval/EvalDetailView.vue
Normal file
749
frontend/src/views/eval/EvalDetailView.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
171
frontend/src/views/eval/create/DimensionFormFields.vue
Normal file
171
frontend/src/views/eval/create/DimensionFormFields.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<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
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
evalModels: ModelItem[]
|
||||||
|
showDescription?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showDescription: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = defineModel<DimensionFormDraft>({ required: true })
|
||||||
|
|
||||||
|
const typeOptions = Object.entries(DIMENSION_TYPE_MAP).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' },
|
||||||
|
)
|
||||||
|
</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="评分最小值">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.method-desc {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
frontend/src/views/eval/create/EvalRuleSetupStep.vue
Normal file
87
frontend/src/views/eval/create/EvalRuleSetupStep.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import DimensionFormFields, { type DimensionFormDraft } from './DimensionFormFields.vue'
|
||||||
|
import type { Dimension, ModelItem } from '@/types'
|
||||||
|
|
||||||
|
export type EvalRuleMode = 'existing' | 'new' | 'baseline'
|
||||||
|
|
||||||
|
export interface EvalRuleSetupDraft {
|
||||||
|
ruleMode: EvalRuleMode
|
||||||
|
dimension_id: string | number
|
||||||
|
newDimension: DimensionFormDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dimensions: Dimension[]
|
||||||
|
evalModels: ModelItem[]
|
||||||
|
loading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = defineModel<EvalRuleSetupDraft>({ required: true })
|
||||||
|
const existingFormRef = ref<FormInstance>()
|
||||||
|
const newFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const existingRules: FormRules = {
|
||||||
|
dimension_id: [{ required: true, message: '请选择评测维度', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
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' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDimension = computed(() =>
|
||||||
|
props.dimensions.find((dimension) => String(dimension.id) === String(form.value.dimension_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
const target = newFormRef.value
|
||||||
|
if (!target) return false
|
||||||
|
try {
|
||||||
|
const valid = await target.validate()
|
||||||
|
return !!valid
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validate })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rule-step">
|
||||||
|
<el-form
|
||||||
|
ref="newFormRef"
|
||||||
|
:model="form.newDimension"
|
||||||
|
:rules="dimensionRules"
|
||||||
|
label-width="130px"
|
||||||
|
class="step-form step-form-wide"
|
||||||
|
:disabled="disabled"
|
||||||
|
scroll-to-error
|
||||||
|
>
|
||||||
|
<DimensionFormFields
|
||||||
|
v-model="form.newDimension"
|
||||||
|
:eval-models="evalModels"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
|
||||||
|
.step-form {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-form-wide {
|
||||||
|
max-width: 920px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
||||||
163
frontend/src/views/eval/create/EvalTaskSetupStep.vue
Normal file
163
frontend/src/views/eval/create/EvalTaskSetupStep.vue
Normal 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>
|
||||||
@@ -13,7 +13,7 @@ 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 type { ModelItem, DatasetItem, GpuInfo } from '@/types'
|
import type { ModelItem, DatasetItem, GpuInfo } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -34,6 +34,7 @@ const form = reactive({
|
|||||||
template: 'qwen',
|
template: 'qwen',
|
||||||
train_method: 'lora' as 'lora' | 'full',
|
train_method: 'lora' as 'lora' | 'full',
|
||||||
train_dataset_id: '' as string | number,
|
train_dataset_id: '' as string | number,
|
||||||
|
auto_merge: false,
|
||||||
// 训练参数
|
// 训练参数
|
||||||
batch_size: 1,
|
batch_size: 1,
|
||||||
learning_rate: 0.0001,
|
learning_rate: 0.0001,
|
||||||
@@ -47,6 +48,13 @@ const form = reactive({
|
|||||||
lora_alpha: 16, // 修复原项目 lora_alpha 默认值不一致 bug
|
lora_alpha: 16, // 修复原项目 lora_alpha 默认值不一致 bug
|
||||||
lora_dropout: 0.1,
|
lora_dropout: 0.1,
|
||||||
lora_rank: 8,
|
lora_rank: 8,
|
||||||
|
// 量化参数
|
||||||
|
quantization_bit: 0, // 训练时量化(QLoRA):0=不量化
|
||||||
|
export_quantized: false, // 训练后是否导出量化模型
|
||||||
|
quant_method: 'bnb', // 导出量化方法
|
||||||
|
quant_bits: 4, // 导出量化位数
|
||||||
|
quant_group_size: 128, // 分组大小(GPTQ/AWQ)
|
||||||
|
export_format: 'Q4_K_M', // GGUF 导出格式
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
@@ -94,6 +102,19 @@ const commandPreview = computed(() => {
|
|||||||
cmd += ` \\\n --lora_dropout ${form.lora_dropout}`
|
cmd += ` \\\n --lora_dropout ${form.lora_dropout}`
|
||||||
cmd += ` \\\n --lora_rank ${form.lora_rank}`
|
cmd += ` \\\n --lora_rank ${form.lora_rank}`
|
||||||
}
|
}
|
||||||
|
if (showLoraParams.value && form.quantization_bit) {
|
||||||
|
cmd += ` \\\n --quantization_bit ${form.quantization_bit}`
|
||||||
|
}
|
||||||
|
if (form.export_quantized) {
|
||||||
|
const method = form.quant_method
|
||||||
|
const bits = form.quant_bits
|
||||||
|
cmd += ` \\\n # 训练后导出量化模型:${method} ${bits}bit`
|
||||||
|
if (method === 'gguf') {
|
||||||
|
cmd += ` \\\n # export_format=${form.export_format}`
|
||||||
|
} else if (method === 'gptq' || method === 'awq') {
|
||||||
|
cmd += ` \\\n # group_size=${form.quant_group_size}`
|
||||||
|
}
|
||||||
|
}
|
||||||
return cmd
|
return cmd
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -131,6 +152,12 @@ function resetParams() {
|
|||||||
lora_alpha: 16,
|
lora_alpha: 16,
|
||||||
lora_dropout: 0.1,
|
lora_dropout: 0.1,
|
||||||
lora_rank: 8,
|
lora_rank: 8,
|
||||||
|
quantization_bit: 0,
|
||||||
|
export_quantized: false,
|
||||||
|
quant_method: 'bnb',
|
||||||
|
quant_bits: 4,
|
||||||
|
quant_group_size: 128,
|
||||||
|
export_format: 'Q4_K_M',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +243,7 @@ async function handleSubmit() {
|
|||||||
train_method: form.train_method,
|
train_method: form.train_method,
|
||||||
gpus: selectedGpus.value,
|
gpus: selectedGpus.value,
|
||||||
train_dataset_id: form.train_dataset_id,
|
train_dataset_id: form.train_dataset_id,
|
||||||
|
auto_merge: form.train_type === 'SFT' && form.auto_merge,
|
||||||
output_model_name: form.name,
|
output_model_name: form.name,
|
||||||
batch_size: form.batch_size,
|
batch_size: form.batch_size,
|
||||||
learning_rate: form.learning_rate,
|
learning_rate: form.learning_rate,
|
||||||
@@ -228,6 +256,12 @@ async function handleSubmit() {
|
|||||||
lora_alpha: form.lora_alpha,
|
lora_alpha: form.lora_alpha,
|
||||||
lora_dropout: form.lora_dropout,
|
lora_dropout: form.lora_dropout,
|
||||||
lora_rank: form.lora_rank,
|
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 : '',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
}
|
}
|
||||||
@@ -244,6 +278,7 @@ async function handleSubmit() {
|
|||||||
train_type: form.train_type,
|
train_type: form.train_type,
|
||||||
train_method: form.train_method,
|
train_method: form.train_method,
|
||||||
train_dataset_id: form.train_dataset_id,
|
train_dataset_id: form.train_dataset_id,
|
||||||
|
auto_merge: form.train_type === 'SFT' && form.auto_merge,
|
||||||
output_model_name: form.name,
|
output_model_name: form.name,
|
||||||
gpus: selectedGpus.value,
|
gpus: selectedGpus.value,
|
||||||
batch_size: form.batch_size,
|
batch_size: form.batch_size,
|
||||||
@@ -257,6 +292,12 @@ async function handleSubmit() {
|
|||||||
lora_alpha: form.lora_alpha,
|
lora_alpha: form.lora_alpha,
|
||||||
lora_dropout: form.lora_dropout,
|
lora_dropout: form.lora_dropout,
|
||||||
lora_rank: form.lora_rank,
|
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 : '',
|
||||||
})
|
})
|
||||||
ElMessage.success('训练任务已创建并启动')
|
ElMessage.success('训练任务已创建并启动')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -424,6 +465,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 +712,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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 工具开发中...')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user