Compare commits
7 Commits
ecb138ed25
...
acdad260d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acdad260d7 | ||
|
|
e2eabe3525 | ||
|
|
6f6609dae5 | ||
|
|
c1893cf82c | ||
|
|
ca9e05aa91 | ||
|
|
8aa67003c8 | ||
|
|
855308e5fa |
160
design-qa.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Training Log Detail Design QA
|
||||
|
||||
## Evidence
|
||||
|
||||
- Source visual truth: `docs/superpowers/specs/assets/training-log-detail-option-2.png`
|
||||
- Implementation screenshot: `docs/superpowers/specs/assets/training-log-detail-final-expanded-1440.png`
|
||||
- Collapsed implementation screenshot with global surface: `docs/superpowers/specs/assets/training-log-detail-global-surface-1440-v2.png`
|
||||
- Normalized full-view comparison: `docs/superpowers/specs/assets/training-log-detail-final-comparison-normalized.png`
|
||||
- Focused parameter comparison: `docs/superpowers/specs/assets/training-log-detail-final-comparison-params.png`
|
||||
- White-canvas reference: `docs/superpowers/specs/assets/page-white-canvas-reference.png`
|
||||
- White-canvas implementation: `docs/superpowers/specs/assets/model-edit-white-page-canvas-final-1440.png`
|
||||
- White-canvas normalized comparison: `docs/superpowers/specs/assets/page-white-canvas-comparison.png`
|
||||
- Training-log white-canvas screenshot: `docs/superpowers/specs/assets/training-log-detail-white-page-canvas-1440.png`
|
||||
- Self-surface list screenshot: `docs/superpowers/specs/assets/fine-tune-list-self-surface-final-1440.png`
|
||||
- Default-canvas detail screenshot: `docs/superpowers/specs/assets/training-log-detail-default-canvas-final-1440.png`
|
||||
- Reference/detail comparison: `docs/superpowers/specs/assets/page-surface-reference-detail-comparison.png`
|
||||
- Create-page duplicate-surface evidence: `docs/superpowers/specs/assets/fine-tune-create-double-surface-before-1440.png`
|
||||
- Create-page single-surface evidence: `docs/superpowers/specs/assets/fine-tune-create-single-surface-final-1440.png`
|
||||
- Route-transition flash reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-ea775434-828f-4bca-9714-72887faa9af9.png`
|
||||
- Route-transition final detail frame: `docs/superpowers/specs/assets/route-transition-detail-final.png`
|
||||
- Viewport: 1440 × 1024; comparison content normalized to 1200 × 800 after removing the existing 240px sidebar and 60px header from the implementation capture.
|
||||
- State: `finance-sft-001`, completed, mock data loaded, training parameters expanded.
|
||||
|
||||
## Full-view comparison
|
||||
|
||||
The implementation preserves the selected two-column hierarchy: task and dataset information occupy the wide left track, runtime facts use the narrow right rail, and training parameters continue as a full-width disclosure section. The selected mock omitted the product shell, so the comparison intentionally crops the existing sidebar and header rather than treating them as design drift.
|
||||
|
||||
The global product mode now uses two intentional surface modes. Form and detail routes render inside one `#ffffff` page canvas with 16px radius and 24px content padding. List routes that already own a white table/card surface render that surface directly on the `#f3f5f8` application background, avoiding a redundant white layer.
|
||||
|
||||
## Required fidelity surfaces
|
||||
|
||||
- Fonts and typography: Existing system font stack is preserved. Heading, label, value, and muted-copy hierarchy match the selected direction; output model uses body-level contrast after iteration 1, and all “未配置” values use `#64748b` on white after iteration 2.
|
||||
- Spacing and layout rhythm: 24px main gap, 16px section gap, 12px surface radius, and light row separators match the selected composition. The existing application shell reduces usable content width, but normalized proportions remain aligned.
|
||||
- Colors and tokens: Indigo accent, Slate text, success status, `#f3f5f8` page background, and white content surfaces are consistent with the current product.
|
||||
- Image and icon fidelity: The screen contains no raster imagery. Existing Font Awesome icons are retained to match the repository's icon system; no placeholder, emoji, CSS drawing, or handcrafted SVG was introduced.
|
||||
- Copy and content: Task name, status, model, date, duration, dataset metadata, storage, SFT, LoRA, and missing-value copy match the selected design and actual mock data.
|
||||
|
||||
## Interaction and responsive checks
|
||||
|
||||
- Parameter disclosure changed from `aria-expanded="false"` to `true` after activation, and the expanded content became visible.
|
||||
- At 1000px viewport width, the overview changed to one column and the document had no horizontal overflow.
|
||||
- At 700px viewport width, dataset metrics and parameter rows changed to one column and the document had no horizontal overflow.
|
||||
- Browser console: no errors. One existing Element Plus `el-link` underline deprecation warning was emitted by the login flow and is unrelated to this page.
|
||||
|
||||
## Comparison history
|
||||
|
||||
### Iteration 1 — blocked
|
||||
|
||||
- [P2] The implementation added a visible “基础训练参数” heading that did not exist in the selected mock, creating extra vertical space.
|
||||
- [P2] “暂未生成” was styled too faintly compared with the selected design.
|
||||
|
||||
Fixes:
|
||||
|
||||
- Removed the redundant visible base-parameter heading while retaining an accessible region label.
|
||||
- Restored body-level contrast for “暂未生成”.
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
- `docs/superpowers/specs/assets/training-log-detail-final-comparison-normalized.png`
|
||||
- `docs/superpowers/specs/assets/training-log-detail-final-comparison-params.png`
|
||||
|
||||
### Iteration 2 — blocked
|
||||
|
||||
- [P2] “未配置” values used `#94a3b8` on white, below WCAG AA contrast for 14px text.
|
||||
|
||||
Fix:
|
||||
|
||||
- Updated muted values to `#64748b`; the regression check now calculates the contrast ratio and requires at least 4.5:1.
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
- Browser computed color: `rgb(100, 116, 139)`.
|
||||
- Browser console: no errors.
|
||||
|
||||
### Iteration 3 — passed
|
||||
|
||||
No actionable P0/P1/P2 differences remain. The retained P3 difference is that the generated mock does not include the real product sidebar/header; this is an intentional constraint because the existing shell is shared by every page.
|
||||
|
||||
### Iteration 4 — clarified global page canvas, passed
|
||||
|
||||
- [P1] The earlier interpretation left the route content directly on the gray layout background and only made individual cards white. The clarified reference requires a single white page canvas behind every route.
|
||||
|
||||
Fixes:
|
||||
|
||||
- Split the shell and page tokens into `--app-shell-bg: #f3f5f8` and `--app-page-bg: #ffffff`.
|
||||
- Added one global `.page-canvas` around every route in `MainLayout.vue`.
|
||||
- Added 16px outer gutter, 16px canvas radius, 24px canvas padding, and a subtle canvas shadow.
|
||||
- Flattened a route-root `PageCard` to prevent a duplicate large card layer.
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
- `docs/superpowers/specs/assets/page-white-canvas-comparison.png`
|
||||
- Browser computed canvas: white background, 16px radius, 24px padding; outer shell: `rgb(243, 245, 248)`.
|
||||
- Both the model-edit page and training-log page render inside the same global white canvas without horizontal overflow.
|
||||
|
||||
### Iteration 5 — corrected list-page surface ownership, passed
|
||||
|
||||
- [P1] Applying the white page canvas to every route created a redundant layer on list pages because `DataTablePage`, model evaluation, and model management already provide their own white root card.
|
||||
|
||||
Fixes:
|
||||
|
||||
- Added explicit `pageSurface: 'self'` metadata to each self-surfaced list route: model tuning, model evaluation, model inference, model management, data processing, and dataset management.
|
||||
- Added `.page-canvas.is-self-surface` to remove the outer canvas padding, radius, background, and shadow only for those routes.
|
||||
- Preserved the default white canvas for training-log, create, edit, preview, chat, and result routes.
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
- `docs/superpowers/specs/assets/fine-tune-list-self-surface-final-1440.png`
|
||||
- `docs/superpowers/specs/assets/training-log-detail-default-canvas-final-1440.png`
|
||||
- `docs/superpowers/specs/assets/page-surface-reference-detail-comparison.png`
|
||||
- Browser computed list state: transparent outer canvas, 0px padding/radius, no shadow; white 12px-radius list card on `rgb(243, 245, 248)` shell.
|
||||
- Browser computed detail state: white outer canvas, 24px padding, 16px radius, subtle shadow.
|
||||
- Both states have no horizontal overflow and no console errors at 1440 × 900.
|
||||
|
||||
### Iteration 6 — flattened wrapped root PageCard, passed
|
||||
|
||||
- [P1] The training-task creation route wraps its root `PageCard` in `.fine-tune-create`. The earlier selector only matched a `PageCard` directly under `.page-canvas`, so this page retained a second white background, 12px radius, and card shadow.
|
||||
|
||||
Fixes:
|
||||
|
||||
- Added an explicit `.page-card-host` marker to the training-task creation route root; the layout flattens only a directly rendered root `PageCard` or a `PageCard` inside that explicit host.
|
||||
- Root `PageCard` now uses a transparent background, 0px radius, no shadow, and no bottom margin while preserving its header/body layout.
|
||||
- Kept the selector excluded from `.is-self-surface`, so list cards retain their own white background, 12px radius, and shadow.
|
||||
- Rejected a generic one-level descendant selector because it would also match the training-log parameter card.
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
- `docs/superpowers/specs/assets/fine-tune-create-double-surface-before-1440.png`
|
||||
- `docs/superpowers/specs/assets/fine-tune-create-single-surface-final-1440.png`
|
||||
- Browser computed create-page root card: transparent background, 0px radius, no shadow; outer canvas remains white with 24px padding.
|
||||
- Browser computed list-page card remains white with 12px radius and subtle shadow on a transparent outer canvas.
|
||||
- Browser computed training-log parameter card remains white with 12px radius and subtle shadow, confirming that internal business cards are not flattened.
|
||||
- Both pages have no horizontal overflow; create-page console has no errors.
|
||||
|
||||
### Iteration 7 — removed page-level opacity transition, passed
|
||||
|
||||
- [P1] When navigating from a self-surface list to a default-canvas secondary page, `route.meta.pageSurface` changed immediately while the old list remained for the 150ms `out-in` leave animation. The result was a semi-transparent old list rendered inside the new white canvas.
|
||||
|
||||
Fixes:
|
||||
|
||||
- Removed the page-level Vue `transition` wrapper from `MainLayout.vue`.
|
||||
- Removed the `.fade-enter-*` and `.fade-leave-*` opacity rules.
|
||||
- Preserved local component animations such as dialogs, disclosures, and the selected-row batch bar.
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
- Source flash frame: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-ea775434-828f-4bca-9714-72887faa9af9.png`.
|
||||
- Final detail frame: `docs/superpowers/specs/assets/route-transition-detail-final.png`.
|
||||
- Immediate state after list → create: old list absent, route-root opacity `1`, zero `.fade-*` transition elements, correct default canvas.
|
||||
- Immediate state after create → list: old create page absent, route-root opacity `1`, zero `.fade-*` transition elements, correct self-surface canvas.
|
||||
- Immediate state after list → training log: old list absent, route-root opacity `1`, zero `.fade-*` transition elements, correct default canvas.
|
||||
- All three paths had no horizontal overflow; browser console had no errors.
|
||||
|
||||
## Build evidence gap
|
||||
|
||||
The `type-check` script now uses project-reference mode (`vue-tsc -b --noEmit`) so it no longer reports a false pass. `npm run type-check` and `npm run build` remain blocked by pre-existing TypeScript errors in `src/mock/adapter.ts`, `FineTuneCreateView.vue`, and `FineTuneListView.vue`; no remaining error points to `TrainingLogView.vue` or the page-surface files. `npx vite build` succeeds, proving the updated UI bundles for production.
|
||||
|
||||
Design-QA final result: passed
|
||||
|
||||
final result: passed
|
||||
458
docs/superpowers/plans/2026-07-10-data-process-create-wizard.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# Data Process Create Wizard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将 `/data-process/create` 实现为固定四步向导,并在第二步提供“右侧选择切片、左侧自动定位并高亮原文”的可编辑对照体验。
|
||||
|
||||
**Architecture:** `DataProcessCreateView.vue` 只负责向导状态、步骤切换和跨步骤数据;每个步骤拆成独立 Vue 组件。源文定位和切片生成由纯 TypeScript 模块负责,第二步组件只消费偏移范围并同步滚动、高亮和编辑状态。现有 Vue 3、Element Plus、SCSS 和 Font Awesome 继续使用,不引入新依赖。
|
||||
|
||||
**Tech Stack:** Vue 3.5、TypeScript 5.7、Vite 6、Element Plus 2.9、SCSS、Node.js 回归脚本、`vue-tsc`。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 顶部固定四步:`创建任务`、`数据预览`、`开始生成`、`结果编辑与保存`。
|
||||
- 结构化和非结构化类型不得改变步骤数量。
|
||||
- 第二步桌面端左侧约 58% 为只读源文件,右侧约 42% 为切片或记录列表及编辑器。
|
||||
- 点击右侧条目时,左侧必须定位并高亮 `sourceStart` 到 `sourceEnd` 的原始范围。
|
||||
- 编辑切片不得改写源文件;来源映射始终指向初始原文。
|
||||
- 每一步只能有一个主操作,不得同时出现“下一步”和“开始生成”等竞争动作。
|
||||
- 页面继续使用现有全局白色页面画布,不新增整页嵌套白卡。
|
||||
- 不增加第三方依赖。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 建立四步向导回归测试
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
- Modify: `frontend/package.json`
|
||||
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Vue SFC 源码、`@vue/compiler-sfc`、`@vue/compiler-dom`。
|
||||
- Produces: `npm run test:data-process-wizard`,验证固定步骤、组件边界、对照定位标记和底部唯一主操作。
|
||||
|
||||
- [ ] **Step 1: 写入当前实现必然失败的结构回归检查**
|
||||
|
||||
```js
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewSource = await readFile(
|
||||
path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue'),
|
||||
'utf8',
|
||||
)
|
||||
const previewSource = await readFile(
|
||||
path.resolve(scriptDir, '../src/views/data-process/create/PreviewCompareStep.vue'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.match(viewSource, /const WIZARD_STEPS = \[/)
|
||||
for (const title of ['创建任务', '数据预览', '开始生成', '结果编辑与保存']) {
|
||||
assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`)
|
||||
}
|
||||
assert.doesNotMatch(viewSource, /all\.filter|steps\s*=\s*computed/)
|
||||
assert.match(previewSource, /class="source-viewer"/)
|
||||
assert.match(previewSource, /class="preview-workspace"/)
|
||||
assert.match(previewSource, /scrollIntoView/)
|
||||
assert.match(previewSource, /sourceStart/)
|
||||
assert.match(previewSource, /sourceEnd/)
|
||||
|
||||
const { descriptor } = parseSfc(viewSource)
|
||||
assert.ok(descriptor.template?.content.includes('TaskSetupStep'))
|
||||
assert.ok(descriptor.template?.content.includes('PreviewCompareStep'))
|
||||
assert.ok(descriptor.template?.content.includes('GenerationStep'))
|
||||
assert.ok(descriptor.template?.content.includes('ResultEditorStep'))
|
||||
|
||||
console.log('数据处理四步向导回归检查通过')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `package.json` 注册命令**
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试并确认失败原因正确**
|
||||
|
||||
Run: `cd frontend && npm run test:data-process-wizard`
|
||||
|
||||
Expected: FAIL,首先因 `PreviewCompareStep.vue` 不存在或固定步骤断言不成立而失败。
|
||||
|
||||
### Task 2: 建立向导类型、草稿状态和来源映射模型
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/data-process/create/types.ts`
|
||||
- Create: `frontend/src/views/data-process/create/previewModel.ts`
|
||||
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 上传文件解析出的字符串。
|
||||
- Produces: `ProcessType`、`StepId`、`PreviewItem`、`ResultItem`、`DataProcessDraft`;`buildPreviewItems(sourceText, processType)` 和 `sourceLines(sourceText)`。
|
||||
|
||||
- [ ] **Step 1: 在回归脚本增加模型文件和关键字段断言**
|
||||
|
||||
```js
|
||||
const typesSource = await readFile(
|
||||
path.resolve(scriptDir, '../src/views/data-process/create/types.ts'),
|
||||
'utf8',
|
||||
)
|
||||
const modelSource = await readFile(
|
||||
path.resolve(scriptDir, '../src/views/data-process/create/previewModel.ts'),
|
||||
'utf8',
|
||||
)
|
||||
for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) {
|
||||
assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`)
|
||||
}
|
||||
assert.match(modelSource, /export function buildPreviewItems/)
|
||||
assert.match(modelSource, /export function sourceLines/)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 定义稳定类型**
|
||||
|
||||
```ts
|
||||
export type ProcessType = 'structured' | 'unstructured'
|
||||
export type StepId = 'create' | 'preview' | 'generate' | 'results'
|
||||
|
||||
export interface PreviewItem {
|
||||
id: string
|
||||
originalContent: string
|
||||
editedContent: string
|
||||
sourceStart: number | null
|
||||
sourceEnd: number | null
|
||||
sourceStartLine: number | null
|
||||
sourceEndLine: number | null
|
||||
tokenCount: number
|
||||
status: 'original' | 'modified' | 'manual' | 'invalid'
|
||||
}
|
||||
|
||||
export interface ResultItem {
|
||||
id: string
|
||||
instruction: string
|
||||
input: string
|
||||
output: string
|
||||
status: 'valid' | 'modified' | 'invalid'
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现可重复的来源偏移生成**
|
||||
|
||||
```ts
|
||||
export function buildPreviewItems(sourceText: string, processType: ProcessType): PreviewItem[] {
|
||||
const lines = sourceText.split('\n')
|
||||
const groupSize = processType === 'structured' ? 1 : 3
|
||||
let cursor = 0
|
||||
const ranges = lines.map((line, index) => {
|
||||
const start = cursor
|
||||
cursor += line.length + (index < lines.length - 1 ? 1 : 0)
|
||||
return { line, lineNumber: index + 1, start, end: start + line.length }
|
||||
})
|
||||
|
||||
const items: PreviewItem[] = []
|
||||
for (let index = 0; index < ranges.length; index += groupSize) {
|
||||
const group = ranges.slice(index, index + groupSize)
|
||||
if (!group.length || group.every((item) => !item.line.trim())) continue
|
||||
const content = group.map((item) => item.line).join('\n')
|
||||
items.push({
|
||||
id: `preview-${items.length + 1}`,
|
||||
originalContent: content,
|
||||
editedContent: content,
|
||||
sourceStart: group[0].start,
|
||||
sourceEnd: group[group.length - 1].end,
|
||||
sourceStartLine: group[0].lineNumber,
|
||||
sourceEndLine: group[group.length - 1].lineNumber,
|
||||
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
|
||||
status: 'original',
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行回归检查和类型检查**
|
||||
|
||||
Run: `cd frontend && npm run test:data-process-wizard && npm run type-check`
|
||||
|
||||
Expected: 回归测试继续因组件未完成而失败;`previewModel.ts` 和 `types.ts` 不产生 TypeScript 错误。
|
||||
|
||||
### Task 3: 实现向导壳层和第一步创建任务
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/data-process/create/TaskSetupStep.vue`
|
||||
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
|
||||
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ProcessType`、Element Plus 表单与上传组件。
|
||||
- Produces: `TaskSetupStep` 的 `v-model:name`、`v-model:description`、`v-model:processType`、`file-change`、`remove-file` 事件;父页面提供固定 `WIZARD_STEPS` 和统一底部操作。
|
||||
|
||||
- [ ] **Step 1: 将父页面步骤定义改为不可变四步**
|
||||
|
||||
```ts
|
||||
const WIZARD_STEPS = [
|
||||
{ id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' },
|
||||
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
|
||||
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
|
||||
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
|
||||
] as const
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建第一步组件,保留现有校验并改为视觉选择块**
|
||||
|
||||
```ts
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
description: string
|
||||
processType: ProcessType
|
||||
file: File | null
|
||||
fileCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:description': [value: string]
|
||||
'update:processType': [value: ProcessType]
|
||||
'file-change': [file: UploadFile]
|
||||
'remove-file': []
|
||||
}>()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在父页面统一步骤导航和底部动作文案**
|
||||
|
||||
```ts
|
||||
const primaryActionLabel = computed(() => ({
|
||||
create: '继续:数据预览',
|
||||
preview: '确认预览并继续',
|
||||
generate: generation.progress === 100 ? '查看生成结果' : '开始生成',
|
||||
results: '保存任务',
|
||||
}[currentStepId.value]))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行回归检查和类型检查**
|
||||
|
||||
Run: `cd frontend && npm run test:data-process-wizard && npm run type-check`
|
||||
|
||||
Expected: 回归测试因后续三个组件缺失而失败;第一步相关代码通过类型检查。
|
||||
|
||||
### Task 4: 实现左右源文件与切片同步预览
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
|
||||
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
|
||||
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `sourceText: string`、`items: PreviewItem[]`、`selectedId: string | null`、`processType: ProcessType`。
|
||||
- Produces: `update:selectedId`、`update:item-content`、`restore:item`;选中条目变化时调用 `scrollIntoView({ block: 'center' })`。
|
||||
|
||||
- [ ] **Step 1: 增加源文范围与选中态的结构断言**
|
||||
|
||||
```js
|
||||
for (const marker of [
|
||||
'source-viewer',
|
||||
'source-line',
|
||||
'is-highlighted',
|
||||
'preview-item',
|
||||
'preview-editor',
|
||||
'scrollIntoView',
|
||||
]) {
|
||||
assert.ok(previewSource.includes(marker), `第二步缺少结构:${marker}`)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 通过行偏移判断高亮范围**
|
||||
|
||||
```ts
|
||||
function isLineHighlighted(lineStart: number, lineEnd: number) {
|
||||
if (!selectedItem.value || selectedItem.value.sourceStart == null || selectedItem.value.sourceEnd == null) {
|
||||
return false
|
||||
}
|
||||
return lineEnd >= selectedItem.value.sourceStart
|
||||
&& lineStart <= selectedItem.value.sourceEnd
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 选中切片后定位首个高亮行**
|
||||
|
||||
```ts
|
||||
watch(selectedItem, async (item) => {
|
||||
if (!item || item.sourceStart == null) return
|
||||
await nextTick()
|
||||
sourceViewerRef.value
|
||||
?.querySelector<HTMLElement>(`[data-offset="${item.sourceStart}"]`)
|
||||
?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编辑时只更新 `editedContent` 和状态**
|
||||
|
||||
```ts
|
||||
function updateContent(item: PreviewItem, value: string) {
|
||||
emit('update:item-content', item.id, value)
|
||||
}
|
||||
```
|
||||
|
||||
父组件处理事件时不得修改 `sourceText`、`sourceStart` 或 `sourceEnd`:
|
||||
|
||||
```ts
|
||||
function updatePreviewContent(id: string, value: string) {
|
||||
const item = draft.previewItems.find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
item.editedContent = value
|
||||
item.status = value === item.originalContent ? 'original' : 'modified'
|
||||
draft.dirty = true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 完成搜索、仅看已修改、上一片、下一片和恢复原文**
|
||||
|
||||
Run: `cd frontend && npm run test:data-process-wizard && npm run type-check`
|
||||
|
||||
Expected: 第二步结构断言通过,类型检查通过;回归测试只因第三、四步组件缺失而失败。
|
||||
|
||||
### Task 5: 实现生成与结果编辑两个独立步骤
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/data-process/create/GenerationStep.vue`
|
||||
- Create: `frontend/src/views/data-process/create/ResultEditorStep.vue`
|
||||
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
|
||||
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 任务摘要、预览条目、生成状态和结果条目。
|
||||
- Produces: `start`、`stop`、`retry`、`update:result`、`restore:result`、`save` 事件。
|
||||
|
||||
- [ ] **Step 1: 生成步骤只保留摘要、进度与状态**
|
||||
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
start: []
|
||||
stop: []
|
||||
retry: []
|
||||
}>()
|
||||
```
|
||||
|
||||
生成完成前底部唯一主操作为 `开始生成`;生成进行中为禁用的 `正在生成`;完成后变为 `查看生成结果`。
|
||||
|
||||
- [ ] **Step 2: 清理并托管模拟生成计时器**
|
||||
|
||||
```ts
|
||||
let generationTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopGenerationTimer() {
|
||||
if (generationTimer) clearInterval(generationTimer)
|
||||
generationTimer = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopGenerationTimer)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 将预览条目转换为结构化结果字段**
|
||||
|
||||
```ts
|
||||
function createResults(items: PreviewItem[]): ResultItem[] {
|
||||
return items.slice(0, 12).map((item, index) => ({
|
||||
id: `result-${index + 1}`,
|
||||
instruction: item.editedContent.split('\n')[0] || `数据条目 ${index + 1}`,
|
||||
input: '',
|
||||
output: item.editedContent.split('\n').slice(1).join('\n') || item.editedContent,
|
||||
status: 'valid',
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 使用左侧结果列表和右侧字段编辑器替代原始 JSON 文本框**
|
||||
|
||||
```ts
|
||||
function validateResult(item: ResultItem) {
|
||||
item.error = item.instruction.trim() && item.output.trim() ? undefined : '指令和输出不能为空'
|
||||
item.status = item.error ? 'invalid' : 'modified'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行回归、页面表面和类型检查**
|
||||
|
||||
Run: `cd frontend && npm run test:data-process-wizard && npm run test:page-surface && npm run type-check`
|
||||
|
||||
Expected: 三项检查全部 PASS。
|
||||
|
||||
### Task 6: 完成视觉实现、响应式和浏览器验收
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
|
||||
- Modify: `frontend/src/views/data-process/create/TaskSetupStep.vue`
|
||||
- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
|
||||
- Modify: `frontend/src/views/data-process/create/GenerationStep.vue`
|
||||
- Modify: `frontend/src/views/data-process/create/ResultEditorStep.vue`
|
||||
- Test: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 已完成的四步组件和现有全局页面画布。
|
||||
- Produces: 与确认修订稿一致的桌面布局,以及 900px 以下的上下布局。
|
||||
|
||||
- [ ] **Step 1: 落实单层页面、固定步骤和底部操作栏样式**
|
||||
|
||||
```scss
|
||||
.wizard-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 64px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-top: 1px solid #eef0f5;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 落实桌面左右对照和 900px 响应式**
|
||||
|
||||
```scss
|
||||
.preview-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 58fr) minmax(380px, 42fr);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.preview-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启动页面并逐步验证四步交互**
|
||||
|
||||
Run: `cd frontend && npm run dev -- --host 0.0.0.0 --port 6801`
|
||||
|
||||
Browser checks at `http://localhost:6801/data-process/create`:
|
||||
|
||||
1. 第一步上传文本并选择非结构化数据。
|
||||
2. 第二步点击至少三个右侧切片,确认左侧滚动目标和高亮范围变化。
|
||||
3. 修改一个切片并切换前后条目,确认修改状态和内容保留。
|
||||
4. 完成生成并进入第四步,修改结果字段并保存。
|
||||
5. 返回前一步,确认草稿和选中项未丢失。
|
||||
6. 以 1440×1024 和 900px 窄屏分别截图,确认无横向溢出和底部遮挡。
|
||||
|
||||
- [ ] **Step 4: 执行完整验证**
|
||||
|
||||
Run: `cd frontend && npm run test:data-process-wizard && npm run test:page-surface && npm run type-check && npm run build`
|
||||
|
||||
Expected: 所有回归脚本、类型检查和生产构建全部 PASS。
|
||||
|
||||
## Self-Review Result
|
||||
|
||||
- 规格中的四步稳定语义由 Tasks 1、3、5 覆盖。
|
||||
- 左右对照、来源映射、滚动高亮、编辑不改源文件由 Tasks 2、4 覆盖。
|
||||
- 生成状态、计时器清理、结果字段校验由 Task 5 覆盖。
|
||||
- 单层白底、响应式、路由转场连续性和最终验收由 Task 6 覆盖。
|
||||
- 未引入新依赖;计划中所有类型和事件名在前置任务中已有定义。
|
||||
|
||||
126
docs/superpowers/plans/2026-07-10-multi-file-preview-selector.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 多文件预览下拉选择器 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在数据处理向导第二步中支持按文件切换原文和切片,同时保留现有双栏阅读空间。
|
||||
|
||||
**Architecture:** 为每个预览条目记录来源文件 ID;父页面按当前文件筛选原文与条目。预览组件只负责可搜索下拉选择器和当前文件双栏对照,不拼接不同文件的原文。
|
||||
|
||||
**Tech Stack:** Vue 3、TypeScript、Element Plus、SCSS、Node 回归脚本。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 预览主区保持原文与切片的双栏比例,不新增常驻第三栏。
|
||||
- 下拉选择器必须支持 100 个文件的名称筛选。
|
||||
- 切换文件不得丢失其他文件已编辑的切片内容。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 锁定多文件来源映射
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
- Modify: `frontend/src/views/data-process/create/types.ts`
|
||||
- Modify: `frontend/src/views/data-process/create/previewModel.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PreviewItem.sourceFileId: string`。
|
||||
- Produces: `buildPreviewItems(sourceText, processType, sourceFileId)` 为同一文件生成带文件归属的唯一条目。
|
||||
|
||||
- [ ] **Step 1: 写入失败断言**
|
||||
|
||||
```js
|
||||
assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识')
|
||||
assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行失败断言**
|
||||
|
||||
Run: `npm run test:data-process-wizard`
|
||||
Expected: FAIL,提示缺少 `sourceFileId`。
|
||||
|
||||
- [ ] **Step 3: 实现文件归属**
|
||||
|
||||
```ts
|
||||
export interface PreviewItem {
|
||||
sourceFileId: string
|
||||
}
|
||||
|
||||
export function buildPreviewItems(sourceText: string, processType: ProcessType, sourceFileId: string) {
|
||||
// 每个 item 写入 sourceFileId,并以它构造稳定 ID。
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 再次运行回归脚本**
|
||||
|
||||
Run: `npm run test:data-process-wizard`
|
||||
Expected: 新断言通过;仅保留已知的布局失败(如存在)。
|
||||
|
||||
### Task 2: 按文件驱动双栏预览
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PreviewItem.sourceFileId`。
|
||||
- Produces: `activePreviewFile`、`activePreviewItems` 与当前文件选择状态。
|
||||
|
||||
- [ ] **Step 1: 为多文件下拉接线添加失败断言**
|
||||
|
||||
```js
|
||||
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
|
||||
assert.match(viewSource, /buildPreviewItems\(file\.content, processType\.value, String\(file\.uid\)\)/, '预览没有按文件分别生成')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行失败断言**
|
||||
|
||||
Run: `npm run test:data-process-wizard`
|
||||
Expected: FAIL,提示缺少当前预览文件状态。
|
||||
|
||||
- [ ] **Step 3: 最小实现**
|
||||
|
||||
```ts
|
||||
const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value))
|
||||
const activePreviewItems = computed(() => previewItems.value.filter((item) => item.sourceFileId === selectedPreviewFileId.value))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行回归脚本**
|
||||
|
||||
Run: `npm run test:data-process-wizard`
|
||||
Expected: 父页面多文件断言通过。
|
||||
|
||||
### Task 3: 加入可搜索文件下拉框与布局修复
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
|
||||
- Modify: `frontend/src/views/data-process/DataProcessCreateView.vue`
|
||||
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `files`、`selectedFileId`、`items`、`sourceText`。
|
||||
- Produces: `update:selectedFileId` 事件。
|
||||
|
||||
- [ ] **Step 1: 添加失败断言**
|
||||
|
||||
```js
|
||||
assert.match(previewSource, /filterable/, '文件选择器必须可搜索')
|
||||
assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行失败断言**
|
||||
|
||||
Run: `npm run test:data-process-wizard`
|
||||
Expected: FAIL,提示缺少可搜索的文件选择器。
|
||||
|
||||
- [ ] **Step 3: 实现下拉框与响应式样式**
|
||||
|
||||
```vue
|
||||
<el-select filterable :model-value="selectedFileId" @update:model-value="emit('update:selectedFileId', $event)">
|
||||
<el-option v-for="file in files" :key="file.id" :label="file.name" :value="file.id" />
|
||||
</el-select>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 将 `.wizard-content` 设为 `min-height: 0` 并运行验证**
|
||||
|
||||
Run: `npm run test:data-process-wizard && npm run type-check && npm run build`
|
||||
Expected: 三个命令退出码均为 0。
|
||||
@@ -0,0 +1,94 @@
|
||||
# Page Surface Classification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 列表页直接使用自身白色卡片,表单和详情页继续使用主布局提供的白色圆角画布。
|
||||
|
||||
**Architecture:** 使用 Vue Router `meta.pageSurface` 做显式页面表面分类。主布局默认渲染白色画布,仅在 `pageSurface === 'self'` 时切换为透明、无内边距的承载容器。
|
||||
|
||||
**Tech Stack:** Vue 3、Vue Router 4、TypeScript、SCSS、Node.js 回归脚本
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 只给六个自带白色列表卡片的路由声明 `pageSurface: 'self'`。
|
||||
- 其他路由默认继续使用白色页面画布。
|
||||
- 不新增依赖,不修改业务逻辑。
|
||||
- 先写失败测试,再实现最小修复。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 路由级页面表面分类
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scripts/regression-page-surface.mjs`
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Test: `frontend/scripts/regression-page-surface.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Vue Router 当前路由对象的 `route.meta.pageSurface`。
|
||||
- Produces: `pageSurface: 'self'` 路由元数据和 `.page-canvas.is-self-surface` 布局状态。
|
||||
|
||||
- [ ] **Step 1: 写入失败回归测试**
|
||||
|
||||
在 `regression-page-surface.mjs` 中读取 `src/router/index.ts`,断言六个列表路由包含
|
||||
`pageSurface: 'self'`;断言 `MainLayout` 使用 `useRoute()` 和动态类;断言状态样式为:
|
||||
|
||||
```scss
|
||||
.page-canvas.is-self-surface {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试并确认 RED**
|
||||
|
||||
Run: `npm run test:page-surface`
|
||||
|
||||
Expected: FAIL,提示列表路由缺少 `pageSurface: 'self'` 或主布局缺少自表面状态。
|
||||
|
||||
- [ ] **Step 3: 写入最小实现**
|
||||
|
||||
在六个列表路由中加入:
|
||||
|
||||
```ts
|
||||
meta: { title: '页面标题', pageSurface: 'self' },
|
||||
```
|
||||
|
||||
在 `MainLayout.vue` 中使用:
|
||||
|
||||
```ts
|
||||
const route = useRoute()
|
||||
```
|
||||
|
||||
```vue
|
||||
<div
|
||||
class="page-canvas"
|
||||
:class="{ 'is-self-surface': route.meta.pageSurface === 'self' }"
|
||||
>
|
||||
```
|
||||
|
||||
并加入透明承载容器样式。
|
||||
|
||||
- [ ] **Step 4: 运行专项测试并确认 GREEN**
|
||||
|
||||
Run: `npm run test:page-surface`
|
||||
|
||||
Expected: PASS,输出“全局页面背景与内容表面回归检查通过”。
|
||||
|
||||
- [ ] **Step 5: 运行相关回归与生产构建**
|
||||
|
||||
Run: `npm run test:training-log-layout`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Run: `npx vite build`
|
||||
|
||||
Expected: build exit code 0;允许保留项目既有字体解析和 chunk size 警告。
|
||||
|
||||
- [ ] **Step 6: 浏览器视觉验证**
|
||||
|
||||
打开 `/fine-tune`,确认灰色背景上仅有列表自身白色卡片;打开
|
||||
`/training-log/1`,确认白色圆角页面画布仍存在。两个页面均不得水平溢出,控制台不得新增错误。
|
||||
77
docs/superpowers/plans/2026-07-10-preview-slice-edit-mode.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 切片单面板编辑模式 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将数据预览右侧改为列表和编辑器互斥的单面板,并通过保存或取消控制切片内容写回。
|
||||
|
||||
**Architecture:** `PreviewCompareStep.vue` 保留切片筛选、分页和源文件定位,新增组件内编辑模式与临时草稿。父页面仅在收到保存事件时更新 `PreviewItem`,删除仍复用现有确认与删除事件。
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API、TypeScript、Element Plus、Node 回归脚本。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 列表行只展示编号、来源行号及编辑、删除图标操作。
|
||||
- 编辑草稿未保存时不得更新 `PreviewItem.editedContent`。
|
||||
- 搜索、分页或切换文件时退出编辑模式并丢弃草稿。
|
||||
- 不新增依赖。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 切片列表与编辑模式切换
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/data-process/create/PreviewCompareStep.vue`
|
||||
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PreviewItem`、`update:selectedId`、`update:item-content`、`remove:item`。
|
||||
- Produces: `openEditor(item)`、`closeEditor()`、`saveEditor()` 与列表/编辑互斥渲染。
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
在 `regression-data-process-wizard.mjs` 断言组件存在 `editingItemId` 和 `editorDraft`,列表以图标按钮触发 `openEditor` 与 `remove:item`,编辑模式拥有 `保存修改`、`取消` 与 `返回列表`,并且列表不再含 `item-token`、`item-status`、`modifiedOnly`。
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:data-process-wizard`
|
||||
|
||||
Expected: FAIL,提示缺少单面板编辑模式结构。
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
在组件中增加以下状态和行为:
|
||||
|
||||
```ts
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const editorDraft = ref('')
|
||||
|
||||
function openEditor(item: PreviewItem) {
|
||||
editingItemId.value = item.id
|
||||
editorDraft.value = item.editedContent
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingItemId.value = null
|
||||
editorDraft.value = ''
|
||||
}
|
||||
|
||||
function saveEditor() {
|
||||
if (!editingItem.value) return
|
||||
emit('update:item-content', editingItem.value.id, editorDraft.value)
|
||||
closeEditor()
|
||||
}
|
||||
```
|
||||
|
||||
列表模式只渲染编号、来源和两个无文字图标按钮;编辑模式在同一位置渲染正文输入框以及返回、取消、保存操作。搜索、翻页、文件切换调用 `closeEditor()`。
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:data-process-wizard`
|
||||
|
||||
Expected: `数据处理四步向导回归检查通过`。
|
||||
|
||||
- [ ] **Step 5: Build and visually verify**
|
||||
|
||||
Run: `npx vite build`
|
||||
|
||||
Expected: Vite completes successfully. Open the second wizard step, verify the list has only the two icon operations and that cancel does not change the selected slice content while save returns to the list.
|
||||
78
docs/superpowers/plans/2026-07-10-route-transition.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Route Transition Removal Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 移除主布局的页面级透明转场,消除列表与二级页面切换时的闪烁中间帧。
|
||||
|
||||
**Architecture:** `router-view` 直接渲染当前路由组件,不再包裹 Vue `transition`。页面表面仍由 `route.meta.pageSurface` 控制,因此内容和表面在同一轮渲染中同步更新。
|
||||
|
||||
**Tech Stack:** Vue 3、Vue Router 4、SCSS、Node.js 回归脚本
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 删除页面级透明度转场。
|
||||
- 保留组件内部动画。
|
||||
- 不改变路由表面分类、业务逻辑或数据加载流程。
|
||||
- 先写失败测试,再实现最小修复。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 移除主布局页面级透明转场
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scripts/regression-page-surface.mjs`
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Test: `frontend/scripts/regression-page-surface.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `router-view` 提供的当前路由组件。
|
||||
- Produces: 不带页面级透明度动画的同步路由内容渲染。
|
||||
|
||||
- [ ] **Step 1: 写入失败回归测试**
|
||||
|
||||
在 `regression-page-surface.mjs` 中断言主布局模板不包含页面级 `transition`,并断言主布局样式不包含 `.fade-enter-*` 或 `.fade-leave-*`。
|
||||
|
||||
- [ ] **Step 2: 运行测试并确认 RED**
|
||||
|
||||
Run: `npm run test:page-surface`
|
||||
|
||||
Expected: FAIL,提示主布局仍包含页面级透明转场。
|
||||
|
||||
- [ ] **Step 3: 写入最小实现**
|
||||
|
||||
将:
|
||||
|
||||
```vue
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```vue
|
||||
<component :is="Component" />
|
||||
```
|
||||
|
||||
并删除主布局中的 `.fade-enter-active`、`.fade-leave-active`、`.fade-enter-from` 和 `.fade-leave-to` 样式。
|
||||
|
||||
- [ ] **Step 4: 运行专项回归并确认 GREEN**
|
||||
|
||||
Run: `npm run test:page-surface`
|
||||
|
||||
Expected: PASS,输出“全局页面背景与内容表面回归检查通过”。
|
||||
|
||||
- [ ] **Step 5: 运行相关回归与构建**
|
||||
|
||||
Run: `npm run test:training-log-layout`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Run: `npx vite build`
|
||||
|
||||
Expected: exit code 0;允许项目既有字体解析和 chunk size 警告。
|
||||
|
||||
- [ ] **Step 6: 浏览器往返验证**
|
||||
|
||||
验证 `/fine-tune` → `/fine-tune/create`、`/fine-tune` → `/training-log/1` 以及二级页返回列表;页面内容与表面同步切换,无半透明旧页面、无水平溢出、无新增控制台错误。
|
||||
|
||||
202
docs/superpowers/plans/2026-07-10-source-upload-file-list.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# 源数据上传紧凑文件列表 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将数据处理创建页的已上传文件从大卡片改为固定高度、可滚动且可逐项操作的紧凑列表。
|
||||
|
||||
**Architecture:** 只修改 `TaskSetupStep` 的模板和局部样式,继续消费现有的
|
||||
`uploadedFiles` 属性并派发既有 `remove-file` 事件。使用同一组件内的标题栏和
|
||||
滚动容器管理信息密度,不改变上传、格式限制或父组件数据流。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`、TypeScript、Element Plus、SCSS、Node.js 回归脚本。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 不新增依赖、组件、数据字段或父子组件事件。
|
||||
- 上传区、格式限制、校验语义与逐项删除行为必须保持不变。
|
||||
- 空状态保留大尺寸拖拽上传区与格式提示;存在至少一个文件时,改为列表标题右侧
|
||||
的小型“继续上传”入口,且复用相同上传属性与文件变更事件。
|
||||
- 列表默认显示最多 5 行;额外文件只能在列表内部纵向滚动。
|
||||
- 文件名允许省略,但必须通过原生 `title` 保留完整文本。
|
||||
- 校验状态必须同时显示图标和文字;删除按钮必须可见且可键盘操作。
|
||||
- 当前工作区不是 Git 仓库,本计划不包含提交操作。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 紧凑上传文件列表
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/scripts/regression-data-process-wizard.mjs`
|
||||
- Modify: `frontend/src/views/data-process/create/TaskSetupStep.vue:157-164,314-351`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `uploadedFiles: { uid: string | number; name: string; size: number; count: number }[]`
|
||||
和 `remove-file(uid)` 事件。
|
||||
- Produces: `.uploaded-file-list-header`、`.uploaded-file-items` 和每条
|
||||
`.uploaded-file` 紧凑行;不新增对外 TypeScript 接口。
|
||||
|
||||
- [ ] **Step 1: 添加会失败的页面结构回归断言**
|
||||
|
||||
在 `frontend/scripts/regression-data-process-wizard.mjs` 的第二步组件检查之后读取
|
||||
`TaskSetupStep.vue`,并追加以下断言:
|
||||
|
||||
```js
|
||||
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
|
||||
const taskSetupSource = await readFile(taskSetupPath, 'utf8')
|
||||
|
||||
for (const marker of [
|
||||
'uploaded-file-list-header',
|
||||
'uploaded-file-items',
|
||||
'已添加 {{ uploadedFiles.length }} 个文件',
|
||||
':title="file.name"',
|
||||
'aria-label="已上传文件列表"',
|
||||
'继续上传',
|
||||
'v-if="uploadedFiles.length === 0"',
|
||||
]) {
|
||||
assert.ok(taskSetupSource.includes(marker), `源数据文件列表缺少:${marker}`)
|
||||
}
|
||||
assert.match(taskSetupSource, /\.uploaded-file-items\s*\{[\s\S]*max-height:\s*240px/, '文件列表没有固定可见高度')
|
||||
assert.match(taskSetupSource, /\.uploaded-file-items\s*\{[\s\S]*overflow-y:\s*auto/, '超出文件没有在列表内滚动')
|
||||
assert.match(taskSetupSource, /compact-upload[\s\S]*:accept="uploadAccept"/, '继续上传没有复用格式限制')
|
||||
assert.match(taskSetupSource, /compact-upload[\s\S]*on-change/, '继续上传没有复用文件变更事件')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行回归脚本,确认新增断言失败**
|
||||
|
||||
Run: `npm --prefix frontend run test:data-process-wizard`
|
||||
|
||||
Expected: FAIL,错误信息包含新增文件列表、列表可访问性或继续上传状态切换的缺失标记。
|
||||
|
||||
- [ ] **Step 3: 更新模板为带标题与滚动区域的紧凑列表**
|
||||
|
||||
在 `TaskSetupStep.vue` 中,将当前上传区改为两种互斥状态:空状态保留大尺寸拖拽
|
||||
区和格式提示;存在文件时显示含继续上传入口的紧凑列表。两个上传入口均保留
|
||||
`multiple`、`:accept="uploadAccept"`、`:auto-upload="false"` 和相同 `on-change` 事件。
|
||||
文件图标、元信息、成功状态和原有删除事件必须保留:
|
||||
|
||||
```vue
|
||||
<el-upload
|
||||
v-if="uploadedFiles.length === 0"
|
||||
class="upload-empty-state"
|
||||
drag
|
||||
multiple
|
||||
:accept="uploadAccept"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(file: UploadFile) => emit('file-change', file)"
|
||||
>
|
||||
<!-- 保留现有大尺寸上传引导和格式提示 -->
|
||||
</el-upload>
|
||||
|
||||
<div v-else class="uploaded-file-list" aria-label="已上传文件列表">
|
||||
<div class="uploaded-file-list-header">
|
||||
<span>已添加 {{ uploadedFiles.length }} 个文件</span>
|
||||
<el-upload
|
||||
class="compact-upload"
|
||||
multiple
|
||||
:accept="uploadAccept"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(file: UploadFile) => emit('file-change', file)"
|
||||
>
|
||||
<el-button size="small" type="primary">继续上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="uploaded-file-items">
|
||||
<div v-for="file in uploadedFiles" :key="file.uid" class="uploaded-file">
|
||||
<span class="file-icon"><i class="fa fa-file-text-o" /></span>
|
||||
<div class="file-main">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<span>{{ formatSize(file.size) }}<template v-if="file.count"> · {{ file.count.toLocaleString() }} 条</template></span>
|
||||
</div>
|
||||
<span class="file-status"><i class="fa fa-check-circle" /> 校验通过</span>
|
||||
<el-button link type="danger" @click="emit('remove-file', file.uid)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 将现有大卡片样式改为紧凑行和内部滚动**
|
||||
|
||||
在 `TaskSetupStep.vue` 中替换现有 `.uploaded-file-list` 与 `.uploaded-file` 相关样式,
|
||||
使容器、标题、滚动区和行高满足以下实现:
|
||||
|
||||
```scss
|
||||
.uploaded-file-list {
|
||||
margin-top: 20px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ea;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.uploaded-file-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
color: #5f6878;
|
||||
font-size: 12px;
|
||||
background: #fbfcfe;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.uploaded-file-items {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.compact-upload {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.uploaded-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.file-main strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
保留 `.file-main` 的弹性填充与 `.file-status` 的成功色;为窄屏媒体查询加入更小的
|
||||
行间距和可换行的状态文本,保证删除按钮不被文件名挤出。
|
||||
|
||||
- [ ] **Step 5: 运行回归脚本并执行类型检查**
|
||||
|
||||
Run: `npm --prefix frontend run test:data-process-wizard && npm --prefix frontend run type-check`
|
||||
|
||||
Expected: 两个命令均以退出码 `0` 完成,前者输出 `数据处理四步向导回归检查通过`。
|
||||
|
||||
- [ ] **Step 6: 构建生产包,检查样式与模板编译**
|
||||
|
||||
Run: `npm --prefix frontend run build`
|
||||
|
||||
Expected: 退出码 `0`,Vite 输出生产构建产物信息,无 Vue 模板或 SCSS 编译错误。
|
||||
|
||||
- [ ] **Step 7: 检查变更范围**
|
||||
|
||||
Run: `git diff -- frontend/src/views/data-process/create/TaskSetupStep.vue frontend/scripts/regression-data-process-wizard.mjs`
|
||||
|
||||
Expected: 当前目录不是 Git 仓库时,该命令会报告仓库缺失;改用
|
||||
`diff -u <(git show HEAD:...) ...` 不可用,因此使用 `sed` 复查两个文件的目标区段,
|
||||
确认没有修改上传、格式限制或父组件事件。
|
||||
@@ -0,0 +1,131 @@
|
||||
# 源数据上传文件分页 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将紧凑源文件列表限制为每页最多 10 条,并在文件总数超过 10 时使用分页器切换文件。
|
||||
|
||||
**Architecture:** 分页状态只在 `TaskSetupStep` 内维护;父组件仍只提供完整的
|
||||
`uploadedFiles` 数组和既有上传、删除事件。组件通过计算属性得到当前页文件,并在
|
||||
文件数变化后跳转到新增文件所在的末页或将删除后的空页回退到有效页。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`、TypeScript、Element Plus、SCSS、Node.js 回归脚本。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 不新增依赖、组件、数据字段或父子组件事件。
|
||||
- 每页固定显示最多 10 个文件;只有第 11 个文件出现分页器。
|
||||
- 不再使用文件列表内部滚动承载额外文件;分页器承担跨页浏览。
|
||||
- 新增文件自动切换到最后一页;删除后当前页不存在时回退到最后一个有效页。
|
||||
- 空状态的大尺寸上传区、有文件时的小型继续上传入口、格式限制、校验状态和逐项
|
||||
删除行为必须保持不变。
|
||||
- 当前工作区不是 Git 仓库,本计划不包含提交操作。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 十条一页的文件列表
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/data-process/create/TaskSetupStep.vue:1-45,158-184,350-420`
|
||||
- Modify: `frontend/scripts/regression-data-process-wizard.mjs:65-140`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `uploadedFiles` 完整数组和既有 `file-change`、`remove-file` 事件。
|
||||
- Produces: 组件内部的 `currentFilePage`、`pagedUploadedFiles` 与只在超出 10 条时显示的
|
||||
Element Plus 分页器;不新增对外接口。
|
||||
|
||||
- [ ] **Step 1: 先添加分页回归断言**
|
||||
|
||||
在 `frontend/scripts/regression-data-process-wizard.mjs` 的 `TaskSetupStep.vue` 检查中追加:
|
||||
|
||||
```js
|
||||
for (const marker of [
|
||||
'const FILE_PAGE_SIZE = 10',
|
||||
'const currentFilePage = ref(1)',
|
||||
'const pagedUploadedFiles = computed',
|
||||
'v-for="file in pagedUploadedFiles"',
|
||||
'class="uploaded-file-pagination"',
|
||||
':page-size="FILE_PAGE_SIZE"',
|
||||
'uploadedFiles.length > FILE_PAGE_SIZE',
|
||||
]) {
|
||||
assert.ok(taskSetupSource.includes(marker), `文件分页缺少:${marker}`)
|
||||
}
|
||||
assert.match(taskSetupSource, /newLength > oldLength[\s\S]*currentFilePage\.value = totalPages/, '新增文件后没有跳到最后一页')
|
||||
assert.match(taskSetupSource, /Math\.min\(currentFilePage\.value, totalPages\)/, '删除文件后没有回退到有效页')
|
||||
assert.doesNotMatch(taskSetupSource, /\.uploaded-file-items\s*\{[\s\S]*overflow-y:\s*auto/, '文件列表仍依赖内部滚动')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行回归脚本,确认新增断言失败**
|
||||
|
||||
Run: `npm --prefix frontend run test:data-process-wizard`
|
||||
|
||||
Expected: FAIL,错误信息包含 `文件分页缺少:const FILE_PAGE_SIZE = 10`。
|
||||
|
||||
- [ ] **Step 3: 增加组件内分页状态与页码校正**
|
||||
|
||||
在 `TaskSetupStep.vue` 中将 Vue 导入扩展为 `computed, ref, watch`,并在
|
||||
`uploadAccept` 之后添加:
|
||||
|
||||
```ts
|
||||
const FILE_PAGE_SIZE = 10
|
||||
const currentFilePage = ref(1)
|
||||
const totalFilePages = computed(() => Math.max(1, Math.ceil(props.uploadedFiles.length / FILE_PAGE_SIZE)))
|
||||
const pagedUploadedFiles = computed(() => {
|
||||
const start = (currentFilePage.value - 1) * FILE_PAGE_SIZE
|
||||
return props.uploadedFiles.slice(start, start + FILE_PAGE_SIZE)
|
||||
})
|
||||
|
||||
watch(() => props.uploadedFiles.length, (newLength, oldLength) => {
|
||||
const totalPages = Math.max(1, Math.ceil(newLength / FILE_PAGE_SIZE))
|
||||
currentFilePage.value = newLength > oldLength
|
||||
? totalPages
|
||||
: Math.min(currentFilePage.value, totalPages)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 使用当前页文件并添加紧凑分页器**
|
||||
|
||||
将文件行循环改为 `v-for="file in pagedUploadedFiles"`。在 `.uploaded-file-items`
|
||||
之后、`</section>` 之前插入:
|
||||
|
||||
```vue
|
||||
<el-pagination
|
||||
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
|
||||
v-model:current-page="currentFilePage"
|
||||
:page-size="FILE_PAGE_SIZE"
|
||||
:total="uploadedFiles.length"
|
||||
:pager-count="5"
|
||||
small
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
class="uploaded-file-pagination"
|
||||
/>
|
||||
```
|
||||
|
||||
保留现有列表语义、继续上传入口、状态、删除按钮和 `aria-label`。
|
||||
|
||||
- [ ] **Step 5: 移除列表滚动样式并添加分页器间距**
|
||||
|
||||
将 `.uploaded-file-items` 中的 `max-height` 与 `overflow-y` 删除,确保 10 条以内
|
||||
由页面自然高度承载;追加:
|
||||
|
||||
```scss
|
||||
.uploaded-file-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid #edf0f5;
|
||||
}
|
||||
```
|
||||
|
||||
在窄屏媒体查询中将分页器改为水平居中,避免与文件操作区争夺宽度。
|
||||
|
||||
- [ ] **Step 6: 验证回归与编译**
|
||||
|
||||
Run: `npm --prefix frontend run test:data-process-wizard`
|
||||
|
||||
Expected: 文件分页新增断言全部通过;脚本若非零,只能在既有父页面的
|
||||
`.wizard-content` / `min-height: 0` 断言处终止。
|
||||
|
||||
Run: `cd frontend && npm exec vite build`
|
||||
|
||||
Expected: 退出码 `0`,无 Vue 模板或 SCSS 编译错误。
|
||||
@@ -0,0 +1,128 @@
|
||||
# 训练日志详情页双栏改版 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将训练日志详情首屏改造成用户选定的双栏任务档案布局,同时保持原有数据与日志行为。
|
||||
|
||||
**Architecture:** 继续由 `TrainingLogView.vue` 负责数据加载与页面编排,仅重写首屏模板和 scoped SCSS。使用原生语义元素与 CSS Grid,不新增依赖或全局组件,避免影响其他详情页。
|
||||
|
||||
**Tech Stack:** Vue 3、TypeScript、Element Plus、SCSS、Node.js 原生断言脚本
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 保留所有 API 请求、5 秒轮询、指标解析、ECharts 和日志输出逻辑。
|
||||
- 使用现有 Indigo/Slate 视觉 token,不改全局主题。
|
||||
- 灰色 `#f3f5f8` 只作为应用外壳留白;所有业务路由必须渲染在统一的白色圆角页面画布内。
|
||||
- 缺失参数显示“未配置”,缺失输出模型显示“暂未生成”。
|
||||
- `<= 1100px` 主体改单栏,折叠按钮具备 `aria-expanded`。
|
||||
- 不新增依赖。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 锁定布局与可访问性回归
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/scripts/regression-training-log-layout.mjs`
|
||||
- Modify: `frontend/package.json`
|
||||
- Test: `frontend/scripts/regression-training-log-layout.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `frontend/src/views/system/TrainingLogView.vue` 源文件。
|
||||
- Produces: `npm run test:training-log-layout` 专项回归命令。
|
||||
|
||||
- [ ] **Step 1: 写入失败测试**
|
||||
|
||||
测试必须断言:双栏容器、任务/数据集/运行概况分组、语义化参数折叠按钮、`aria-expanded`、缺失值文案和 `1100px` 响应式断点存在;首屏旧 `el-descriptions` 结构已移除。
|
||||
|
||||
- [ ] **Step 2: 运行并确认 RED**
|
||||
|
||||
Run: `cd frontend && npm run test:training-log-layout`
|
||||
Expected: FAIL,提示缺少 `.overview-layout` 等新结构。
|
||||
|
||||
- [ ] **Step 3: 在 package scripts 暴露测试命令**
|
||||
|
||||
```json
|
||||
"test:training-log-layout": "node scripts/regression-training-log-layout.mjs"
|
||||
```
|
||||
|
||||
### Task 2: 实现双栏任务档案首屏
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/system/TrainingLogView.vue`
|
||||
- Test: `frontend/scripts/regression-training-log-layout.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 现有 `task`、`dataset`、`modelsStore`、枚举映射与 `paramsExpanded`。
|
||||
- Produces: `.overview-layout`、`.profile-section`、`.runtime-panel`、`.parameter-groups` 和语义化 `.params-toggle-button`。
|
||||
|
||||
- [ ] **Step 1: 重写任务与数据集模板**
|
||||
|
||||
使用一个顶层 `PageCard` 承载任务头和双栏内容;左栏用定义列表表达任务信息,数据集使用名称/类型/描述加指标带;右栏使用纵向运行概况项。
|
||||
|
||||
- [ ] **Step 2: 重写参数分组模板**
|
||||
|
||||
训练参数和 LoRA 参数改为双列轻分隔行,所有缺失值用 `未配置`,折叠按钮绑定 `:aria-expanded="paramsExpanded"`。
|
||||
|
||||
- [ ] **Step 3: 实现视觉与响应式 SCSS**
|
||||
|
||||
添加双栏、信息行、指标带、焦点态和 `@media (max-width: 1100px)` / `700px` 规则;不修改全局样式。
|
||||
|
||||
- [ ] **Step 4: 运行并确认 GREEN**
|
||||
|
||||
Run: `cd frontend && npm run test:training-log-layout`
|
||||
Expected: PASS,输出“训练日志详情布局回归检查通过”。
|
||||
|
||||
### Task 2.5: 统一页面背景与内容表面
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/styles/index.scss`
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Modify: `frontend/src/views/system/TrainingLogView.vue`
|
||||
- Create: `frontend/scripts/regression-page-surface.mjs`
|
||||
|
||||
- [ ] **Step 1: 先写并运行背景层回归检查**
|
||||
|
||||
Run: `cd frontend && npm run test:page-surface`
|
||||
Expected: 首次 FAIL,提示缺少 `--app-shell-bg` 和全局 `.page-canvas`。
|
||||
|
||||
- [ ] **Step 2: 添加全局背景与表面 token**
|
||||
|
||||
定义灰色外壳 `--app-shell-bg: #f3f5f8`、白色页面画布 `--app-page-bg: #ffffff` 和白色内容表面 `--app-surface-bg: #ffffff`。在 `MainLayout` 中用 `.page-canvas` 包裹所有业务路由,并消除直接根 `PageCard` 的重复阴影。
|
||||
|
||||
- [ ] **Step 3: 运行并确认 GREEN**
|
||||
|
||||
Run: `cd frontend && npm run test:page-surface`
|
||||
Expected: PASS,输出“全局页面背景与内容表面回归检查通过”。
|
||||
|
||||
### Task 3: 类型、构建与视觉 QA
|
||||
|
||||
**Files:**
|
||||
- Create: `design-qa.md`
|
||||
- Modify: `frontend/src/views/system/TrainingLogView.vue`(仅在 QA 发现 P0/P1/P2 时)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 选定视觉稿、浏览器渲染截图。
|
||||
- Produces: 通过的类型检查、生产构建和 `design-qa.md`。
|
||||
|
||||
- [ ] **Step 1: 静态验证**
|
||||
|
||||
Run: `cd frontend && npm run type-check`
|
||||
Expected: 项目引用模式能检查真实源码;若仓库原有错误仍存在,输出中不得包含本次修改的训练日志页、布局或全局样式。
|
||||
|
||||
Run: `cd frontend && npx vite build`
|
||||
Expected: exit 0。
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: 在类型检查修复前仍会被仓库原有错误阻断,必须记录实际错误文件。
|
||||
|
||||
- [ ] **Step 2: 启动并检查页面**
|
||||
|
||||
启动 Vite,打开 `/login`,使用 mock 账号进入 `/training-log/1`,在 1440px 宽视口检查双栏、折叠交互和控制台错误。
|
||||
|
||||
- [ ] **Step 3: 执行 Design QA**
|
||||
|
||||
将选定视觉稿与实现截图放在同一比较输入中,检查字体、间距、颜色、图标、文案和交互;修复所有 P0/P1/P2 后更新 `design-qa.md` 为 `final result: passed`。
|
||||
|
||||
- [ ] **Step 4: 最终验证**
|
||||
|
||||
重新运行专项回归、类型检查和构建,并记录实际结果。
|
||||
@@ -0,0 +1,267 @@
|
||||
# 前端视觉与样式系统重构设计
|
||||
|
||||
**日期**:2026-07-09
|
||||
**范围**:`frontend/` (Vue 3 + Element Plus + Pinia)
|
||||
**目标用户**:内部数据/算法工程师,桌面端浏览器使用
|
||||
|
||||
## 背景与动机
|
||||
|
||||
当前前端已用 Element Plus + 自定义 SCSS 变量搭起一套"深色侧边栏 + 浅色内容区"的现代 B 端布局,但实际页面观感"乱"且有功能性瑕疵,根因不是设计语言本身有问题,而是**设计系统没有真正落地**:
|
||||
|
||||
- 颜色散落硬编码(`#1e293b`、`#475569`、`#909399`、`#1890ff`、`#67c23a`…)
|
||||
- Element Plus 旧默认色与新主题色(`#4f46e5`)并存
|
||||
- 用了 FontAwesome **4.7.0**(2017 年停止维护),大量 `fa-cogs`/`fa-hdd-o` 这种短横线后缀命名
|
||||
- 登录页(`LoginView.vue`)背景是纯色 `#dbe0f9`,与主应用玻璃拟态风格割裂
|
||||
- `AppHeader.vue:189` 引用了主题里**不存在**的 `--warning-color` 变量,导致回退到默认值
|
||||
- `index.scss` 内有 5+ 处 `!important`,说明覆盖策略不对
|
||||
- 装饰性动画(按钮悬停 `translateY(-1px)`、`pulse` 动画、过度渐变)增加视觉噪音
|
||||
|
||||
本次重构目标是**收口样式系统**,让"主题改一处,全站生效",并消除明显观感问题。
|
||||
|
||||
## 范围与非范围
|
||||
|
||||
### 范围内
|
||||
|
||||
1. 统一颜色变量系统(消除硬编码、补齐缺失变量)
|
||||
2. 图标库迁移:FA4 → **Element Plus Icons**(`@element-plus/icons-vue`,已与 Element Plus 同步)
|
||||
3. 重新设计登录页,与主应用视觉一致
|
||||
4. 修复 `AppHeader.vue` 的 `--warning-color` 缺失问题
|
||||
5. 整理 `index.scss` 的 `!important` 覆盖,改用更高特异性选择器
|
||||
6. 适度降低装饰性效果:移除按钮悬停位移、统一卡片阴影阶梯
|
||||
|
||||
### 非范围(本次不做)
|
||||
|
||||
- 布局结构改动(保留 260px 侧边栏 + 60px 顶栏)
|
||||
- 深色模式切换
|
||||
- 任何业务逻辑修改
|
||||
- 移动端适配(当前项目无移动端需求)
|
||||
- `web/` 静态版不动
|
||||
|
||||
## 方案选型
|
||||
|
||||
### 选定方案:方案 A — 克制重塑
|
||||
|
||||
保留现有"深色侧边栏 + 浅色内容区"骨架,做样式系统收口。
|
||||
|
||||
**未选 方案 B(极简重做)**:改动面太广,10+ 视图都要适配,回报/风险比差。
|
||||
**未选 方案 C(全深色科技感)**:Element Plus 暗色需要大量单独调校,工作量与当前诉求不匹配。
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. 颜色变量系统(`src/styles/index.scss` 重构)
|
||||
|
||||
**目标**:所有颜色、阴影、圆角都走 CSS 变量;后续改主题只改 `:root` 即可。
|
||||
|
||||
**新增/补齐变量**(`src/styles/index.scss` 顶部):
|
||||
|
||||
```scss
|
||||
:root {
|
||||
/* Element Plus 主色(已有) */
|
||||
--el-color-primary: #4f46e5;
|
||||
--el-color-primary-light-3: #818cf8;
|
||||
--el-color-primary-light-5: #a5b4fc;
|
||||
--el-color-primary-light-7: #c7d2fe;
|
||||
--el-color-primary-light-8: #e0e7ff;
|
||||
--el-color-primary-light-9: #eef2ff;
|
||||
--el-color-primary-dark-2: #4338ca;
|
||||
|
||||
/* 状态色(已有) */
|
||||
--el-color-success: #10b981;
|
||||
--el-color-warning: #f59e0b; /* 修复:之前未显式定义 */
|
||||
--el-color-danger: #ef4444;
|
||||
--el-color-info: #64748b;
|
||||
|
||||
/* 项目别名(已有 + 新增) */
|
||||
--primary-color: var(--el-color-primary);
|
||||
--primary-hover: var(--el-color-primary-light-3);
|
||||
--success-color: var(--el-color-success);
|
||||
--warning-color: var(--el-color-warning); /* 新增:修复 AppHeader 引用 */
|
||||
--danger-color: var(--el-color-danger);
|
||||
|
||||
/* 圆角(已有) */
|
||||
--el-border-radius-base: 8px;
|
||||
--el-border-radius-small: 6px;
|
||||
--el-border-radius-round: 20px;
|
||||
|
||||
/* ========== 新增:项目语义化变量 ========== */
|
||||
|
||||
/* 文字 */
|
||||
--text-primary: #1e293b; /* 替代硬编码 #1e293b */
|
||||
--text-secondary: #475569; /* 替代 #475569 */
|
||||
--text-tertiary: #64748b; /* 替代 #64748b */
|
||||
--text-placeholder: #94a3b8; /* 替代 #94a3b8 */
|
||||
|
||||
/* 背景 */
|
||||
--bg-page: #f8fafc; /* 替代 --el-bg-color-page */
|
||||
--bg-card: #ffffff;
|
||||
--bg-subtle: #f1f5f9; /* 替代硬编码 #f1f5f9 */
|
||||
--bg-hover: #f8fafc; /* 替代硬编码 #f8fafc */
|
||||
|
||||
/* 边框 */
|
||||
--border-subtle: #e2e8f0; /* 替代硬编码 #e2e8f0 */
|
||||
--border-base: #f1f5f9; /* 替代硬编码 #f1f5f9 */
|
||||
|
||||
/* 侧边栏(已有) */
|
||||
--sidebar-bg: #0f172a;
|
||||
--sidebar-text: #94a3b8;
|
||||
--sidebar-active-bg: rgba(79, 70, 229, 0.15);
|
||||
--sidebar-active-text: #ffffff;
|
||||
--sidebar-section-title: #64748b;
|
||||
|
||||
/* 阴影(统一阶梯) */
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow-md: 0 2px 8px rgba(15, 23, 42, 0.06); /* 替代 --card-shadow */
|
||||
--shadow-lg: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
--shadow-dialog: 0 20px 40px rgba(15, 23, 42, 0.12);
|
||||
|
||||
/* 尺寸 */
|
||||
--header-height: 60px;
|
||||
--sidebar-width: 260px;
|
||||
--content-padding-x: 32px;
|
||||
--content-padding-y: 32px;
|
||||
}
|
||||
```
|
||||
|
||||
**改动原则**:
|
||||
- 组件 SCSS 内的硬编码颜色全部替换为对应变量
|
||||
- 任何新增颜色需求都先在 `:root` 定义,再引用
|
||||
- 不再使用 `!important`;如确需覆盖,提升特异性(如 `:deep(.el-button)`)
|
||||
|
||||
### 2. 图标库迁移:FA4 → Element Plus Icons
|
||||
|
||||
**为什么选 Element Plus Icons**:
|
||||
- 零新增依赖成本(仅在 `package.json` 增加一行)
|
||||
- 与 Element Plus 组件视觉风格天然一致
|
||||
- 树摇友好,按需引入
|
||||
- SVG 渲染,可继承 `currentColor` 与 `font-size`,比 FA 字体图标更现代
|
||||
|
||||
**实施**:
|
||||
1. `npm i @element-plus/icons-vue`
|
||||
2. `main.ts` 全局注册常用图标(按需在组件内 import 也可)
|
||||
3. 替换规则(在每个 view/component 内做局部替换):
|
||||
|
||||
| FA4 类名 | Element Plus 图标 | 用在哪 |
|
||||
|---------|------------------|--------|
|
||||
| `fa-cogs` | `Setting` | 侧边栏"模型调优" |
|
||||
| `fa-line-chart` | `TrendCharts` | 侧边栏"模型评测" |
|
||||
| `fa-server` | `Monitor` | 侧边栏"模型推理" |
|
||||
| `fa-cube` | `Box` | 侧边栏"模型管理"、Logo |
|
||||
| `fa-file-text` | `Document` | 侧边栏"数据集" |
|
||||
| `fa-wrench` | `Tools` | 侧边栏"其他工具" |
|
||||
| `fa-bar-chart` | `DataAnalysis` | 侧边栏"平台性能" |
|
||||
| `fa-search` | `Search` | 搜索框前缀 |
|
||||
| `fa-plus` | `Plus` | 新建按钮 |
|
||||
| `fa-trash` | `Delete` | 删除按钮 |
|
||||
| `fa-arrow-left` | `ArrowLeft` | 返回按钮 |
|
||||
| `fa-microchip` | `Cpu` | 性能监控 |
|
||||
| `fa-database` | `Files` | 内存监控(数据文件堆) |
|
||||
| `fa-hdd-o` | `Box` | 磁盘监控(存储箱) |
|
||||
| `fa-sign-out` | `SwitchButton` | 退出登录 |
|
||||
| `fa-angle-down` | `ArrowDown` | 下拉箭头 |
|
||||
| `fa-user-circle-o` | `User` | 个人中心 |
|
||||
| `fa-cog` | `Setting` | 系统设置 |
|
||||
| `fa-stop-circle-o` | `VideoPause` | 停止训练 |
|
||||
| `fa-pencil` | `Edit` | 编辑 |
|
||||
| `fa-times` | `Close` | 关闭 |
|
||||
| `fa-globe` | `Connection` | 网络流量 |
|
||||
| `fa-info-circle` | `InfoFilled` | 系统信息 |
|
||||
| `fa-user-o` | `User` | 登录页账号图标 |
|
||||
| `fa-lock-o` | `Lock` | 登录页密码图标 |
|
||||
| `fa-sign-in` | `Right` | 登录按钮 |
|
||||
|
||||
**注意点**:
|
||||
- 替换后调整图标尺寸(Element Plus Icons 用 `size` prop 或 CSS `font-size`)
|
||||
- 部分 FA4 类名(如 `fa-file-text-o`)在 FA5/FA6 已无 `-o` 后缀
|
||||
- `main.ts` 中删除 `import '@/assets/font-awesome/css/font-awesome.min.css'`
|
||||
- 删 `src/assets/font-awesome/` 整个目录(字体文件 100+KB)
|
||||
|
||||
### 3. 登录页重新设计(`LoginView.vue`)
|
||||
|
||||
**目标**:与主应用保持一致的视觉语言,背景改用渐变 + 玻璃卡片。
|
||||
|
||||
**设计**:
|
||||
- 背景:渐变 `linear-gradient(135deg, #4f46e5 0%, #818cf8 50%, #c7d2fe 100%)`
|
||||
- 卡片:白色,圆角 16px,阴影 `var(--shadow-dialog)`
|
||||
- Logo 区域:上下结构(Logo 48px → 标题 24px → 副标题 14px 灰色)
|
||||
- 表单:单项之间 20px 间距,输入框 44px 高,圆角 8px
|
||||
- 按钮:主色实心,44px 高,全宽
|
||||
- 选项行:flex 布局,记住密码左、忘记密码右
|
||||
- 整体居中,最大宽 420px
|
||||
|
||||
**关键 CSS 改动**:
|
||||
- 背景从 `#dbe0f9` 改为 `linear-gradient(135deg, #4f46e5 0%, #818cf8 50%, #c7d2fe 100%)`
|
||||
- 卡片圆角从 12px 提升到 16px
|
||||
- 阴影替换为 `var(--shadow-dialog)`
|
||||
- 所有内联颜色改用变量
|
||||
|
||||
### 4. 修复 `AppHeader.vue` 的 `--warning-color` 缺失
|
||||
|
||||
**问题**:`AppHeader.vue:189` 使用 `var(--warning-color)`,但 `index.scss` 里只定义了 `--danger-color`、`--success-color`,没有 `--warning-color`。
|
||||
|
||||
**修复**:在 `:root` 中新增 `--warning-color: var(--el-color-warning);`(已在第 1 节列入)。
|
||||
|
||||
### 5. 整理 `index.scss` 的 `!important`
|
||||
|
||||
**现有 `!important` 用法**(来自 `index.scss`):
|
||||
|
||||
| 选择器 | 用途 | 现状 |
|
||||
|--------|------|------|
|
||||
| `.el-card { border: none !important; ... }` | 去掉 EP 默认边框 | 提升为 `.app-container .el-card` 或全局加 `:where()` 重置 |
|
||||
| `.el-tag { border: none !important; ... }` | 去掉 EP 默认边框 | 同上 |
|
||||
| `.el-dialog { border-radius: 16px !important; ... }` | 圆角和阴影 | 同上 |
|
||||
|
||||
**改写策略**:
|
||||
- 用更高特异性选择器(如 `body .el-card`)替代 `!important`
|
||||
- 或用 CSS Layer (`@layer reset, overrides, components`) 控制优先级
|
||||
|
||||
**本次采用**:直接提高特异性,避免引入 CSS Layer 复杂度(属于项目既定风格之外的引入,需要权衡)。
|
||||
|
||||
### 6. 降低装饰性效果
|
||||
|
||||
**调整清单**:
|
||||
- `index.scss` 移除 `.el-button:hover { transform: translateY(-1px); box-shadow: ...; }` — 改为仅改变背景色
|
||||
- `AppHeader.vue` 移除 `pulse` 动画,改为静态红色
|
||||
- 侧边栏菜单图标的 `transform: scale(1.1)` 悬停动画保留(这是细节点缀,不算噪音)
|
||||
- 卡片悬停 `transform` 动效保留(仅在 DataTablePage 等列表卡片上,目前没有,不新增)
|
||||
- 顶栏 `backdrop-filter: blur(12px)` 保留(玻璃感是这个项目的风格语言)
|
||||
|
||||
## 实施步骤(落地顺序)
|
||||
|
||||
按风险从低到高,便于逐步验证:
|
||||
|
||||
1. **步骤 1**:扩展 `src/styles/index.scss` 变量(新增,不删除旧的)
|
||||
2. **步骤 2**:安装 `@element-plus/icons-vue`,在 `main.ts` 注册
|
||||
3. **步骤 3**:逐文件替换图标(按表格顺序)
|
||||
4. **步骤 4**:逐文件替换硬编码颜色为变量
|
||||
5. **步骤 5**:重新设计 `LoginView.vue`
|
||||
6. **步骤 6**:删除 `src/assets/font-awesome/` 目录和 `main.ts` 的 import
|
||||
7. **步骤 7**:运行 `npm run type-check` 和 `npm run build` 验证
|
||||
|
||||
每步完成后截图(手动)确认未引入新问题。
|
||||
|
||||
## 验证标准
|
||||
|
||||
- [ ] `npm run type-check` 通过
|
||||
- [ ] `npm run build` 通过
|
||||
- [ ] 浏览器控制台无 CSS 警告(无未定义变量)
|
||||
- [ ] 全站不再有 `#1890ff`、`#67c23a`、`#e6a23c`、`#f56c6c` 这类 EP 旧色硬编码(grep 验证)
|
||||
- [ ] 全站不再有 `class="fa fa-` 的引用(grep 验证)
|
||||
- [ ] 全站不再有 `--warning-color` 这类指向不存在变量的引用
|
||||
- [ ] `grep -c "!important" src/styles/index.scss` 数量从当前值下降 50% 以上
|
||||
- [ ] 登录页与主应用风格一致
|
||||
|
||||
## 风险与回退
|
||||
|
||||
- **风险 1**:Element Plus Icons 不覆盖某些冷门 FA 图标(如 `fa-stop-circle-o` 训练停止)
|
||||
- **应对**:在替换前先做一次图标对照表,确认每个 FA 类名都有 EP 对应项;没有则保留 FA4(不删除 FA 资源),但推进用 EP 主流图标
|
||||
- **风险 2**:CSS 变量替换漏掉某处导致视觉回退
|
||||
- **应对**:分阶段提交,每阶段都跑 build;如有遗漏,EP 会回退到自身默认色
|
||||
- **风险 3**:`!important` 整理引入新覆盖问题
|
||||
- **应对**:整理前先 `git diff` 对比编译产物颜色变化,保留 `git stash` 能力
|
||||
|
||||
## 后续优化(不在本次范围)
|
||||
|
||||
- 抽出 Design Token 文件(`tokens.scss`)做 Figma → Code 同步
|
||||
- 引入 UnoCSS 或 Windi CSS 做原子化样式(取决于团队接受度)
|
||||
- 给所有列表页加 skeleton 加载态
|
||||
- 暗色模式支持(需要先在 EP 主题层注册暗色)
|
||||
@@ -0,0 +1,194 @@
|
||||
# 数据处理创建流程四步向导设计
|
||||
|
||||
日期:2026-07-10
|
||||
|
||||
## 1. 目标
|
||||
|
||||
重构 `/data-process/create`,把当前职责混杂、步骤数量会变化的流程,调整为四个语义稳定、页面内容相互独立的步骤。用户在任一时刻只处理当前任务,同时始终知道所处阶段、前后关系和唯一主操作。
|
||||
|
||||
本次设计沿用现有平台视觉语言:浅灰应用背景、单层白色页面画布、靛蓝主色、克制边框与阴影、固定四步进度导航和底部操作栏。不得出现页面白底中再次嵌套整页白色大卡片的双层白底。
|
||||
|
||||
## 2. 固定流程
|
||||
|
||||
顶部步骤数量始终为四个,不随结构化或非结构化类型变化:
|
||||
|
||||
1. **创建任务**:填写任务信息、上传源文件并选择处理类型。
|
||||
2. **数据预览**:检查系统解析结果;非结构化数据进入切片预览,结构化数据进入记录预览。
|
||||
3. **开始生成**:确认摘要、启动生成并查看进度或错误。
|
||||
4. **结果编辑与保存**:检查并修改生成结果,通过校验后保存任务。
|
||||
|
||||
步骤状态只有 `未开始`、`当前`、`已完成` 三种。用户返回已完成步骤时保留已经填写或编辑的内容,不因切换处理类型而静默清空任务名称、描述和上传文件。
|
||||
|
||||
## 3. 第一步:创建任务
|
||||
|
||||
页面只显示创建任务所需内容:
|
||||
|
||||
- 任务名称和可选描述。
|
||||
- 结构化数据、非结构化数据两种处理类型。
|
||||
- 单文件上传区、文件格式和大小约束。
|
||||
- 上传后的基础识别结果:文件名、大小、格式、记录数或文本长度、校验状态。
|
||||
|
||||
底部操作:`取消`、`保存草稿`、唯一主操作 `继续:数据预览`。
|
||||
|
||||
切换处理类型时仅更新后续预览和生成策略;如果已经产生预览结果,需要明确提示预览将重新计算,确认后才清除后续步骤数据。
|
||||
|
||||
## 4. 第二步:数据预览
|
||||
|
||||
### 4.1 页面职责
|
||||
|
||||
本页只负责核对源数据与系统预览结果的对应关系,并允许修改不合理的切片或记录内容。不得显示第一步的大量表单、第三步的生成进度或第四步的最终结果表格。
|
||||
|
||||
页面顶部仅保留一行来源摘要,例如:
|
||||
|
||||
`finance_qa.jsonl · 非结构化数据 · 8,560 个切片`
|
||||
|
||||
### 4.2 非结构化数据:源文件与切片对照
|
||||
|
||||
主体为同一白色画布中的左右分栏,使用竖向分隔线区分,不使用两个浮动大卡片:
|
||||
|
||||
- **左侧约 58%:源文件阅读器**
|
||||
- 只读展示完整源文件,支持滚动和行号。
|
||||
- 当前切片对应的原文使用浅靛蓝背景高亮,并在左边缘显示定位条。
|
||||
- 顶部提示当前定位,例如 `已定位到切片 #002`。
|
||||
- 左侧原文始终保持源文件真实性,修改右侧切片不会直接改写源文件。
|
||||
|
||||
- **右侧约 42%:切片内容**
|
||||
- 上方为切片列表,支持按编号或内容搜索、仅查看已修改项。
|
||||
- 每个切片显示编号、来源行范围、Token 数和修改状态。
|
||||
- 下方为当前切片的可编辑正文、字符数、Token 数和自动保存状态。
|
||||
- 提供上一片、下一片和恢复原始内容。
|
||||
|
||||
### 4.3 对照定位交互
|
||||
|
||||
右侧切片是主选择源,左侧原文是定位反馈:
|
||||
|
||||
1. 用户点击右侧切片。
|
||||
2. 系统读取该切片保存的原始来源范围 `sourceStart`、`sourceEnd`。
|
||||
3. 左侧阅读器自动滚动到目标范围,并将对应原文高亮。
|
||||
4. 右侧切片进入选中态,左右使用一致的浅靛蓝语义色建立对应关系。
|
||||
5. 用户切换上一片或下一片时执行相同的同步定位。
|
||||
|
||||
为了避免跳动,目标范围应滚动到阅读区中部;如果目标已完全可见,则只更新高亮,不重复滚动。
|
||||
|
||||
### 4.4 编辑与来源映射
|
||||
|
||||
- 编辑切片正文只改变待生成内容,来源映射仍指向最初的原文范围。
|
||||
- 编辑后显示 `已修改,草稿已自动保存`,并允许恢复到系统初始切片内容。
|
||||
- 修改后的文本与原文不再完全一致时,左侧仍高亮原始来源,右侧显示 `内容已调整`,避免误解为源文件被改写。
|
||||
- 手动新增的切片没有原始来源,显示 `手动新增,无源文件定位`,左侧清除高亮但保留当前位置。
|
||||
- 删除切片需要二次确认;删除只影响本次处理,不修改源文件。
|
||||
- 找不到来源范围或范围越界时,右侧显示 `无法定位原文`,左侧不进行错误滚动,用户仍可编辑该切片。
|
||||
|
||||
### 4.5 结构化数据适配
|
||||
|
||||
第二步的整体布局和交互不变,但语义调整为记录对照:
|
||||
|
||||
- 左侧展示源文件中的表格行或原始 JSON 记录。
|
||||
- 右侧展示系统解析后的单条记录及字段值。
|
||||
- 点击右侧记录,左侧定位并高亮对应源行或 JSON 范围。
|
||||
|
||||
这样可以保持四步流程稳定,同时避免对结构化数据使用不准确的“切片”文案。
|
||||
|
||||
底部操作:`返回:创建任务`、`保存草稿`、唯一主操作 `确认预览并继续`。
|
||||
|
||||
## 5. 第三步:开始生成
|
||||
|
||||
页面只显示:
|
||||
|
||||
- 任务名称、数据类型、文件、预览条目数和修改条目数的只读摘要。
|
||||
- 唯一启动操作 `开始生成`。
|
||||
- 启动后的进度、当前阶段、耗时和状态日志。
|
||||
- 失败时显示错误摘要与 `重试`;运行中提供 `停止生成`,停止前需要二次确认。
|
||||
|
||||
启动前不显示底部“下一步”与页面内“开始生成”两个竞争按钮。生成成功后,唯一主操作切换为 `查看生成结果`。
|
||||
|
||||
## 6. 第四步:结果编辑与保存
|
||||
|
||||
结果不再使用一组原始 JSON 文本框堆叠展示,而采用结构化编辑列表:
|
||||
|
||||
- 桌面端左侧为结果条目列表,支持搜索、错误筛选和已修改筛选。
|
||||
- 桌面端右侧为当前结果的字段编辑区;较窄窗口改为列表在上、编辑区在下。
|
||||
- 每条结果显示校验状态,错误定位到具体字段。
|
||||
- 支持上一条、下一条、恢复生成结果。
|
||||
- 保存前执行完整校验;有阻断错误时禁止保存并聚焦首个错误。
|
||||
|
||||
底部操作:`返回:开始生成`、`保存草稿`、唯一主操作 `保存任务`。
|
||||
|
||||
## 7. 状态与数据模型
|
||||
|
||||
向导需要维护统一草稿状态:
|
||||
|
||||
```ts
|
||||
interface DataProcessDraft {
|
||||
task: TaskInfo
|
||||
sourceFile: SourceFileMeta
|
||||
processingType: 'structured' | 'unstructured'
|
||||
previewItems: PreviewItem[]
|
||||
selectedPreviewId: string | null
|
||||
generation: GenerationState
|
||||
results: ResultItem[]
|
||||
currentStep: 1 | 2 | 3 | 4
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
interface PreviewItem {
|
||||
id: string
|
||||
originalContent: string
|
||||
editedContent: string
|
||||
sourceStart: number | null
|
||||
sourceEnd: number | null
|
||||
sourceStartLine: number | null
|
||||
sourceEndLine: number | null
|
||||
tokenCount: number
|
||||
status: 'original' | 'modified' | 'manual' | 'invalid'
|
||||
}
|
||||
```
|
||||
|
||||
源文件位置以字符偏移作为定位依据,行号用于展示。这样即使原文存在长行或不同换行符,也能稳定高亮准确范围。
|
||||
|
||||
## 8. 页面组件边界
|
||||
|
||||
- `DataProcessWizardShell`:固定四步导航、页面标题和底部操作栏。
|
||||
- `TaskSetupStep`:任务信息、类型和文件上传。
|
||||
- `PreviewStep`:数据预览容器,按处理类型选择预览器。
|
||||
- `SourceViewer`:源文件渲染、滚动定位和范围高亮。
|
||||
- `PreviewItemList`:切片或记录列表、搜索与选择。
|
||||
- `PreviewItemEditor`:当前条目编辑、状态和恢复。
|
||||
- `GenerationStep`:启动、进度、失败与重试。
|
||||
- `ResultReviewStep`:生成结果列表、字段编辑和保存校验。
|
||||
|
||||
`SourceViewer` 不感知切片列表内部逻辑,只接收选中范围;`PreviewItemList` 不直接操纵滚动,只上报选中项。同步行为由 `PreviewStep` 统一协调。
|
||||
|
||||
## 9. 异常与离开保护
|
||||
|
||||
- 上传失败、解析失败、预览生成失败分别提供明确原因和重试入口。
|
||||
- 预览数据加载时显示骨架或局部加载状态,不能让整个白色画布闪烁或短暂回退到列表页。
|
||||
- 刷新、返回列表或关闭页面前,如果存在未持久化修改,必须提示保存草稿或放弃更改。
|
||||
- 自动保存失败时保留本地编辑内容,并显示可重试状态。
|
||||
- 生成过程中禁止无提示切换处理类型或替换源文件。
|
||||
|
||||
## 10. 响应式与可访问性
|
||||
|
||||
- 桌面端左右对照为默认布局,分栏宽度允许拖动但设置最小宽度。
|
||||
- 较窄窗口下改为上下布局:源文件在上,切片在下;选中切片后仍自动定位高亮。
|
||||
- 选中、高亮、修改和错误状态不能只依赖颜色,还需文字、图标或边缘标记。
|
||||
- 键盘支持上下切换切片、聚焦编辑器、保存草稿;滚动定位后不抢走编辑器焦点。
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
1. 结构化和非结构化流程都始终显示四个固定步骤。
|
||||
2. 每一步只包含该阶段必要内容,页面中不存在跨步骤主操作竞争。
|
||||
3. 点击任一有来源映射的右侧切片后,左侧在一次交互内定位并高亮准确原文范围。
|
||||
4. 已完全可见的范围不会产生多余滚动;不可定位时有明确反馈且不破坏编辑。
|
||||
5. 修改切片不会改写左侧源文件,高亮仍指向原始来源。
|
||||
6. 返回前一步再进入时,选中项、编辑内容、滚动上下文和草稿状态可恢复。
|
||||
7. 生成开始前只存在一个明确启动操作;生成成功后才允许进入结果编辑。
|
||||
8. 最终结果保存前执行字段级校验,阻断错误可准确定位。
|
||||
9. 路由切换不出现列表页或灰色背景闪烁,白色页面画布保持连续。
|
||||
10. 页面在常用桌面宽度下无横向溢出,底部操作栏不遮挡主体内容。
|
||||
|
||||
## 12. 视觉参考
|
||||
|
||||
- 当前流程截图:`docs/superpowers/specs/assets/data-process-create-audit/01-create-task.png`
|
||||
- 当前动态步骤截图:`docs/superpowers/specs/assets/data-process-create-audit/02-unstructured-step-change.png`
|
||||
- 最终左右对照修订稿:`/Users/caoxiaozhu/.codex/generated_images/019f4981-cb06-79f0-b387-1d685e74ceea/exec-c72de596-4c50-405b-8987-43d122e02b86.png`
|
||||
@@ -0,0 +1,53 @@
|
||||
# 页面表面分类设计
|
||||
|
||||
## 目标
|
||||
|
||||
修正主布局无条件套用白色页面画布的问题,使页面层级符合以下规则:
|
||||
|
||||
- 列表页已经拥有自己的白色卡片,因此直接放在灰色应用背景上。
|
||||
- 表单页、详情页和对话页继续使用统一的白色圆角页面画布。
|
||||
- 页面分类由路由显式声明,避免依赖路径字符串或 DOM 结构猜测。
|
||||
|
||||
## 根因
|
||||
|
||||
`MainLayout.vue` 当前无条件为所有子路由渲染 `.page-canvas`。同时,列表页通过
|
||||
`DataTablePage`、`.eval-card-container` 或 `.manage-card-container` 自带白色卡片,
|
||||
因此形成“灰色外层 → 白色页面画布 → 白色列表卡片”的重复层级。
|
||||
|
||||
## 设计
|
||||
|
||||
在列表路由的 `meta` 中增加 `pageSurface: 'self'`:
|
||||
|
||||
- `/fine-tune`
|
||||
- `/model-eval`
|
||||
- `/model-inference`
|
||||
- `/model-manage`
|
||||
- `/data-process`
|
||||
- `/dataset`
|
||||
|
||||
其他路由保持默认行为,不需要逐项声明,仍由主布局提供白色页面画布。
|
||||
|
||||
`MainLayout.vue` 根据 `route.meta.pageSurface` 给 `.page-canvas` 添加
|
||||
`.is-self-surface` 状态类。该状态仅取消外层画布的白色背景、圆角、阴影和内边距,
|
||||
保留列表页自身的卡片样式。
|
||||
|
||||
对于默认白色画布页面,如果路由直接输出 `PageCard`,可直接扁平化;如果由页面
|
||||
根容器包裹主 `PageCard`,根容器必须显式声明 `.page-card-host`。只有这两类主卡片
|
||||
改为透明、无圆角、无阴影,内部业务卡片不受影响,避免创建页出现“白色画布再套
|
||||
白色卡片”,同时不误伤训练日志的参数卡片。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 六个列表路由只显示自身的一层白色卡片,外部可见灰色页面背景。
|
||||
2. `/training-log/:id`、创建页、编辑页等仍显示白色圆角页面画布。
|
||||
3. 不根据 URL 文本或 DOM 自动判断页面类型。
|
||||
4. 不改变列表、表单和详情页现有业务逻辑。
|
||||
5. 桌面宽度下不产生水平滚动。
|
||||
6. 创建训练任务等表单页只显示一层白色画布,根 `PageCard` 不形成第二层表面。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 静态回归脚本验证六个列表路由都声明 `pageSurface: 'self'`。
|
||||
- 静态回归脚本验证 `MainLayout` 根据路由 meta 设置状态类。
|
||||
- 静态回归脚本验证 `.is-self-surface` 取消白底、内边距、圆角和阴影。
|
||||
- 浏览器分别检查一个列表页和一个详情页。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 数据预览切片编辑模式
|
||||
|
||||
## 目标
|
||||
|
||||
第二步右侧区域不再把切片列表和正文编辑器上下堆叠。默认仅展示可搜索、可分页的切片列表;编辑时在同一右侧区域进入单独的编辑视图。
|
||||
|
||||
## 列表模式
|
||||
|
||||
- 每行显示切片编号与来源行号。
|
||||
- 每行右侧提供铅笔和垃圾桶图标按钮,分别用于编辑与删除。
|
||||
- 不显示 Token、修改状态和“仅看已修改”筛选。
|
||||
- 保留按编号或正文搜索及每页 6 条分页。
|
||||
|
||||
## 编辑模式
|
||||
|
||||
- 点击铅笔后,右侧替换为当前切片的编辑界面,左侧源文件继续高亮对应行。
|
||||
- 编辑使用组件内临时草稿;输入过程不更新父级预览数据。
|
||||
- “保存修改”将草稿写回父级数据并返回列表。
|
||||
- “取消”或“返回列表”丢弃未保存草稿并返回列表,用户可继续选择其他切片。
|
||||
- 删除继续使用确认弹窗;确认后删除该条并返回列表。
|
||||
|
||||
## 边界与验证
|
||||
|
||||
- 翻页、搜索或切换文件时,应退出编辑模式并丢弃未保存草稿,避免将内容写到错误条目。
|
||||
- 切片列表、编辑模式切换、保存和取消行为由向导回归脚本覆盖。
|
||||
27
docs/superpowers/specs/2026-07-10-route-transition-design.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 二级页面路由转场设计
|
||||
|
||||
## 目标
|
||||
|
||||
消除列表页进入新建、详情、编辑等二级页面时的透明闪烁,使路由内容与页面表面模式在同一帧完成切换。
|
||||
|
||||
## 根因
|
||||
|
||||
`MainLayout.vue` 使用 `transition name="fade" mode="out-in"`。路由变化时,
|
||||
`route.meta.pageSurface` 会立即切换外层画布模式,但旧页面仍执行 150ms 的离场淡出,
|
||||
因此旧列表会短暂显示在新页面的白色画布中并处于半透明状态。
|
||||
|
||||
## 已选方案
|
||||
|
||||
移除主布局的页面级 `transition` 和对应 `.fade-*` 样式,让 `router-view` 组件立即替换。
|
||||
列表、详情、新建和编辑页面的表面模式将与内容同步切换。
|
||||
|
||||
保留组件内部动效,包括弹窗、折叠、按钮状态、批量操作栏等局部交互动画。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `MainLayout.vue` 不再使用页面级 `<transition>`。
|
||||
2. 主布局不再包含 `.fade-enter-*` 或 `.fade-leave-*` 透明度规则。
|
||||
3. 列表进入新建、列表进入训练日志、二级页面返回列表时,不出现旧页面半透明中间帧。
|
||||
4. 页面表面分类和根 `PageCard` 扁平化逻辑保持不变。
|
||||
5. 页面无水平溢出,控制台无新增错误。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 源数据上传紧凑文件列表设计
|
||||
|
||||
日期:2026-07-10
|
||||
|
||||
## 1. 目标
|
||||
|
||||
优化数据处理创建页“源数据上传”区域中已选文件的呈现方式。当前每个文件
|
||||
占用一张独立的大卡片;文件数量增多时,页面会被文件清单拉长,用户也难以
|
||||
快速定位和删除某一文件。
|
||||
|
||||
本次改动将已上传文件改为每页最多 10 条的紧凑列表,并让上传入口随文件状态切换:
|
||||
空状态使用大尺寸拖拽上传区,已有文件时切换为列表标题右侧的小型继续上传按钮。
|
||||
这样能保持文件状态和逐项删除能力,让大量文件的查看与操作集中在一个稳定区域内。
|
||||
|
||||
## 2. 方案选择
|
||||
|
||||
评估过三种方式:
|
||||
|
||||
1. 文件名标签流:占用最少,但长文件名、状态与删除操作会拥挤,不适合本页。
|
||||
2. 折叠摘要:页面最短,但需要额外展开才能操作,增加一步操作成本。
|
||||
3. 固定高度紧凑列表:每个文件是一行,状态和删除按钮始终可见;超过可见
|
||||
行数后在列表内滚动。
|
||||
|
||||
采用第三种方案。它保留当前已有的逐项删除能力,同时解决页面高度随文件数
|
||||
量线性增长的问题。
|
||||
|
||||
## 3. 布局与交互
|
||||
|
||||
空状态只显示现有的大尺寸拖拽上传区及格式提示,用于首次添加文件。
|
||||
|
||||
存在文件后,隐藏大尺寸拖拽上传区和其格式提示,在原位置显示文件列表:
|
||||
|
||||
- 列表顶部左侧显示 `已添加 N 个文件`,其中 `N` 为当前文件数;右侧显示
|
||||
小型 `继续上传` 按钮。
|
||||
- `继续上传` 复用原上传控件的多文件选择、格式限制、非自动上传和文件变更事件,
|
||||
只改变触发入口的尺寸与位置。
|
||||
- 每页最多显示 10 行;超过 10 个文件时,在列表底部显示分页器,不使用长列表
|
||||
或内部滚动承载额外文件。
|
||||
- 新增文件后自动切换到最后一页,确保用户能立刻看到刚添加的文件;删除当前页
|
||||
的最后一个文件后,自动回退到仍有文件的最后一页。
|
||||
- 单行高度约 48px,使用细分隔线区分,不再使用外框、左侧绿条和大尺寸图标。
|
||||
- 每行从左至右依次为:文件类型图标、文件名、大小与条目数、校验状态、删除。
|
||||
- 文件名占据可伸缩区域;空间不足时省略,保留原始 `title` 以便悬停查看全名。
|
||||
- 删除按钮沿用红色文字链接,点击后继续使用当前的移除事件;不新增确认弹窗
|
||||
或批量删除,以免改变既有操作语义。
|
||||
- 校验状态沿用绿色图标和“校验通过”文案;后续若存在失败状态,可在同一位置
|
||||
显示错误文案而不改变列表结构。
|
||||
|
||||
## 4. 视觉约束
|
||||
|
||||
- 延续页面的靛蓝文件图标、绿色成功状态和红色危险操作色;继续上传按钮使用现有
|
||||
主色的紧凑次级按钮样式。
|
||||
- 列表容器使用现有浅灰边框和 8px 圆角,背景保持白色;不新增大卡片层级。
|
||||
- 仅当文件数超过 10 个时显示分页器;分页器使用紧凑的上一页、页码、下一页布局,
|
||||
不提供页大小选择或跳页输入,避免增加不必要的操作。
|
||||
- 在窄屏下,元信息可收缩,文件名仍保持最优先;删除按钮必须始终可见且可点按。
|
||||
|
||||
## 5. 组件边界与数据流
|
||||
|
||||
改动只在 `TaskSetupStep` 内完成:
|
||||
|
||||
- 继续使用现有 `uploadedFiles` 属性和 `remove-file` 事件。
|
||||
- 不修改文件上传、格式限制、校验逻辑或父组件中的文件数据模型。
|
||||
- 新增的文件总数来自 `uploadedFiles.length`,每行信息继续来自现有文件元数据。
|
||||
|
||||
## 6. 可访问性
|
||||
|
||||
- 删除按钮继续使用真实的 `el-button`,确保键盘可聚焦和激活。
|
||||
- 文件名省略时通过原生 `title` 暴露完整名称。
|
||||
- 校验状态同时保留图标和文字,不能只依靠颜色传达结果。
|
||||
- 滚动列表不截断键盘焦点;聚焦到不可见的删除按钮时,浏览器应能将对应行带入
|
||||
可见区域。
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 尚未添加文件时,只显示大尺寸拖拽上传区与格式提示,不显示文件列表。
|
||||
2. 上传至少 1 个文件后,大尺寸上传区与格式提示不再显示;文件列表标题右侧显示
|
||||
可继续选择多个文件的小型上传按钮。
|
||||
3. 上传 1 至 10 个文件时,所有文件在紧凑列表中直接可见,每行均可单独删除。
|
||||
4. 上传超过 10 个文件时,列表一次只渲染当前 10 条,分页器可切换其他文件页;
|
||||
页面高度不随总文件数继续增长。
|
||||
5. 新增文件后自动显示最后一页;删除造成当前页没有文件时,自动回到有效页。
|
||||
6. 每一行均展示文件名、大小、可选条目数、校验状态和删除操作。
|
||||
7. 长文件名不会挤压删除按钮,且悬停时可获取完整名称。
|
||||
8. 处理类型切换、格式限制、首次上传、继续上传和删除事件的现有行为不变。
|
||||
9. 常用桌面宽度和窄屏下均无横向溢出,删除操作保持可访问。
|
||||
@@ -0,0 +1,68 @@
|
||||
# 训练日志详情页双栏改版设计
|
||||
|
||||
**日期**:2026-07-10
|
||||
**页面**:`frontend/src/views/system/TrainingLogView.vue`
|
||||
**视觉基准**:用户选定的第 2 套方案(双栏任务档案型)
|
||||
**视觉稿**:`docs/superpowers/specs/assets/training-log-detail-option-2.png`
|
||||
|
||||
## 目标
|
||||
|
||||
把当前多组带边框 `el-descriptions` 表格改成更易扫描的任务档案页:左侧聚合任务信息与数据集,右侧集中展示运行概况,下方用轻量折叠区呈现训练参数。数据请求、日志刷新、训练曲线和原始日志行为保持不变。
|
||||
|
||||
## 选定布局
|
||||
|
||||
1. 页面顶部保留任务名与状态,并增加任务类型、创建时间等辅助上下文。
|
||||
2. 首屏主体使用 `2fr / 0.9fr` 双栏:
|
||||
- 左栏依次为“任务信息”和“训练数据集”;
|
||||
- 右栏为“运行概况”,集中展示时间、时长、存储、训练方式和训练方法。
|
||||
3. 数据集名称与描述优先,条数和大小放入同一条浅色指标带。
|
||||
4. 训练参数与 LoRA 参数使用同一卡片内的两个分组;标签和值采用轻分隔行,不再使用描边表格。
|
||||
5. 缺失值统一显示“未配置”,输出模型缺失显示“暂未生成”,避免大量孤立的 `-`。
|
||||
|
||||
## 视觉规则
|
||||
|
||||
- 应用外壳使用 `--app-shell-bg: #f3f5f8`,灰色只作为页面四周的留白背景。
|
||||
- 每个业务路由统一渲染在 `--app-page-bg: #ffffff` 的白色圆角页面画布中,所有页面内容都基于这层白底布局。
|
||||
- 页面内部白色内容表面继续使用 `--app-surface-bg: #ffffff`;当路由根节点本身是 `PageCard` 时,移除其额外阴影和外边距,避免白底里再套一张重复大卡片。
|
||||
- 沿用项目现有 Indigo `#4f46e5`、Slate 灰阶和成功色 `#10b981`。
|
||||
- 一级卡片使用现有 `PageCard` 和 12px 实际圆角;内部块使用 8px 圆角。
|
||||
- 仅使用 `#e2e8f0` / `#f1f5f9` 轻分隔,不新增阴影层级和渐变。
|
||||
- 标题 18px/600,分组标题 16px/600,正文值 14px,辅助标签 12–13px。
|
||||
- 图标继续使用项目现有 Font Awesome 4,避免引入依赖或扩大本次改造范围。
|
||||
|
||||
## 交互与响应式
|
||||
|
||||
- 参数折叠头改为原生 `button`,提供 `aria-expanded`、可见焦点和至少 44px 点击高度。
|
||||
- 宽屏为双栏;`<= 1100px` 改为单栏;参数明细在窄屏下降为单列。
|
||||
- 保持 5 秒自动刷新、图表、汇总和原始日志逻辑不变。
|
||||
|
||||
## 范围
|
||||
|
||||
### 修改
|
||||
|
||||
- `frontend/src/views/system/TrainingLogView.vue`
|
||||
- `frontend/src/layouts/MainLayout.vue`
|
||||
- `frontend/src/styles/index.scss`
|
||||
- `frontend/package.json`
|
||||
- `frontend/scripts/regression-training-log-layout.mjs`
|
||||
- `frontend/scripts/regression-page-surface.mjs`
|
||||
|
||||
### 不修改
|
||||
|
||||
- API、Pinia、类型定义、路由和 mock 数据。
|
||||
- 全局 `PageCard.vue`。
|
||||
- 训练曲线、训练汇总和日志输出的业务逻辑。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 首屏不再使用带边框的详情表格表达任务、数据集和训练参数。
|
||||
- 双栏信息架构与选定视觉稿一致,缺失值文案可读。
|
||||
- 参数折叠支持鼠标、键盘和辅助技术。
|
||||
- 1100px 以下无双栏挤压或横向滚动。
|
||||
- 两个专项回归脚本与 `npx vite build` 通过。
|
||||
- `npm run type-check` / `npm run build` 使用项目引用模式执行;当前仓库原有的 mock 与微调页面类型错误需如实记录,且本次改动文件不得出现在错误列表中。
|
||||
|
||||
## 风险
|
||||
|
||||
- 生成视觉稿只覆盖首屏,图表区继续沿用现状;实现以首屏信息架构为高保真目标。
|
||||
- 项目目录没有 Git 元数据,无法提交设计文档或代码提交;所有变更保持小范围、可直接审阅。
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 390 KiB |
BIN
docs/superpowers/specs/assets/page-white-canvas-comparison.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
docs/superpowers/specs/assets/page-white-canvas-reference.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/superpowers/specs/assets/route-transition-detail-final.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 851 KiB |
BIN
docs/superpowers/specs/assets/training-log-detail-comparison.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 856 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/superpowers/specs/assets/training-log-detail-option-2.png
Normal file
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 83 KiB |
22
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# 自动生成的类型声明
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
66
frontend/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 远光软件微调平台 - 前端(Vue 3)
|
||||
|
||||
> 由原 `web/` 静态多页面 HTML 项目改造而来。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Vue 3** + **TypeScript** + **Vite**
|
||||
- **Vue Router 4** - 路由
|
||||
- **Pinia** - 状态管理
|
||||
- **Element Plus** - UI 组件库(主色 `#1890ff`)
|
||||
- **axios** - HTTP 请求
|
||||
- **Chart.js** - 训练曲线图表
|
||||
- **marked + DOMPurify** - Markdown 渲染
|
||||
- **md-editor-v3** - Prompt 编辑器
|
||||
- **@vueuse/core** - 组合式工具
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
开发服务器默认运行在 `http://localhost:6801`。
|
||||
|
||||
后端 API 默认通过 Vite 代理转发到 `http://localhost:7861`(见 `vite.config.ts`)。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build # 类型检查 + 生产构建,产物在 dist/
|
||||
npm run preview # 本地预览构建产物
|
||||
npm run type-check # 仅类型检查
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # axios 封装 + 各业务模块 API
|
||||
├── components/ # 公共组件(侧边栏、顶部栏、通用列表等)
|
||||
├── composables/ # 组合式函数(流式对话、图表、轮询等)
|
||||
├── constants/ # 常量与映射表
|
||||
├── layouts/ # 主布局
|
||||
├── router/ # 路由配置 + 登录守卫
|
||||
├── stores/ # Pinia 状态(auth/system/models/tools)
|
||||
├── styles/ # 全局样式与主题
|
||||
├── types/ # TypeScript 类型定义
|
||||
└── views/ # 页面视图
|
||||
├── login/ # 登录
|
||||
├── fine-tune/ # 模型调优
|
||||
├── eval/ # 模型评测
|
||||
├── inference/ # 模型推理
|
||||
├── compare/ # 模型对比
|
||||
├── model/ # 模型管理
|
||||
├── dataset/ # 数据集
|
||||
├── tools/ # 其他工具
|
||||
└── system/ # 平台性能 / 日志
|
||||
```
|
||||
|
||||
## 与原 web/ 项目的关系
|
||||
|
||||
- 原 `web/` 目录**保留不动**,作为功能对照参照。
|
||||
- 新前端位于 `frontend/`,新旧可并存。
|
||||
- 后端 API 端点完全不变,仅前端实现方式改为 Vue SPA。
|
||||
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>远光软件微调平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
4242
frontend/package-lock.json
generated
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "yg-ft-platform-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc -b --noEmit",
|
||||
"test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs",
|
||||
"test:model-manage": "node scripts/regression-model-manage.mjs",
|
||||
"test:training-log-layout": "node scripts/regression-training-log-layout.mjs",
|
||||
"test:page-surface": "node scripts/regression-page-surface.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"chart.js": "^4.4.7",
|
||||
"dompurify": "^3.2.3",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"marked": "^15.0.5",
|
||||
"md-editor-v3": "^5.1.4",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sass": "^1.83.0",
|
||||
"typescript": "~5.7.2",
|
||||
"unplugin-auto-import": "^0.19.0",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
35
frontend/scripts/regression-back-navigation.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
|
||||
function read(relativePath) {
|
||||
return readFileSync(resolve(root, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const appHeader = read('src/components/AppHeader.vue')
|
||||
const trainingLog = read('src/views/system/TrainingLogView.vue')
|
||||
|
||||
assert(
|
||||
appHeader.includes('showBackButton'),
|
||||
'AppHeader should gate the global back button behind showBackButton',
|
||||
)
|
||||
|
||||
assert(
|
||||
appHeader.includes('v-if="showBackButton"'),
|
||||
'AppHeader should hide 返回上一页 when the current route is not a detail/sub page',
|
||||
)
|
||||
|
||||
assert(
|
||||
!trainingLog.includes('返回列表'),
|
||||
'TrainingLogView should rely on the global 返回上一页 button instead of rendering 返回列表',
|
||||
)
|
||||
|
||||
console.log('back-navigation regression checks passed')
|
||||
411
frontend/scripts/regression-data-process-wizard.mjs
Normal file
@@ -0,0 +1,411 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue')
|
||||
const createDir = path.resolve(scriptDir, '../src/views/data-process/create')
|
||||
const layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue')
|
||||
const viewSource = await readFile(viewPath, 'utf8')
|
||||
const layoutSource = await readFile(layoutPath, 'utf8')
|
||||
|
||||
assert.match(viewSource, /const WIZARD_STEPS = \[/, '向导步骤尚未改为固定常量')
|
||||
for (const title of ['创建任务', '数据预览', '开始生成', '结果编辑与保存']) {
|
||||
assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`)
|
||||
}
|
||||
assert.doesNotMatch(viewSource, /steps\s*=\s*computed|all\.filter/, '步骤仍根据处理类型动态增减')
|
||||
assert.match(viewSource, /localStorage\.setItem\(DRAFT_STORAGE_KEY/, '草稿没有持久化')
|
||||
assert.match(viewSource, /localStorage\.getItem\(DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
|
||||
assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
|
||||
|
||||
const expectedComponents = [
|
||||
'TaskSetupStep.vue',
|
||||
'PreviewCompareStep.vue',
|
||||
'GenerationStep.vue',
|
||||
'ResultEditorStep.vue',
|
||||
]
|
||||
for (const component of expectedComponents) {
|
||||
assert.ok(existsSync(path.join(createDir, component)), `缺少步骤组件:${component}`)
|
||||
assert.ok(viewSource.includes(component.replace('.vue', '')), `父页面未使用:${component}`)
|
||||
}
|
||||
|
||||
const typesPath = path.join(createDir, 'types.ts')
|
||||
const modelPath = path.join(createDir, 'previewModel.ts')
|
||||
assert.ok(existsSync(typesPath), '缺少向导类型定义')
|
||||
assert.ok(existsSync(modelPath), '缺少来源映射模型')
|
||||
|
||||
const [typesSource, modelSource, previewSource] = await Promise.all([
|
||||
readFile(typesPath, 'utf8'),
|
||||
readFile(modelPath, 'utf8'),
|
||||
readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'),
|
||||
])
|
||||
|
||||
for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) {
|
||||
assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`)
|
||||
}
|
||||
assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识')
|
||||
assert.match(modelSource, /export function buildPreviewItems/, '缺少切片来源映射生成函数')
|
||||
assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数')
|
||||
assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识')
|
||||
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
|
||||
assert.match(
|
||||
viewSource,
|
||||
/buildPreviewItems\(file\.content, processType\.value, String\(file\.uid\)\)/,
|
||||
'预览没有按文件分别生成',
|
||||
)
|
||||
|
||||
for (const marker of [
|
||||
'preview-workspace',
|
||||
'source-viewer',
|
||||
'source-line',
|
||||
'is-highlighted',
|
||||
'preview-item',
|
||||
'preview-editor',
|
||||
'scrollIntoView',
|
||||
]) {
|
||||
assert.ok(previewSource.includes(marker), `第二步缺少结构或行为:${marker}`)
|
||||
}
|
||||
assert.match(previewSource, /sourceStart/, '第二步未使用来源起始偏移')
|
||||
assert.match(previewSource, /sourceEnd/, '第二步未使用来源结束偏移')
|
||||
assert.match(previewSource, /filterable/, '文件选择器必须可搜索')
|
||||
assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器')
|
||||
assert.doesNotMatch(previewSource, /located-badge|sync-label|已定位到/, '源文件栏不应显示冗余定位提示')
|
||||
assert.match(previewSource, /const PREVIEW_PAGE_SIZE = 6/, '切片列表必须限制每页展示数量')
|
||||
assert.match(previewSource, /const pagedItems = computed/, '切片列表缺少分页数据')
|
||||
assert.match(previewSource, /v-for="item in pagedItems"/, '切片列表没有使用分页数据')
|
||||
assert.match(previewSource, /<el-pagination[\s\S]*:page-size="PREVIEW_PAGE_SIZE"/, '切片列表缺少分页控件')
|
||||
assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '预览工作区高度不足以展示切片正文')
|
||||
assert.match(previewSource, /const editingItemId = ref<string \| null>\(null\)/, '缺少切片编辑模式状态')
|
||||
assert.match(previewSource, /const editorDraft = ref\(''\)/, '缺少编辑临时草稿')
|
||||
assert.match(previewSource, /function openEditor\(item: PreviewItem\)/, '列表缺少打开切片编辑器的动作')
|
||||
assert.match(previewSource, /function closeEditor\(\)/, '编辑器缺少返回列表的动作')
|
||||
assert.match(previewSource, /function saveEditor\(\)/, '编辑器缺少保存动作')
|
||||
assert.match(previewSource, /<template v-if="!editingItem">[\s\S]*?<template v-else>/, '切片列表与编辑器必须互斥展示')
|
||||
assert.match(previewSource, /fa-pencil/, '切片列表缺少铅笔编辑按钮')
|
||||
assert.match(previewSource, /fa-trash-o/, '切片列表缺少垃圾桶删除按钮')
|
||||
assert.match(previewSource, /class="preview-item"[\s\S]*?@click="selectItem\(item\.id\)"/, '点击切片行必须更新当前选中切片')
|
||||
assert.match(previewSource, /v-model="editorDraft"/, '编辑器必须绑定临时草稿')
|
||||
assert.match(previewSource, />取消<\/el-button>/, '编辑器缺少取消按钮')
|
||||
assert.doesNotMatch(previewSource, /返回列表/, '编辑器不应同时显示返回列表和取消两个相同作用的按钮')
|
||||
assert.match(previewSource, />保存修改<\/el-button>/, '编辑器缺少保存修改按钮')
|
||||
assert.match(previewSource, /\.editor-actions\s*\{[\s\S]*?justify-content:\s*flex-end/, '取消和保存按钮必须在编辑器右侧对齐')
|
||||
assert.doesNotMatch(previewSource, /item-token|item-status|modifiedOnly|仅看已修改/, '切片列表不应再显示 Token 或修改状态')
|
||||
assert.match(previewSource, /\.preview-editor\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?overflow-y:\s*auto/, '编辑模式必须占据右侧剩余区域并可滚动')
|
||||
assert.match(previewSource, /@media \(max-width: 900px\)/, '第二步缺少窄屏上下布局')
|
||||
|
||||
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
|
||||
const taskSetupSource = await readFile(taskSetupPath, 'utf8')
|
||||
|
||||
function findNextStyleBlockStart(source, startIndex) {
|
||||
let quote = null
|
||||
|
||||
for (let index = startIndex; index < source.length; index += 1) {
|
||||
const character = source[index]
|
||||
const nextCharacter = source[index + 1]
|
||||
|
||||
if (quote) {
|
||||
if (character === '\\') {
|
||||
index += 1
|
||||
} else if (character === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === '/' && nextCharacter === '*') {
|
||||
const commentEnd = source.indexOf('*/', index + 2)
|
||||
index = commentEnd === -1 ? source.length : commentEnd + 1
|
||||
continue
|
||||
}
|
||||
if (character === '/' && nextCharacter === '/') {
|
||||
const commentEnd = source.indexOf('\n', index + 2)
|
||||
index = commentEnd === -1 ? source.length : commentEnd
|
||||
continue
|
||||
}
|
||||
if (character === '\'' || character === '"') {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if (character === '{') return index
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function findStyleBlockEnd(source, blockStart) {
|
||||
let depth = 0
|
||||
let quote = null
|
||||
|
||||
for (let index = blockStart; index < source.length; index += 1) {
|
||||
const character = source[index]
|
||||
const nextCharacter = source[index + 1]
|
||||
|
||||
if (quote) {
|
||||
if (character === '\\') {
|
||||
index += 1
|
||||
} else if (character === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === '/' && nextCharacter === '*') {
|
||||
const commentEnd = source.indexOf('*/', index + 2)
|
||||
index = commentEnd === -1 ? source.length : commentEnd + 1
|
||||
continue
|
||||
}
|
||||
if (character === '/' && nextCharacter === '/') {
|
||||
const commentEnd = source.indexOf('\n', index + 2)
|
||||
index = commentEnd === -1 ? source.length : commentEnd
|
||||
continue
|
||||
}
|
||||
if (character === '\'' || character === '"') {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if (character === '{') {
|
||||
depth += 1
|
||||
} else if (character === '}' && --depth === 0) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function resolveNestedSelector(selector, parentSelector) {
|
||||
if (!parentSelector) return selector
|
||||
if (selector.includes('&')) return selector.replace(/&/g, parentSelector)
|
||||
return `${parentSelector} ${selector}`
|
||||
}
|
||||
|
||||
function collectStyleRules(source, parentSelector = '') {
|
||||
const rules = []
|
||||
let ruleStart = 0
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < source.length) {
|
||||
const blockStart = findNextStyleBlockStart(source, cursor)
|
||||
if (blockStart === -1) break
|
||||
|
||||
const blockEnd = findStyleBlockEnd(source, blockStart)
|
||||
if (blockEnd === -1) break
|
||||
|
||||
const rawSelector = source.slice(ruleStart, blockStart).trim()
|
||||
const declarations = source.slice(blockStart + 1, blockEnd)
|
||||
if (rawSelector) {
|
||||
const isAtRule = rawSelector.startsWith('@')
|
||||
const selector = isAtRule ? rawSelector : resolveNestedSelector(rawSelector, parentSelector)
|
||||
rules.push({ selector, declarations })
|
||||
rules.push(...collectStyleRules(declarations, isAtRule ? parentSelector : selector))
|
||||
}
|
||||
|
||||
cursor = blockEnd + 1
|
||||
ruleStart = cursor
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
function directStyleDeclarations(source) {
|
||||
let result = ''
|
||||
let nestedDepth = 0
|
||||
let quote = null
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index]
|
||||
const nextCharacter = source[index + 1]
|
||||
|
||||
if (quote) {
|
||||
if (character === '\\') {
|
||||
index += 1
|
||||
} else if (character === quote) {
|
||||
quote = null
|
||||
}
|
||||
if (nestedDepth === 0) result += ' '
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === '/' && nextCharacter === '*') {
|
||||
const commentEnd = source.indexOf('*/', index + 2)
|
||||
index = commentEnd === -1 ? source.length : commentEnd + 1
|
||||
if (nestedDepth === 0) result += ' '
|
||||
continue
|
||||
}
|
||||
if (character === '/' && nextCharacter === '/') {
|
||||
const commentEnd = source.indexOf('\n', index + 2)
|
||||
index = commentEnd === -1 ? source.length : commentEnd
|
||||
if (nestedDepth === 0) result += ' '
|
||||
continue
|
||||
}
|
||||
if (character === '\'' || character === '"') {
|
||||
quote = character
|
||||
if (nestedDepth === 0) result += ' '
|
||||
continue
|
||||
}
|
||||
if (character === '{') {
|
||||
nestedDepth += 1
|
||||
if (nestedDepth === 1) result += ' '
|
||||
continue
|
||||
}
|
||||
if (character === '}') {
|
||||
nestedDepth = Math.max(0, nestedDepth - 1)
|
||||
if (nestedDepth === 0) result += ' '
|
||||
continue
|
||||
}
|
||||
if (nestedDepth === 0) result += character
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const nestedUploadedFileItemsStyles = collectStyleRules(`
|
||||
.upload-context {
|
||||
.uploaded-file {
|
||||
&-items {
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1px) {
|
||||
&-items {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (display: grid) {
|
||||
&-items {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`).filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector))
|
||||
|
||||
assert.equal(
|
||||
nestedUploadedFileItemsStyles.length,
|
||||
3,
|
||||
'嵌套的 &-items 选择器必须展开为 .uploaded-file-items,且不能被 at-rule 上下文遮蔽',
|
||||
)
|
||||
for (const property of ['max-height', 'overflow', 'overflow-x', 'overflow-y']) {
|
||||
assert.ok(
|
||||
nestedUploadedFileItemsStyles.some(({ declarations }) =>
|
||||
new RegExp(`(?:^|;)\\s*${property}\\s*:`, 'i').test(directStyleDeclarations(declarations)),
|
||||
),
|
||||
`嵌套 .uploaded-file-items 必须识别受限样式属性:${property}`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const marker of [
|
||||
'uploaded-file-list-header',
|
||||
'uploaded-file-items',
|
||||
'已添加 {{ uploadedFiles.length }} 个文件',
|
||||
':title="file.name"',
|
||||
]) {
|
||||
assert.ok(taskSetupSource.includes(marker), `源数据文件列表缺少:${marker}`)
|
||||
}
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/^const[ \t]+FILE_PAGE_SIZE[ \t]*=[ \t]*10[ \t]*;?[ \t]*$/m,
|
||||
'文件分页大小必须固定为整数 10',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/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(
|
||||
taskSetupSource,
|
||||
/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)]
|
||||
assert.equal(filePaginationTags.length, 1, '文件列表必须只有一个分页器')
|
||||
const [filePaginationTag] = filePaginationTags
|
||||
for (const attribute of [
|
||||
'v-if="uploadedFiles.length > FILE_PAGE_SIZE"',
|
||||
'v-model:current-page="currentFilePage"',
|
||||
':page-size="FILE_PAGE_SIZE"',
|
||||
':total="uploadedFiles.length"',
|
||||
]) {
|
||||
assert.ok(filePaginationTag[0].includes(attribute), `文件分页器缺少属性:${attribute}`)
|
||||
}
|
||||
|
||||
const { descriptor: taskSetupDescriptor } = parseSfc(taskSetupSource, { filename: taskSetupPath })
|
||||
const uploadedFileItemsStyles = taskSetupDescriptor.styles
|
||||
.flatMap(({ content }) => collectStyleRules(content))
|
||||
.filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector))
|
||||
|
||||
for (const { declarations } of uploadedFileItemsStyles) {
|
||||
assert.doesNotMatch(
|
||||
directStyleDeclarations(declarations),
|
||||
/(?:^|;)\s*(?:max-height|overflow|overflow-x|overflow-y)\s*:/i,
|
||||
'文件列表不能用内部滚动替代分页',
|
||||
)
|
||||
}
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/<el-upload\s+v-if="uploadedFiles\.length === 0"[\s\S]*?<\/el-upload>\s*<section\s+v-else\s+class="uploaded-file-list"\s+aria-label="已上传文件列表">/,
|
||||
'有文件状态缺少带 aria-label="已上传文件列表" 的语义列表容器',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>/,
|
||||
'文件列表标题结构或文件数量文案缺失',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">/,
|
||||
'文件列表缺少分页后的文件行容器',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/<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*>/,
|
||||
'无文件时未保留原有大拖拽上传区或上传配置',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/<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*\}\}/,
|
||||
'无文件时大拖拽上传区缺少格式提示槽、处理类型分支或完整格式提示',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/<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>/,
|
||||
'有文件时缺少标题右侧的继续上传触发器或上传配置',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/\.uploaded-file-list-header\s*\{[^}]*display:\s*flex[^}]*justify-content:\s*space-between/,
|
||||
'文件列表标题未布局为右侧继续上传按钮',
|
||||
)
|
||||
assert.match(
|
||||
taskSetupSource,
|
||||
/\.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(
|
||||
taskSetupSource,
|
||||
/<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 template = descriptor.template?.content || ''
|
||||
assert.equal((template.match(/class="wizard-primary-action"/g) || []).length, 1, '页面必须只有一个主操作入口')
|
||||
assert.match(viewSource, /onBeforeUnmount\(stopGenerationTimer\)/, '生成计时器没有在卸载时清理')
|
||||
assert.match(viewSource, /function scrollToStepTop/, '步骤切换后没有恢复页面顶部上下文')
|
||||
assert.match(viewSource, /nextTick\(scrollToStepTop\)/, '步骤切换没有触发页面滚动复位')
|
||||
assert.match(viewSource, /\.wizard-content\s*\{[\s\S]*min-height:\s*400px/, '第一步内容区必须保留足够高度以显示底部操作栏')
|
||||
assert.match(
|
||||
layoutSource,
|
||||
/&:has\(\.create-wizard-layout\)\s*\{[\s\S]*?overflow-y:\s*hidden[\s\S]*?\.page-canvas\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?min-height:\s*0/,
|
||||
'创建任务页必须约束画布高度,避免底部操作栏被裁掉',
|
||||
)
|
||||
assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '对照预览高度不足以展示切片正文')
|
||||
|
||||
console.log('数据处理四步向导回归检查通过')
|
||||
27
frontend/scripts/regression-fine-tune-create-ui.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
const source = readFileSync(resolve(root, 'src/views/fine-tune/FineTuneCreateView.vue'), 'utf8')
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
assert(source.includes(':rows="4"'), '任务描述 textarea should be taller than the original 2 rows')
|
||||
assert(source.includes('gpu-index'), 'GPU cards should use a restrained index label')
|
||||
assert(source.includes('GPU-{{ idx }}'), 'GPU cards should show the GPU number in a compact format')
|
||||
assert(source.includes('gpu-usage-bar'), 'GPU cards should visualize usage with an enterprise-style progress bar')
|
||||
assert(source.includes('is-busy'), 'GPU cards should have a distinct busy state for usage over 80%')
|
||||
assert(source.includes('gpu.gpu_percent > 80'), 'GPU busy state should be driven by usage over 80%')
|
||||
assert(source.includes('modelDialogVisible'), 'Model selection should open a dialog instead of a plain select')
|
||||
assert(!source.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(source.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(source.includes('confirmModelSelection'), 'Model dialog should confirm the selected model before updating the form')
|
||||
|
||||
console.log('fine-tune create UI regression checks passed')
|
||||
30
frontend/scripts/regression-model-manage.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewPath = path.resolve(scriptDir, '../src/views/model/ModelManageView.vue')
|
||||
const source = await readFile(viewPath, 'utf8')
|
||||
const { descriptor, errors } = parseSfc(source, { filename: viewPath })
|
||||
|
||||
assert.equal(errors.length, 0, `模型管理页面模板无法解析:${errors[0]}`)
|
||||
assert.ok(descriptor.template?.content.trim(), '模型管理页面缺少可渲染模板')
|
||||
assert.equal((source.match(/<template>/g) || []).length, 1, '模型管理页面只能有一个根模板标签')
|
||||
|
||||
const configTableStart = source.indexOf('v-if="activeTab === \'config\'"')
|
||||
const trainedTableStart = source.indexOf('<DataTablePage\n v-else')
|
||||
assert.ok(configTableStart >= 0 && trainedTableStart > configTableStart, '模型管理页面缺少两个标签对应的列表分支')
|
||||
assert.doesNotMatch(
|
||||
source.slice(configTableStart, trainedTableStart),
|
||||
/activeTab === 'trained'/,
|
||||
'配置模型分支不应再比较已不可能的训练模型状态',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
source.slice(trainedTableStart),
|
||||
/activeTab === 'config'/,
|
||||
'训练模型分支不应再比较已不可能的配置模型状态',
|
||||
)
|
||||
|
||||
console.log('模型管理页面模板回归检查通过')
|
||||
185
frontend/scripts/regression-page-surface.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { parse as parseTemplate } from '@vue/compiler-dom'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const [globalStyles, routerSource, mainLayoutSource, trainingLogSource, fineTuneCreateSource] = await Promise.all([
|
||||
readFile(path.resolve(scriptDir, '../src/styles/index.scss'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/views/fine-tune/FineTuneCreateView.vue'), 'utf8'),
|
||||
])
|
||||
|
||||
function extractCssBlock(css, marker) {
|
||||
const markerIndex = css.indexOf(marker)
|
||||
assert.notEqual(markerIndex, -1, `未找到样式规则:${marker}`)
|
||||
const openBrace = css.indexOf('{', markerIndex)
|
||||
assert.notEqual(openBrace, -1, `样式规则缺少左花括号:${marker}`)
|
||||
|
||||
let depth = 0
|
||||
for (let index = openBrace; index < css.length; index += 1) {
|
||||
if (css[index] === '{') depth += 1
|
||||
if (css[index] === '}') depth -= 1
|
||||
if (depth === 0) return css.slice(openBrace + 1, index)
|
||||
}
|
||||
|
||||
assert.fail(`样式规则缺少右花括号:${marker}`)
|
||||
}
|
||||
|
||||
function findElements(node, predicate, result = []) {
|
||||
if (node?.type === 1 && predicate(node)) result.push(node)
|
||||
for (const child of node?.children || []) findElements(child, predicate, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function staticAttribute(node, name) {
|
||||
const prop = node.props.find((item) => item.type === 6 && item.name === name)
|
||||
return prop?.value?.content
|
||||
}
|
||||
|
||||
function boundAttribute(node, name) {
|
||||
const prop = node.props.find(
|
||||
(item) => item.type === 7 && item.name === 'bind' && item.arg?.content === name,
|
||||
)
|
||||
return prop?.exp?.content
|
||||
}
|
||||
|
||||
function extractRouteBlock(source, routePath) {
|
||||
const pathPattern = new RegExp(`path:\\s*['"]${routePath.replaceAll('/', '\\/')}['"]`)
|
||||
const pathIndex = source.search(pathPattern)
|
||||
assert.notEqual(pathIndex, -1, `未找到路由 /${routePath}`)
|
||||
|
||||
const openBrace = source.lastIndexOf('{', pathIndex)
|
||||
assert.notEqual(openBrace, -1, `路由 /${routePath} 缺少左花括号`)
|
||||
|
||||
let depth = 0
|
||||
for (let index = openBrace; index < source.length; index += 1) {
|
||||
if (source[index] === '{') depth += 1
|
||||
if (source[index] === '}') depth -= 1
|
||||
if (depth === 0) return source.slice(openBrace + 1, index)
|
||||
}
|
||||
|
||||
assert.fail(`路由 /${routePath} 缺少右花括号`)
|
||||
}
|
||||
|
||||
const selfSurfaceRoutes = [
|
||||
'fine-tune',
|
||||
'model-eval',
|
||||
'model-inference',
|
||||
'model-manage',
|
||||
'data-process',
|
||||
'dataset',
|
||||
]
|
||||
for (const routePath of selfSurfaceRoutes) {
|
||||
const routeBlock = extractRouteBlock(routerSource, routePath)
|
||||
assert.match(
|
||||
routeBlock,
|
||||
/meta:\s*\{[^}]*pageSurface:\s*['"]self['"][^}]*\}/,
|
||||
`列表路由 /${routePath} 未声明 pageSurface: 'self'`,
|
||||
)
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
routerSource.match(/pageSurface:\s*['"]self['"]/g)?.length,
|
||||
selfSurfaceRoutes.length,
|
||||
"只能给指定的自带白色表面的列表路由声明 pageSurface: 'self'",
|
||||
)
|
||||
|
||||
const rootBlock = extractCssBlock(globalStyles, ':root')
|
||||
assert.match(rootBlock, /--app-shell-bg:\s*#f3f5f8;/, '全局样式缺少灰色外层背景 token')
|
||||
assert.match(rootBlock, /--app-page-bg:\s*#ffffff;/, '全局样式缺少白色页面画布 token')
|
||||
assert.match(rootBlock, /--app-surface-bg:\s*#ffffff;/, '全局样式缺少统一内容表面 token')
|
||||
|
||||
const bodyBlock = extractCssBlock(globalStyles, '\nbody {')
|
||||
assert.match(bodyBlock, /background-color:\s*var\(--app-shell-bg\);/, 'body 未使用灰色外层背景')
|
||||
|
||||
const cardBlock = extractCssBlock(globalStyles, '.el-card {')
|
||||
assert.match(cardBlock, /background-color:\s*var\(--app-surface-bg\);/, '全局卡片未使用统一内容表面')
|
||||
|
||||
const mainLayoutDescriptor = parseSfc(mainLayoutSource).descriptor
|
||||
const mainLayoutScript = mainLayoutDescriptor.scriptSetup?.content || ''
|
||||
const mainLayoutStyle = mainLayoutDescriptor.styles.map((item) => item.content).join('\n')
|
||||
const mainLayoutTemplate = mainLayoutDescriptor.template?.content || ''
|
||||
const mainLayoutAst = parseTemplate(mainLayoutTemplate)
|
||||
|
||||
assert.match(mainLayoutScript, /import\s*\{[^}]*\buseRoute\b[^}]*\}\s*from\s*['"]vue-router['"]/, '主布局未引入 useRoute')
|
||||
assert.match(mainLayoutScript, /const\s+route\s*=\s*useRoute\(\)/, '主布局未获取当前路由')
|
||||
|
||||
const pageCanvases = findElements(
|
||||
mainLayoutAst,
|
||||
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('page-canvas'),
|
||||
)
|
||||
assert.equal(pageCanvases.length, 1, '主布局必须且只能提供一个全局白色页面画布')
|
||||
assert.match(
|
||||
boundAttribute(pageCanvases[0], 'class') || '',
|
||||
/['"]is-self-surface['"]\s*:\s*route\.meta\.pageSurface\s*===\s*['"]self['"]/,
|
||||
'主布局未根据 route.meta.pageSurface 为页面画布添加 is-self-surface 类',
|
||||
)
|
||||
assert.equal(
|
||||
findElements(pageCanvases[0], (node) => node.tag === 'router-view').length,
|
||||
1,
|
||||
'所有业务路由必须渲染在全局页面画布内部',
|
||||
)
|
||||
assert.equal(
|
||||
findElements(mainLayoutAst, (node) => node.tag.toLowerCase() === 'transition').length,
|
||||
0,
|
||||
'主布局仍包含会造成列表与二级页面表面错位的页面级 Transition',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
mainLayoutStyle,
|
||||
/\.fade-(?:enter|leave)-(?:active|from|to)/,
|
||||
'主布局仍包含页面级透明度转场样式',
|
||||
)
|
||||
|
||||
const layoutContentBlock = extractCssBlock(mainLayoutStyle, '.layout-content')
|
||||
assert.match(layoutContentBlock, /background-color:\s*var\(--app-shell-bg\);/, '主内容区未使用灰色外层背景')
|
||||
|
||||
const pageCanvasBlock = extractCssBlock(mainLayoutStyle, '.page-canvas')
|
||||
assert.match(pageCanvasBlock, /min-height:\s*100%;/, '白色页面画布没有铺满可用高度')
|
||||
assert.match(pageCanvasBlock, /padding:\s*24px;/, '白色页面画布缺少统一内容内边距')
|
||||
assert.match(pageCanvasBlock, /border-radius:\s*16px;/, '白色页面画布圆角与参考不一致')
|
||||
assert.match(pageCanvasBlock, /background-color:\s*var\(--app-page-bg\);/, '全局页面画布未使用白色背景')
|
||||
|
||||
const selfSurfaceBlock = extractCssBlock(mainLayoutStyle, '.page-canvas.is-self-surface')
|
||||
assert.match(selfSurfaceBlock, /padding:\s*0;/, '自带表面的页面仍保留全局画布内边距')
|
||||
assert.match(selfSurfaceBlock, /border-radius:\s*0;/, '自带表面的页面仍保留全局画布圆角')
|
||||
assert.match(selfSurfaceBlock, /background-color:\s*transparent;/, '自带表面的页面未透出灰色应用背景')
|
||||
assert.match(selfSurfaceBlock, /box-shadow:\s*none;/, '自带表面的页面仍保留全局画布阴影')
|
||||
|
||||
assert.match(
|
||||
mainLayoutStyle,
|
||||
/\.page-canvas:not\(\.is-self-surface\)\s*>\s*:deep\(\.page-card-host\s*>\s*\.page-card\)/,
|
||||
'显式根卡片宿主内的 PageCard 未被识别为页面根卡片',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
mainLayoutStyle,
|
||||
/:deep\(\*\s*>\s*\.page-card\)/,
|
||||
'通用层级选择器会误伤训练日志等页面的内部业务卡片',
|
||||
)
|
||||
assert.match(
|
||||
fineTuneCreateSource,
|
||||
/class=["'][^"']*\bfine-tune-create\b[^"']*\bpage-card-host\b[^"']*["']/,
|
||||
'创建训练任务页未显式标记根 PageCard 宿主',
|
||||
)
|
||||
const rootPageCardBlock = extractCssBlock(
|
||||
mainLayoutStyle,
|
||||
'.page-canvas:not(.is-self-surface) > :deep(.page-card)',
|
||||
)
|
||||
assert.match(rootPageCardBlock, /margin-bottom:\s*0;/, '页面根卡片仍在白色画布内保留额外外边距')
|
||||
assert.match(rootPageCardBlock, /background-color:\s*transparent;/, '页面根卡片仍形成第二层白色背景')
|
||||
assert.match(rootPageCardBlock, /border-radius:\s*0\s*!important;/, '页面根卡片仍形成第二层圆角边界')
|
||||
assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根卡片仍形成重复卡片层级')
|
||||
|
||||
const trainingLogStyle = parseSfc(trainingLogSource).descriptor.styles.map((item) => item.content).join('\n')
|
||||
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.profile-section,\n.runtime-panel')
|
||||
assert.match(
|
||||
businessSurfaceBlock,
|
||||
/background:\s*var\(--app-surface-bg\);/,
|
||||
'任务、数据集和运行概况未使用统一白色内容表面',
|
||||
)
|
||||
|
||||
console.log('全局页面背景与内容表面回归检查通过')
|
||||
128
frontend/scripts/regression-training-log-layout.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { parse as parseTemplate } from '@vue/compiler-dom'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue')
|
||||
const source = await readFile(viewPath, 'utf8')
|
||||
const { descriptor } = parseSfc(source, { filename: viewPath })
|
||||
const template = descriptor.template?.content || ''
|
||||
const style = descriptor.styles.map((item) => item.content).join('\n')
|
||||
const templateAst = parseTemplate(template)
|
||||
|
||||
function findElements(node, predicate, result = []) {
|
||||
if (node?.type === 1 && predicate(node)) result.push(node)
|
||||
for (const child of node?.children || []) findElements(child, predicate, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function staticAttribute(node, name) {
|
||||
const prop = node.props.find((item) => item.type === 6 && item.name === name)
|
||||
return prop?.value?.content
|
||||
}
|
||||
|
||||
function boundExpression(node, name) {
|
||||
const prop = node.props.find(
|
||||
(item) => item.type === 7
|
||||
&& item.name === 'bind'
|
||||
&& item.arg?.type === 4
|
||||
&& item.arg.content === name,
|
||||
)
|
||||
return prop?.exp?.type === 4 ? prop.exp.content : undefined
|
||||
}
|
||||
|
||||
function extractCssBlock(css, marker) {
|
||||
const markerIndex = css.indexOf(marker)
|
||||
assert.notEqual(markerIndex, -1, `未找到样式规则:${marker}`)
|
||||
const openBrace = css.indexOf('{', markerIndex)
|
||||
assert.notEqual(openBrace, -1, `样式规则缺少左花括号:${marker}`)
|
||||
|
||||
let depth = 0
|
||||
for (let index = openBrace; index < css.length; index += 1) {
|
||||
if (css[index] === '{') depth += 1
|
||||
if (css[index] === '}') depth -= 1
|
||||
if (depth === 0) return css.slice(openBrace + 1, index)
|
||||
}
|
||||
|
||||
assert.fail(`样式规则缺少右花括号:${marker}`)
|
||||
}
|
||||
|
||||
function relativeLuminance(hex) {
|
||||
const channels = hex
|
||||
.replace('#', '')
|
||||
.match(/.{2}/g)
|
||||
.map((channel) => Number.parseInt(channel, 16) / 255)
|
||||
.map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4))
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
||||
}
|
||||
|
||||
function contrastRatio(foreground, background) {
|
||||
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background))
|
||||
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background))
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
const overview = findElements(
|
||||
templateAst,
|
||||
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
|
||||
)
|
||||
assert.equal(overview.length, 1, '双栏任务档案容器必须且只能存在一个')
|
||||
|
||||
for (const expectedClass of ['task-profile', 'dataset-profile', 'runtime-panel', 'parameter-groups']) {
|
||||
const matched = findElements(
|
||||
templateAst,
|
||||
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes(expectedClass),
|
||||
)
|
||||
assert.equal(matched.length, 1, `缺少或重复布局结构:${expectedClass}`)
|
||||
}
|
||||
|
||||
const toggleButtons = findElements(
|
||||
templateAst,
|
||||
(node) => node.tag === 'button'
|
||||
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
|
||||
)
|
||||
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的原生 button')
|
||||
|
||||
const toggleButton = toggleButtons[0]
|
||||
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
|
||||
assert.equal(staticAttribute(toggleButton, 'aria-controls'), 'training-parameter-content', '折叠按钮缺少正确的 aria-controls')
|
||||
|
||||
const controlledRegions = findElements(
|
||||
templateAst,
|
||||
(node) => staticAttribute(node, 'id') === 'training-parameter-content',
|
||||
)
|
||||
assert.equal(controlledRegions.length, 1, 'aria-controls 指向的参数内容区域不存在或重复')
|
||||
|
||||
const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
|
||||
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
|
||||
assert.equal(
|
||||
template.slice(0, firstChartIndex).includes('<el-descriptions'),
|
||||
false,
|
||||
'任务概览、数据集和训练参数仍使用带表格感的 el-descriptions',
|
||||
)
|
||||
|
||||
assert.ok(template.includes("task?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
|
||||
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
|
||||
|
||||
const media1100 = extractCssBlock(style, '@media (max-width: 1100px)')
|
||||
const overviewAt1100 = extractCssBlock(media1100, '.overview-layout')
|
||||
assert.match(overviewAt1100, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '1100px 断点未将双栏改为单栏')
|
||||
|
||||
const media700 = extractCssBlock(style, '@media (max-width: 700px)')
|
||||
for (const selector of ['.dataset-metrics', '.runtime-list', '.parameter-grid']) {
|
||||
assert.ok(media700.includes(selector), `700px 断点缺少单列规则:${selector}`)
|
||||
}
|
||||
assert.match(media700, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '700px 断点未设置单列网格')
|
||||
|
||||
const mutedBlock = extractCssBlock(style, '.is-muted')
|
||||
const mutedColor = mutedBlock.match(/color:\s*(#[0-9a-f]{6})/i)?.[1]
|
||||
assert.ok(mutedColor, '未配置文本缺少明确颜色')
|
||||
assert.ok(
|
||||
contrastRatio(mutedColor, '#ffffff') >= 4.5,
|
||||
`未配置文本颜色 ${mutedColor} 与白色背景对比度不足 4.5:1`,
|
||||
)
|
||||
|
||||
console.log('训练日志详情布局回归检查通过')
|
||||
7
frontend/src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件,仅承载路由出口
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
80
frontend/src/api/modules/compare.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { get, post, del } from '../request'
|
||||
import type { CompareTask, CompareModelRef } from '@/types'
|
||||
|
||||
/** 推理/对比任务列表 */
|
||||
export const getCompareList = () => get<CompareTask[]>('/model-compare')
|
||||
|
||||
/** 任务详情 */
|
||||
export const getCompare = (id: string | number) => get<CompareTask>(`/model-compare/${id}`)
|
||||
|
||||
/** 创建任务 */
|
||||
export const createCompare = (data: Partial<CompareTask>) =>
|
||||
post<{ id: string | number }>('/model-compare', data)
|
||||
|
||||
/** 删除任务 */
|
||||
export const deleteCompare = (id: string | number) => del(`/model-compare/${id}`)
|
||||
|
||||
/** 更新任务加载状态 */
|
||||
export const updateLoadStatus = (id: string | number, load_status: any) =>
|
||||
post(`/model-compare/${id}/load-status`, { load_status })
|
||||
|
||||
/** 查询任务加载状态 */
|
||||
export const getLoadStatus = (id: string | number) =>
|
||||
get<{ all_ready: boolean; loaded_models: any[] }>(`/model-compare/${id}/load-status`)
|
||||
|
||||
/** 停止所有旧模型服务 */
|
||||
export const stopAllModels = () => post('/model-compare/all/stop-all')
|
||||
|
||||
/** 启动单个模型服务 */
|
||||
export const startModel = (id: string | number, data: Partial<CompareModelRef>) =>
|
||||
post<{ pid: number; port: number }>(`/model-compare/${id}/start-model`, data)
|
||||
|
||||
/** 按 PID 停止模型进程 */
|
||||
export const stopModelByPid = (pid: number) =>
|
||||
post('/model-compare/stop-by-pid', { pid })
|
||||
|
||||
/** 加载任务 */
|
||||
export const loadCompare = (id: string | number) => post(`/model-compare/${id}/load`)
|
||||
|
||||
/** 卸载任务 */
|
||||
export const unloadCompare = (id: string | number) => post(`/model-compare/${id}/unload`)
|
||||
|
||||
/** 流式对话(mock 模式下返回非流式响应,调用方需兼容) */
|
||||
export const streamChat = async (data: any): Promise<any> => {
|
||||
// Mock 环境:返回非流式响应对象,调用方检测 content-type 决定如何处理
|
||||
const resp = await post<{ response: string }>('/model-compare/stream-chat', data)
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => {
|
||||
// 模拟流式读取:把整个响应切成小块逐个返回
|
||||
const text = resp?.response || ''
|
||||
const encoder = new TextEncoder()
|
||||
const chunks = [text.slice(0, text.length / 3), text.slice(text.length / 3, 2 * text.length / 3), text.slice(2 * text.length / 3)]
|
||||
let i = 0
|
||||
return {
|
||||
read: async () => {
|
||||
if (i >= chunks.length) return { done: true, value: undefined }
|
||||
const value = encoder.encode(chunks[i++])
|
||||
return { done: false, value }
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 非流式对话(按端口代理) */
|
||||
export const chatWithPort = (data: any) => post('/model-compare/chat-with-port', data)
|
||||
|
||||
/** 批量对话(API 类型模型) */
|
||||
export const batchChat = (data: any) => post('/model-chat/batch', data)
|
||||
|
||||
/** 本地 transformers 模型对话 */
|
||||
export const localChat = (data: any) => post('/model-chat/local/chat', data)
|
||||
|
||||
/** 预加载本地模型 */
|
||||
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data)
|
||||
|
||||
/** 预加载已训练模型 */
|
||||
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data)
|
||||
40
frontend/src/api/modules/dataset.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { get, post, put, del } from '../request'
|
||||
import type { DatasetItem } from '@/types'
|
||||
|
||||
/** 数据集列表 */
|
||||
export const getDatasetList = () => get<DatasetItem[]>('/dataset-manage')
|
||||
|
||||
/** 数据集详情 */
|
||||
export const getDataset = (id: string | number) => get<DatasetItem>(`/dataset-manage/${id}`)
|
||||
|
||||
/** 创建数据集 */
|
||||
export const createDataset = (data: Partial<DatasetItem>) =>
|
||||
post<{ id: string | number }>('/dataset-manage', data)
|
||||
|
||||
/** 更新数据集 */
|
||||
export const updateDataset = (id: string | number, data: Partial<DatasetItem>) =>
|
||||
put(`/dataset-manage/${id}`, data)
|
||||
|
||||
/** 删除数据集 */
|
||||
export const deleteDataset = (id: string | number) => del(`/dataset-manage/${id}`)
|
||||
|
||||
/** 上传数据集文件(multipart,字段名 files) */
|
||||
export const uploadDatasetFiles = (datasetId: string | number, files: File[]) => {
|
||||
const formData = new FormData()
|
||||
files.forEach((f) => formData.append('files', f))
|
||||
return post(`/dataset-manage/upload/${datasetId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 预览数据集文件内容 */
|
||||
export const previewDatasetFile = (fileId: string | number) =>
|
||||
get<{ content: string }>(`/dataset-manage/preview/${fileId}`)
|
||||
|
||||
/** 下载文件 URL */
|
||||
export const downloadFileUrl = (datasetId: string | number, fileId: string | number) =>
|
||||
`/api/dataset-manage/download/${datasetId}/${fileId}`
|
||||
|
||||
/** 打包下载数据集 URL */
|
||||
export const downloadDatasetUrl = (datasetId: string | number) =>
|
||||
`/api/dataset-manage/download/${datasetId}`
|
||||
27
frontend/src/api/modules/eval.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { get, post, put, del } from '../request'
|
||||
import type { EvalTask, Dimension } from '@/types'
|
||||
|
||||
/** 评测任务列表 */
|
||||
export const getEvalList = () => get<EvalTask[]>('/model-eval')
|
||||
|
||||
/** 删除评测任务 */
|
||||
export const deleteEval = (id: string | number) => del(`/model-eval/${id}`)
|
||||
|
||||
/** 启动评测 */
|
||||
export const startEval = (data: any) => post('/model-eval/start', data)
|
||||
|
||||
/** 评测维度列表 */
|
||||
export const getDimensionList = () => get<Dimension[]>('/dimension')
|
||||
|
||||
/** 评测维度详情 */
|
||||
export const getDimension = (id: string | number) => get<Dimension>(`/dimension/${id}`)
|
||||
|
||||
/** 创建维度 */
|
||||
export const createDimension = (data: Partial<Dimension>) => post('/dimension', data)
|
||||
|
||||
/** 编辑维度 */
|
||||
export const updateDimension = (id: string | number, data: Partial<Dimension>) =>
|
||||
put(`/dimension/${id}`, data)
|
||||
|
||||
/** 删除维度 */
|
||||
export const deleteDimension = (id: string | number) => del(`/dimension/${id}`)
|
||||
40
frontend/src/api/modules/fineTune.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { get, post, put, del } from '../request'
|
||||
import type { FineTuneTask, TrainingProgress } from '@/types'
|
||||
|
||||
/** 训练任务列表 */
|
||||
export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune')
|
||||
|
||||
/** 训练任务详情 */
|
||||
export const getFineTune = (id: string | number) => get<FineTuneTask>(`/fine-tune/${id}`)
|
||||
|
||||
/** 任务名查重 */
|
||||
export const checkFineTuneName = (name: string) =>
|
||||
get<{ exists: boolean }>('/fine-tune/check-name', { name })
|
||||
|
||||
/** 创建训练任务记录(第一步) */
|
||||
export const createFineTune = (data: Partial<FineTuneTask>) =>
|
||||
post<{ id: string | number }>('/fine-tune', data)
|
||||
|
||||
/** 启动训练(第二步) */
|
||||
export const startFineTune = (data: any) => post('/fine-tune/start', data)
|
||||
|
||||
/** 更新训练任务 */
|
||||
export const updateFineTune = (id: string | number, data: Partial<FineTuneTask>) =>
|
||||
put(`/fine-tune/${id}`, data)
|
||||
|
||||
/** 停止训练任务 */
|
||||
export const stopFineTune = (id: string | number) => post(`/fine-tune/stop/${id}`)
|
||||
|
||||
/** 删除训练任务 */
|
||||
export const deleteFineTune = (id: string | number) => del(`/fine-tune/${id}`)
|
||||
|
||||
/** 获取训练进度 */
|
||||
export const getFineTuneProgress = (id: string | number) =>
|
||||
get<TrainingProgress>(`/fine-tune/progress/${id}`)
|
||||
|
||||
/** 启动 TensorBoard */
|
||||
export const startTensorboard = () => post('/fine-tune/tensorboard/start')
|
||||
|
||||
/** 提交 Web 日志 */
|
||||
export const sendWebLog = (level: string, message: string, page: string) =>
|
||||
post('/web-log', { level, message, page, timestamp: new Date().toISOString() })
|
||||
18
frontend/src/api/modules/log.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { get } from '../request'
|
||||
import type { LogFile, LogContent, TrainingLogFile } from '@/types'
|
||||
|
||||
/** 按日期获取系统日志文件列表 */
|
||||
export const getLogFiles = (date: string) =>
|
||||
get<LogFile[]>('/log-files', { date })
|
||||
|
||||
/** 获取系统日志内容 */
|
||||
export const getLogContent = (file: string) =>
|
||||
get<LogContent>('/log-content', { file })
|
||||
|
||||
/** 训练日志文件列表 */
|
||||
export const getTrainingLogFiles = () =>
|
||||
get<TrainingLogFile[]>('/training-log-files')
|
||||
|
||||
/** 训练日志内容 */
|
||||
export const getTrainingLogContent = (file: string) =>
|
||||
get<LogContent>('/training-log-content', { file })
|
||||
48
frontend/src/api/modules/model.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { get, post, put, del } from '../request'
|
||||
import type { ModelItem, ModelForm, TrainedModel } from '@/types'
|
||||
|
||||
/** 模型列表 */
|
||||
export const getModelList = () => get<ModelItem[]>('/model-manage')
|
||||
|
||||
/** 模型详情 */
|
||||
export const getModel = (id: string | number) => get<ModelItem>(`/model-manage/${id}`)
|
||||
|
||||
/** 按名称查模型 */
|
||||
export const getModelByName = (name: string) => get<ModelItem>(`/model-manage/name/${name}`)
|
||||
|
||||
/** 本地模型路径列表 */
|
||||
export const getLocalModels = () =>
|
||||
get<{ models: { path: string; name: string }[] }>('/model-manage/local-models')
|
||||
|
||||
/** 已训练模型列表 */
|
||||
export const getTrainedModels = () =>
|
||||
get<{ models: TrainedModel[] }>('/model-manage/trained-models')
|
||||
|
||||
/** 创建模型 */
|
||||
export const createModel = (data: ModelForm) => post('/model-manage', data)
|
||||
|
||||
/** 编辑模型 */
|
||||
export const updateModel = (id: string | number, data: ModelForm) =>
|
||||
put(`/model-manage/${id}`, data)
|
||||
|
||||
/** 删除模型 */
|
||||
export const deleteModel = (id: string | number) => del(`/model-manage/${id}`)
|
||||
|
||||
/** 删除已训练模型(合并权重) */
|
||||
export const deleteTrainedModel = (id: string | number, type: 'merged' | 'lora' = 'merged') =>
|
||||
del(`/model-manage/trained-models/${id}`, { type })
|
||||
|
||||
/** 更新模型用途 */
|
||||
export const updateModelPurpose = (id: string | number, purpose: string) =>
|
||||
put(`/model-manage/${id}/purpose`, { purpose })
|
||||
|
||||
/** 合并 LoRA 权重 */
|
||||
export const mergeModel = (data: {
|
||||
model_name: string
|
||||
train_method: string
|
||||
base_model_path: string
|
||||
}) => post('/model-manage/merge', data)
|
||||
|
||||
/** 导出已训练模型权重 */
|
||||
export const exportModelUrl = (modelName: string) =>
|
||||
`/api/model-manage/trained-models/${encodeURIComponent(modelName)}/export`
|
||||
12
frontend/src/api/modules/system.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { get, post } from '../request'
|
||||
import type { SystemInfo, HealthMetrics } from '@/types'
|
||||
|
||||
/** 系统信息(CPU/内存/磁盘/GPU/网络/系统) */
|
||||
export const getSystemInfo = () => get<SystemInfo>('/system-info')
|
||||
|
||||
/** 健康指标(顶部栏轻量指标) */
|
||||
export const getHealth = () => get<HealthMetrics>('/health')
|
||||
|
||||
/** 登录 */
|
||||
export const login = (username: string, password: string) =>
|
||||
post('/login', { username, password })
|
||||
73
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/**
|
||||
* 后端统一响应格式
|
||||
* code === 0 表示成功,data 为业务数据
|
||||
*/
|
||||
export interface ApiResult<T = any> {
|
||||
code: number
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const service: AxiosInstance = axios.create({
|
||||
// 统一走相对路径,由 Vite 代理转发到 http://localhost:7861
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 安装 mock adapter(拦截所有 axios 请求返回 mock 数据,方便前端独立开发调试)
|
||||
import { installMockAdapter } from '@/mock/adapter'
|
||||
installMockAdapter(service)
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => config,
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
// 响应拦截器:统一解包 { code, data, message }
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data as ApiResult
|
||||
// 二进制流等非 JSON 响应直接返回
|
||||
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
|
||||
return response
|
||||
}
|
||||
if (res.code === 0) {
|
||||
return res.data
|
||||
}
|
||||
// 业务错误
|
||||
const message = res.message || '请求失败'
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(new Error(message))
|
||||
},
|
||||
(error) => {
|
||||
const message = error.response?.data?.message || error.message || '网络异常'
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/** GET 请求,返回已解包的 data */
|
||||
export function get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.get(url, { params, ...config }) as unknown as Promise<T>
|
||||
}
|
||||
|
||||
/** POST 请求 */
|
||||
export function post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.post(url, data, config) as unknown as Promise<T>
|
||||
}
|
||||
|
||||
/** PUT 请求 */
|
||||
export function put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.put(url, data, config) as unknown as Promise<T>
|
||||
}
|
||||
|
||||
/** DELETE 请求 */
|
||||
export function del<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.delete(url, { params, ...config }) as unknown as Promise<T>
|
||||
}
|
||||
|
||||
export default service
|
||||
4
frontend/src/assets/font-awesome/css/font-awesome.min.css
vendored
Normal file
BIN
frontend/src/assets/font-awesome/fonts/fontawesome-webfont.ttf
Normal file
BIN
frontend/src/assets/font-awesome/fonts/fontawesome-webfont.woff
Normal file
BIN
frontend/src/assets/font-awesome/fonts/fontawesome-webfont.woff2
Normal file
264
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
const { metrics } = storeToRefs(systemStore)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const showBackButton = computed(() => {
|
||||
return route.path.split('/').filter(Boolean).length > 1
|
||||
})
|
||||
|
||||
function usageClass(val?: number) {
|
||||
return val != null && val > 80 ? 'is-danger' : ''
|
||||
}
|
||||
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function updateTime() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
currentDate.value = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
|
||||
}
|
||||
|
||||
const serverIp = ref(window.location.hostname)
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
v-if="showBackButton"
|
||||
class="back-btn"
|
||||
link
|
||||
@click="router.back()"
|
||||
>
|
||||
<div class="back-icon-wrap">
|
||||
<i class="fa fa-arrow-left" />
|
||||
</div>
|
||||
<span class="back-text">返回上一页</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 系统监控指标 -->
|
||||
<div class="metrics-dashboard" @click="router.push('/hardware')">
|
||||
<el-tooltip content="CPU 使用率" placement="bottom">
|
||||
<div class="metric-badge">
|
||||
<i class="fa fa-microchip icon cpu" />
|
||||
<span class="value" :class="usageClass(metrics.cpu_percent)">
|
||||
{{ metrics.cpu_percent ?? '--' }}<small>%</small>
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="内存 使用率" placement="bottom">
|
||||
<div class="metric-badge">
|
||||
<i class="fa fa-database icon mem" />
|
||||
<span class="value" :class="usageClass(metrics.memory_percent)">
|
||||
{{ metrics.memory_percent ?? '--' }}<small>%</small>
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="磁盘 使用率" placement="bottom">
|
||||
<div class="metric-badge">
|
||||
<i class="fa fa-hdd-o icon disk" />
|
||||
<span class="value" :class="usageClass(metrics.disk_percent)">
|
||||
{{ metrics.disk_percent ?? '--' }}<small>%</small>
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- 时间日期与服务器IP -->
|
||||
<div class="system-info">
|
||||
<div class="info-item">
|
||||
<i class="fa fa-server" />
|
||||
<span>{{ serverIp }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="fa fa-calendar" />
|
||||
<span>{{ currentDate }}</span>
|
||||
</div>
|
||||
<div class="info-item time-item">
|
||||
<i class="fa fa-clock-o" />
|
||||
<span>{{ currentTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-header {
|
||||
height: var(--header-height);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.02);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
.back-icon-wrap {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
margin-right: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
|
||||
.back-icon-wrap {
|
||||
background: rgba(79, 70, 229, 0.1);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.metrics-dashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 12px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.metric-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
border-color: #e2e8f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 6px;
|
||||
font-size: 14px;
|
||||
|
||||
&.cpu { color: var(--primary-color); }
|
||||
&.mem { color: var(--success-color); }
|
||||
&.disk { color: var(--warning-color); }
|
||||
}
|
||||
|
||||
.value {
|
||||
small {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: var(--danger-color);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: #f8fafc;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f1f5f9;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
i {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.time-item {
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
281
frontend/src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
/** 当前激活菜单 key(取路由路径第一段,如 /fine-tune/create → fine-tune) */
|
||||
const activeMenu = computed(() => {
|
||||
const seg = route.path.split('/')[1] || 'fine-tune'
|
||||
// 训练日志归到模型调优
|
||||
if (seg === 'training-log') return 'fine-tune'
|
||||
// 维度管理归到模型评测
|
||||
if (route.path.includes('model-eval/dimension')) return 'model-eval'
|
||||
// 对比对话归到模型推理
|
||||
if (route.path.startsWith('/model-compare/chat')) return 'model-inference'
|
||||
// 合并权重归到模型管理
|
||||
if (route.path === '/model-manage/merge') return 'model-manage'
|
||||
// 模型管理编辑
|
||||
if (route.path.match(/^\/model-manage\/\d+\/edit/)) return 'model-manage'
|
||||
// 数据集编辑/预览
|
||||
if (route.path.match(/^\/dataset\/(\d+)/)) return 'dataset'
|
||||
// 工具编辑
|
||||
if (route.path.match(/^\/tools\/[^/]+\/edit/)) return 'tools'
|
||||
return seg
|
||||
})
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
// 服务看板:独立入口,不显示分类小标题
|
||||
showTitle: false,
|
||||
items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard' }],
|
||||
},
|
||||
{
|
||||
title: '模型服务',
|
||||
items: [
|
||||
{ key: 'fine-tune', label: '模型微调', icon: 'fa-cogs', to: '/fine-tune' },
|
||||
{ key: 'model-eval', label: '模型评测', icon: 'fa-line-chart', to: '/model-eval' },
|
||||
{ key: 'model-inference', label: '模型推理', icon: 'fa-server', to: '/model-inference' },
|
||||
{ key: 'model-manage', label: '模型管理', icon: 'fa-cube', to: '/model-manage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据治理',
|
||||
items: [
|
||||
{ key: 'data-process', label: '数据处理', icon: 'fa-filter', to: '/data-process' },
|
||||
{ key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '其他工具',
|
||||
items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert' }],
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
items: [
|
||||
{ key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware' },
|
||||
{ key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function handleSelect(key: string) {
|
||||
const all = menuGroups.flatMap((g) => g.items)
|
||||
const target = all.find((i) => i.key === key)
|
||||
if (target) router.push(target.to)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="app-sidebar">
|
||||
<!-- 平台 LOGO -->
|
||||
<div class="sidebar-logo">
|
||||
<img src="/logo.png" alt="Logo" class="sidebar-logo-img" />
|
||||
<span class="logo-text">远光软件微调平台</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航 -->
|
||||
<el-scrollbar class="sidebar-nav" wrap-class="nav-scrollbar-wrap">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="sidebar-menu"
|
||||
background-color="transparent"
|
||||
text-color="var(--sidebar-text)"
|
||||
active-text-color="var(--sidebar-active-text)"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<template v-for="group in menuGroups" :key="group.title || group.items[0].key">
|
||||
<div v-if="group.title" class="sidebar-section-title">{{ group.title }}</div>
|
||||
<el-menu-item
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
:index="item.key"
|
||||
>
|
||||
<i class="fa" :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="footer-user" v-if="auth.username">
|
||||
<el-avatar :size="36" src="https://picsum.photos/id/1005/36/36" class="user-avatar" />
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ auth.username }}</span>
|
||||
<span class="user-role">Administrator</span>
|
||||
</div>
|
||||
<el-tooltip content="退出登录" placement="top">
|
||||
<div class="logout-btn" @click="handleLogout">
|
||||
<i class="fa fa-sign-out" />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
background-color: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--sidebar-text);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.02);
|
||||
z-index: 100;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px 20px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.sidebar-logo-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
:deep(.nav-scrollbar-wrap) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px !important;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.fa {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||
color: var(--sidebar-active-text) !important;
|
||||
|
||||
.fa {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--sidebar-active-bg) !important;
|
||||
color: var(--sidebar-active-text) !important;
|
||||
font-weight: 600;
|
||||
|
||||
.fa {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
padding: 16px 16px 8px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
background: transparent;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.footer-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
.user-avatar {
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.user-name {
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
380
frontend/src/components/DataTablePage.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { TableInstance } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 通用列表页组件
|
||||
* 封装原 renderTablePage 的能力:标题、搜索、创建按钮、多选、批量删除、操作列
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
data: T[]
|
||||
loading?: boolean
|
||||
/** 是否显示搜索框 */
|
||||
searchable?: boolean
|
||||
/** 搜索字段(默认取数据第一列) */
|
||||
searchFields?: string[]
|
||||
/** 是否支持多选 */
|
||||
multiSelect?: boolean
|
||||
/** 创建按钮文本,不传则不显示 */
|
||||
createText?: string
|
||||
/** 创建按钮跳转路径 */
|
||||
createTo?: string
|
||||
/** 删除函数(用于批量删除),返回 boolean 表示成功 */
|
||||
deleteFn?: (row: any) => Promise<any>
|
||||
/** 行 key 字段名 */
|
||||
rowKey?: string
|
||||
/** 空数据提示 */
|
||||
emptyText?: string
|
||||
/** 默认每页条数 */
|
||||
pageSize?: number
|
||||
/** 可选每页条数 */
|
||||
pageSizes?: number[]
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
searchable: false,
|
||||
multiSelect: false,
|
||||
rowKey: 'id',
|
||||
emptyText: '暂无数据',
|
||||
pageSize: 10,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: []
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const tableRef = ref<TableInstance>()
|
||||
const selectedIds = ref<Set<string | number>>(new Set())
|
||||
const keyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(props.pageSize)
|
||||
|
||||
/** 行唯一标识取值 */
|
||||
function rowId(row: T): string | number {
|
||||
return row[props.rowKey] ?? row['name'] ?? row['id']
|
||||
}
|
||||
|
||||
/** 过滤后的数据 */
|
||||
const filteredData = computed(() => {
|
||||
if (!keyword.value.trim() || !props.searchFields?.length) return props.data
|
||||
const kw = keyword.value.toLowerCase().trim()
|
||||
return props.data.filter((row) =>
|
||||
props.searchFields!.some((f) => String(row[f] ?? '').toLowerCase().includes(kw)),
|
||||
)
|
||||
})
|
||||
|
||||
/** 当前页数据 */
|
||||
const pagedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * currentPageSize.value
|
||||
return filteredData.value.slice(start, start + currentPageSize.value)
|
||||
})
|
||||
|
||||
/** 处理多选变化 */
|
||||
function handleSelectionChange(rows: T[]) {
|
||||
selectedIds.value = new Set(rows.map(rowId))
|
||||
}
|
||||
|
||||
/** 单个删除(子组件或操作列调用) */
|
||||
async function handleDelete(row: any, confirmText = '确定要删除这条记录吗?') {
|
||||
if (!props.deleteFn) return
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmText, '确认删除', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await props.deleteFn(row)
|
||||
emit('refresh')
|
||||
} catch {
|
||||
// 用户取消或删除失败
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量删除 */
|
||||
async function handleBatchDelete() {
|
||||
if (selectedIds.value.size === 0 || !props.deleteFn) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedIds.value.size} 条记录吗?`,
|
||||
'批量删除',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
const rows = props.data.filter((row) => selectedIds.value.has(rowId(row)))
|
||||
let ok = 0
|
||||
let fail = 0
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await props.deleteFn(row)
|
||||
ok++
|
||||
} catch {
|
||||
fail++
|
||||
}
|
||||
}
|
||||
selectedIds.value.clear()
|
||||
emit('refresh')
|
||||
if (fail === 0) {
|
||||
ElMessageBox.alert(`成功删除 ${ok} 条记录`, '成功', { type: 'success' })
|
||||
} else {
|
||||
ElMessageBox.alert(`成功 ${ok} 条,失败 ${fail} 条`, '部分失败', { type: 'warning' })
|
||||
}
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value.clear()
|
||||
tableRef.value?.clearSelection()
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (props.createTo) {
|
||||
router.push(props.createTo)
|
||||
} else {
|
||||
emit('create')
|
||||
}
|
||||
}
|
||||
|
||||
// 数据变化时清空选中
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
selectedIds.value.clear()
|
||||
currentPage.value = 1
|
||||
tableRef.value?.clearSelection()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => keyword.value,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({ handleDelete, clearSelection })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-table-page">
|
||||
<el-card shadow="never" class="table-card">
|
||||
<!-- 工具栏 -->
|
||||
<div class="table-toolbar">
|
||||
<slot name="title">
|
||||
<h2 class="table-title">{{ title }}</h2>
|
||||
</slot>
|
||||
<div class="toolbar-actions">
|
||||
<el-input
|
||||
v-if="searchable"
|
||||
v-model="keyword"
|
||||
:placeholder="`搜索${title}...`"
|
||||
clearable
|
||||
class="search-input"
|
||||
>
|
||||
<template #prefix><i class="fa fa-search" /></template>
|
||||
</el-input>
|
||||
<el-button v-if="createText" type="primary" @click="handleCreate">
|
||||
<i class="fa fa-plus" style="margin-right: 4px" />
|
||||
{{ createText }}
|
||||
</el-button>
|
||||
<slot name="toolbar-extra" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<transition name="el-zoom-in-top">
|
||||
<div v-if="multiSelect && selectedIds.size > 0" class="batch-bar">
|
||||
<span class="batch-info">
|
||||
已选择 <strong>{{ selectedIds.size }}</strong> 项
|
||||
<el-button link type="primary" @click="clearSelection">取消选择</el-button>
|
||||
</span>
|
||||
<el-button type="danger" size="small" @click="handleBatchDelete">
|
||||
<i class="fa fa-trash" style="margin-right: 4px" />
|
||||
批量删除 ({{ selectedIds.size }})
|
||||
</el-button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-body">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="pagedData"
|
||||
v-loading="loading"
|
||||
:row-key="rowKey as any"
|
||||
height="100%"
|
||||
style="width: 100%"
|
||||
:empty-text="emptyText"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column
|
||||
v-if="multiSelect"
|
||||
type="selection"
|
||||
width="50"
|
||||
align="center"
|
||||
reserve-selection
|
||||
fixed="left"
|
||||
/>
|
||||
<slot name="columns" />
|
||||
<el-table-column
|
||||
v-if="$slots.actions"
|
||||
label="操作"
|
||||
align="center"
|
||||
width="220"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<slot name="actions" :row="row" :handleDelete="handleDelete" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="currentPageSize"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="pageSizes"
|
||||
:total="filteredData.length"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.data-table-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.table-card {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 280px;
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e2e8f0 inset;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--primary-color) inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(79, 70, 229, 0.05);
|
||||
border: 1px solid rgba(79, 70, 229, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
margin: 0 24px 16px;
|
||||
|
||||
.batch-info {
|
||||
font-size: 13px;
|
||||
color: var(--primary-color);
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
:deep(.el-pagination) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 0 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 表格单元格统一样式(各列表页共用,与模型调优保持一致) */
|
||||
:deep(.el-table td.el-table__cell) {
|
||||
padding: 18px 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__cell .cell) {
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 操作列按钮组统一样式(各列表页共用) */
|
||||
:deep(.el-table) .action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
86
frontend/src/components/MarkdownView.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const html = computed(() => {
|
||||
if (!props.content) return ''
|
||||
try {
|
||||
const raw = marked.parse(props.content, { async: false }) as string
|
||||
return DOMPurify.sanitize(raw)
|
||||
} catch {
|
||||
return props.content
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-view" v-html="html" />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-view {
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 16px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 24px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
pre {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 8px;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 8px;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #dcdfe6;
|
||||
padding-left: 12px;
|
||||
color: #909399;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
a {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
506
frontend/src/components/ModelSelectDialog.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ModelItem } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
models: ModelItem[]
|
||||
/** 当前已选模型 id,打开弹窗时用于初始化草稿与 Tab */
|
||||
currentModelId: string | number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', modelId: string | number): void
|
||||
}>()
|
||||
|
||||
const activeModelTab = ref<'official' | 'mine'>('official')
|
||||
const activeModelSeries = ref('')
|
||||
const draftModelId = ref<string | number>('')
|
||||
const modelSearchKeyword = ref('')
|
||||
|
||||
/** 根据模型名提取系列名(去掉参数量与日期后缀) */
|
||||
function getModelSeries(name: string): string {
|
||||
return name
|
||||
.replace(/-\d+(?:\.\d+)?B.*$/i, '')
|
||||
.replace(/-\d+(?:\.\d+)?b.*$/i, '')
|
||||
.replace(/-\d{4}.*$/, '')
|
||||
}
|
||||
|
||||
const dialogModels = computed(() => {
|
||||
const keyword = modelSearchKeyword.value.trim().toLowerCase()
|
||||
const tabModels = props.models.filter((model) => {
|
||||
if (activeModelTab.value === 'official') return model.model_source !== 'api'
|
||||
return model.model_source === 'api'
|
||||
})
|
||||
|
||||
if (!keyword) return tabModels
|
||||
return tabModels.filter((model) => {
|
||||
return [model.name, model.description, model.path, model.online_model_name]
|
||||
.filter(Boolean)
|
||||
.some((text) => String(text).toLowerCase().includes(keyword))
|
||||
})
|
||||
})
|
||||
|
||||
const modelSeriesGroups = computed(() => {
|
||||
const groupMap = new Map<string, ModelItem[]>()
|
||||
dialogModels.value.forEach((model) => {
|
||||
const series = getModelSeries(model.name)
|
||||
const list = groupMap.get(series) || []
|
||||
list.push(model)
|
||||
groupMap.set(series, list)
|
||||
})
|
||||
|
||||
return Array.from(groupMap.entries()).map(([name, list]) => ({
|
||||
name,
|
||||
count: list.length,
|
||||
models: list,
|
||||
}))
|
||||
})
|
||||
|
||||
const currentSeriesModels = computed(() => {
|
||||
return modelSeriesGroups.value.find((group) => group.name === activeModelSeries.value)?.models || []
|
||||
})
|
||||
|
||||
const draftModel = computed(() => props.models.find((model) => model.id === draftModelId.value))
|
||||
|
||||
function syncActiveModelSeries() {
|
||||
const firstSeries = modelSeriesGroups.value[0]?.name || ''
|
||||
if (!activeModelSeries.value || !modelSeriesGroups.value.some((group) => group.name === activeModelSeries.value)) {
|
||||
activeModelSeries.value = firstSeries
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗打开时初始化:草稿沿用当前选中、Tab 按来源切换、系列定位到草稿所在组
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
const current = props.models.find((model) => model.id === props.currentModelId)
|
||||
activeModelTab.value = current?.model_source === 'api' ? 'mine' : 'official'
|
||||
draftModelId.value = props.currentModelId
|
||||
syncActiveModelSeries()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch([activeModelTab, modelSearchKeyword], () => {
|
||||
syncActiveModelSeries()
|
||||
})
|
||||
|
||||
function chooseModelSeries(series: string) {
|
||||
activeModelSeries.value = series
|
||||
}
|
||||
|
||||
function chooseDraftModel(model: ModelItem) {
|
||||
draftModelId.value = model.id
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (draftModelId.value !== '' && draftModelId.value !== undefined && draftModelId.value !== null) {
|
||||
emit('confirm', draftModelId.value)
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
class="model-select-dialog"
|
||||
width="860px"
|
||||
top="10vh"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="model-dialog-header">
|
||||
<div class="model-dialog-title">
|
||||
<span>选择模型</span>
|
||||
<em>仅可选 1 个模型</em>
|
||||
</div>
|
||||
<button class="model-dialog-close" type="button" @click="handleCancel">
|
||||
<i class="fa fa-close" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="model-dialog-body">
|
||||
<div class="model-dialog-toolbar">
|
||||
<el-tabs v-model="activeModelTab" class="model-tabs">
|
||||
<el-tab-pane label="官方模型" name="official" />
|
||||
<el-tab-pane label="我的模型" name="mine" />
|
||||
</el-tabs>
|
||||
<el-input
|
||||
v-model="modelSearchKeyword"
|
||||
class="model-search"
|
||||
placeholder="从全部模型中搜索"
|
||||
clearable
|
||||
>
|
||||
<template #suffix>
|
||||
<i class="fa fa-search" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="model-dialog-content">
|
||||
<aside class="model-series-list">
|
||||
<div class="column-title">模型系列</div>
|
||||
<button
|
||||
v-for="series in modelSeriesGroups"
|
||||
:key="series.name"
|
||||
class="model-series-item"
|
||||
:class="{ active: activeModelSeries === series.name }"
|
||||
type="button"
|
||||
@click="chooseModelSeries(series.name)"
|
||||
>
|
||||
<i class="fa fa-star-o" />
|
||||
<span>{{ series.name }}</span>
|
||||
<em>{{ series.count }}</em>
|
||||
</button>
|
||||
<el-empty v-if="!modelSeriesGroups.length" description="暂无模型" :image-size="80" />
|
||||
</aside>
|
||||
|
||||
<section class="model-version-list">
|
||||
<div class="column-title">快照版本</div>
|
||||
<button
|
||||
v-for="model in currentSeriesModels"
|
||||
:key="model.id"
|
||||
class="model-version-item"
|
||||
:class="{ active: draftModelId === model.id }"
|
||||
type="button"
|
||||
@click="chooseDraftModel(model)"
|
||||
>
|
||||
<div class="model-version-main">
|
||||
<strong>{{ model.name }}</strong>
|
||||
<span>{{ model.description || model.path || model.online_model_name || '暂无模型描述' }}</span>
|
||||
</div>
|
||||
<span class="model-radio-dot" />
|
||||
</button>
|
||||
<el-empty v-if="!currentSeriesModels.length" description="暂无快照版本" :image-size="80" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="model-dialog-footer">
|
||||
<div class="selected-model-summary">
|
||||
<span>已选 {{ draftModel ? 1 : 0 }}/1</span>
|
||||
<div v-if="draftModel" class="selected-model-pill">
|
||||
<i class="fa fa-star-o" />
|
||||
{{ draftModel.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-dialog-actions">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.model-select-dialog) {
|
||||
max-width: calc(100vw - 48px);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 18px 46px rgba(15, 23, 42, 0.18);
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dialog-header {
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
}
|
||||
|
||||
.model-dialog-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dialog-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f6fb;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dialog-body {
|
||||
height: 452px;
|
||||
}
|
||||
|
||||
.model-dialog-toolbar {
|
||||
height: 76px;
|
||||
padding: 14px 20px 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
}
|
||||
|
||||
.model-tabs {
|
||||
min-width: 210px;
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
height: 42px;
|
||||
padding: 0 18px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
height: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.model-search {
|
||||
width: 280px;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dialog-content {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: 376px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.model-series-list,
|
||||
.model-version-list {
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.model-series-list {
|
||||
border-right: 1px solid #eef0f5;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.model-series-item,
|
||||
.model-version-item {
|
||||
width: 100%;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #1f2937;
|
||||
|
||||
&:hover {
|
||||
border-color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
box-shadow: inset 0 0 0 1px #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.model-series-item {
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
|
||||
i {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: #777b92;
|
||||
}
|
||||
}
|
||||
|
||||
.model-version-item {
|
||||
min-height: 64px;
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.model-version-main {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.model-radio-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.model-version-item.active .model-radio-dot {
|
||||
border-color: #2563eb;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dialog-footer {
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid #eef0f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.selected-model-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.selected-model-pill {
|
||||
max-width: 300px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 4px;
|
||||
background: #eff6ff;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.model-dialog-toolbar,
|
||||
.model-dialog-footer {
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.model-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.model-dialog-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.model-series-list {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/components/ModelStatusTag.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
status?: string
|
||||
}>()
|
||||
|
||||
/** 将任务/模型状态映射为 Element Plus tag 类型 */
|
||||
const tagInfo = computed(() => {
|
||||
const s = props.status || ''
|
||||
switch (s) {
|
||||
case 'running':
|
||||
case 'ready':
|
||||
case 'loaded':
|
||||
return { text: s === 'ready' || s === 'loaded' ? '已就绪' : '运行中', type: 'success' as const }
|
||||
case 'pending':
|
||||
return { text: '等待中', type: 'info' as const }
|
||||
case 'failed':
|
||||
return { text: '失败', type: 'danger' as const }
|
||||
case 'starting':
|
||||
case 'loading':
|
||||
return { text: '加载中', type: 'warning' as const }
|
||||
case 'completed':
|
||||
return { text: '已完成', type: 'success' as const }
|
||||
case 'stopped':
|
||||
return { text: '已停止', type: 'info' as const }
|
||||
default:
|
||||
return { text: s || '未知', type: 'info' as const }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tag :type="tagInfo.type" size="small" effect="light">{{ tagInfo.text }}</el-tag>
|
||||
</template>
|
||||
66
frontend/src/components/PageCard.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用页面卡片容器
|
||||
* 用于表单页、详情页等需要统一卡片样式的场景
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
bordered?: boolean
|
||||
}>(),
|
||||
{
|
||||
bordered: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="page-card">
|
||||
<div v-if="title || $slots.header" class="page-card-header">
|
||||
<slot name="header">
|
||||
<div>
|
||||
<h2 class="page-card-title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="page-card-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</slot>
|
||||
<div v-if="$slots.extra" class="page-card-extra">
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-card-body">
|
||||
<slot />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-card {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.page-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-card-subtitle {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
52
frontend/src/components/PlaceholderView.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 占位页面
|
||||
* 用于尚未实现的菜单项,保持统一的卡片样式与「敬请期待」提示
|
||||
*/
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
desc?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageCard :title="title">
|
||||
<div class="placeholder">
|
||||
<i class="fa fa-wrench placeholder-icon" />
|
||||
<p class="placeholder-title">{{ title }} 功能开发中</p>
|
||||
<p class="placeholder-desc">{{ desc || '敬请期待,我们正在努力构建该功能。' }}</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 56px;
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/composables/useCountdown.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 倒计时 composable(日志自动刷新倒计时用)
|
||||
*/
|
||||
export function useCountdown(total: number) {
|
||||
const remaining = ref(total)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function start() {
|
||||
stop()
|
||||
remaining.value = total
|
||||
timer = setInterval(() => {
|
||||
remaining.value--
|
||||
if (remaining.value <= 0) {
|
||||
remaining.value = total
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
remaining.value = total
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stop)
|
||||
|
||||
return { remaining, start, stop, reset }
|
||||
}
|
||||
20
frontend/src/composables/usePolling.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 轮询 composable
|
||||
* 封装 useIntervalFn,自动在组件卸载时清理
|
||||
*/
|
||||
export function usePolling(fn: () => void | Promise<void>, interval = 5000, immediate = true) {
|
||||
const { pause, resume } = useIntervalFn(fn, interval, { immediate })
|
||||
|
||||
function stop() {
|
||||
pause()
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (immediate) fn()
|
||||
resume()
|
||||
}
|
||||
|
||||
return { start, stop, pause, resume }
|
||||
}
|
||||
143
frontend/src/composables/useStreamChat.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { ref } from 'vue'
|
||||
import { streamChat } from '@/api/modules/compare'
|
||||
|
||||
export interface StreamMessage {
|
||||
/** 用户问题 */
|
||||
question: string
|
||||
/** 完整回答(含 think 标签原始内容) */
|
||||
fullContent: string
|
||||
/** 去除 think 标签后的展示内容 */
|
||||
displayContent: string
|
||||
/** 思考过程内容 */
|
||||
thinkContent: string
|
||||
/** 是否正在思考(think 标签未闭合) */
|
||||
isThinking: boolean
|
||||
/** 是否流式中 */
|
||||
isStreaming: boolean
|
||||
/** 是否已完成 */
|
||||
done: boolean
|
||||
/** 错误信息 */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话 composable
|
||||
* 移植自原 model-chat.html:
|
||||
* - fetch + body.getReader() + TextDecoder
|
||||
* - <think>...</think> 标签解析(思考过程可折叠)
|
||||
* - 50ms 节流更新
|
||||
*/
|
||||
export function useStreamChat() {
|
||||
const message = ref<StreamMessage>({
|
||||
question: '',
|
||||
fullContent: '',
|
||||
displayContent: '',
|
||||
thinkContent: '',
|
||||
isThinking: false,
|
||||
isStreaming: false,
|
||||
done: false,
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
/** 从内容中解析 think 标签 */
|
||||
function parseContent(content: string) {
|
||||
const thinkRegex = /<think>([\s\S]*?)(<\/think>)?/g
|
||||
let think = ''
|
||||
let display = content
|
||||
let isThinking = false
|
||||
|
||||
let match
|
||||
// 检查是否有未闭合的 think 标签
|
||||
const openTags = (content.match(/<think>/g) || []).length
|
||||
const closeTags = (content.match(/<\/think>/g) || []).length
|
||||
isThinking = openTags > closeTags
|
||||
|
||||
// 提取所有 think 内容
|
||||
while ((match = thinkRegex.exec(content)) !== null) {
|
||||
think += match[1]
|
||||
}
|
||||
// 去除 think 标签得到展示内容
|
||||
display = content.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trim()
|
||||
|
||||
return { think: think.trim(), display, isThinking }
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起流式对话
|
||||
* @param payload 后端请求体 { port, model_name, model_path, system_prompt, user_question, ... }
|
||||
*/
|
||||
async function send(payload: any) {
|
||||
loading.value = true
|
||||
message.value = {
|
||||
question: payload.user_question || '',
|
||||
fullContent: '',
|
||||
displayContent: '',
|
||||
thinkContent: '',
|
||||
isThinking: false,
|
||||
isStreaming: true,
|
||||
done: false,
|
||||
}
|
||||
|
||||
let lastUpdate = 0
|
||||
const UPDATE_INTERVAL = 50 // 50ms 节流
|
||||
|
||||
try {
|
||||
const response = await streamChat(payload)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('无法读取响应流')
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
message.value.fullContent = buffer
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastUpdate >= UPDATE_INTERVAL) {
|
||||
lastUpdate = now
|
||||
const parsed = parseContent(buffer)
|
||||
message.value.thinkContent = parsed.think
|
||||
message.value.displayContent = parsed.display
|
||||
message.value.isThinking = parsed.isThinking
|
||||
}
|
||||
}
|
||||
|
||||
// 最终更新
|
||||
const parsed = parseContent(buffer)
|
||||
message.value.thinkContent = parsed.think
|
||||
message.value.displayContent = parsed.display
|
||||
message.value.isThinking = false
|
||||
message.value.isStreaming = false
|
||||
message.value.done = true
|
||||
} catch (e: any) {
|
||||
message.value.isStreaming = false
|
||||
message.value.done = true
|
||||
message.value.error = e.message || '流式请求失败'
|
||||
message.value.displayContent = message.value.fullContent || message.value.error || '请求失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
message.value = {
|
||||
question: '',
|
||||
fullContent: '',
|
||||
displayContent: '',
|
||||
thinkContent: '',
|
||||
isThinking: false,
|
||||
isStreaming: false,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
return { message, loading, send, reset }
|
||||
}
|
||||
106
frontend/src/constants/dimension.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { DimensionType } from '@/types'
|
||||
|
||||
/** 各指标类型的评估方法选项 */
|
||||
export const EVAL_METHODS: Record<
|
||||
DimensionType | string,
|
||||
{ value: string; name: string; desc: string }[]
|
||||
> = {
|
||||
classification: [
|
||||
{ value: 'standard', name: '标准匹配', desc: '根据预设标准评估' },
|
||||
{ value: 'sentiment', name: '情感分析', desc: '识别文本情感倾向' },
|
||||
{ value: 'custom', name: '自定义评分器', desc: '自定义评估规则' },
|
||||
],
|
||||
metric: [
|
||||
{ value: 'metric_standard', name: '综合评测', desc: '根据预设标准评分' },
|
||||
{ value: 'semantic', name: '语义相似度', desc: '计算语义相似程度' },
|
||||
{ value: 'custom', name: '自定义评分器', desc: '自定义评估规则' },
|
||||
],
|
||||
text_similarity: [
|
||||
{ value: 'bleu_4', name: 'BLEU-4', desc: 'N-gram 精确度评估' },
|
||||
{ value: 'cosine', name: 'Cosine', desc: '余弦相似度' },
|
||||
{ value: 'rouge_1', name: 'ROUGE-1', desc: 'unigram 重叠评估' },
|
||||
{ value: 'rouge_2', name: 'ROUGE-2', desc: 'bigram 重叠评估' },
|
||||
{ value: 'rouge_4', name: 'ROUGE-4', desc: '4-gram 重叠评估' },
|
||||
],
|
||||
}
|
||||
|
||||
/** 各评估方法的预置 Prompt 模板 */
|
||||
export const EVAL_METHOD_PROMPTS: Record<string, string> = {
|
||||
standard: `# 角色
|
||||
你是一位专业的对话评估专家,擅长根据提供的标准对助理在对话中的最终反应进行评估,并确定其是否[[Pass]]或[[Fail]]。
|
||||
|
||||
## 技能
|
||||
### 技能 1: 对话回顾
|
||||
- 通读整个对话以理解上下文和背景信息。
|
||||
- 确保全面理解对话的意图和用户的需求。
|
||||
|
||||
### 技能 2: 最终回答识别
|
||||
- 从对话中准确识别出助理给出的最后一个回答。
|
||||
- 确保关注的是最终的回答,而不是中间的部分。
|
||||
|
||||
### 技能 3: 标准应用
|
||||
- 仔细审查每个评价标准。
|
||||
- 将助理的最终回答与标准的各个方面进行详细比较。
|
||||
|
||||
### 技能 4: 逐步推理
|
||||
- 记录每个标准以及最终响应如何满足或不满足该标准。
|
||||
- 提供详细的证据,并解释为什么最终答复满足或不满足期望。
|
||||
|
||||
### 技能 5: 结果判定
|
||||
- 根据逐步推理的结果,判断最终回答是[[Pass]]或[[Fail]]
|
||||
- 提供明确的结论并解释理由。
|
||||
|
||||
## 输出格式
|
||||
以下列格式提供结果:
|
||||
- **分步推理:**[详细推理在这里]
|
||||
- **最终结果:**[[Pass]]或[[Fail]]
|
||||
|
||||
## 限制
|
||||
- 只针对对话中的最终回答进行评估。
|
||||
- 在评估过程中,确保遵循提供的标准,避免主观判断。`,
|
||||
|
||||
sentiment: `# 角色
|
||||
你是一位专业的情感分析专家,擅长识别文本中的情感倾向,并给出[[Positive]]、[[Neutral]]或[[Negative]]的判断。
|
||||
|
||||
## 技能
|
||||
### 技能 1: 文本理解
|
||||
- 通读输入文本,理解其内容和语境。
|
||||
|
||||
### 技能 2: 情感识别
|
||||
- 识别文本中表达的情感词汇、语气和情感强度。
|
||||
- 分析上下文中的情感线索。
|
||||
|
||||
### 技能 3: 情感判定
|
||||
- 根据识别到的情感信息,判断整体情感倾向。
|
||||
|
||||
## 输出格式
|
||||
- **情感分析:**[分析过程]
|
||||
- **最终结果:**[[Positive]]/[[Neutral]]/[[Negative]]`,
|
||||
|
||||
metric_standard: `# 角色
|
||||
你是一位专业的模型评估专家,擅长根据预设标准对模型输出进行综合评分(0-5分)。
|
||||
|
||||
## 评估标准
|
||||
请从以下维度对模型输出进行评分:
|
||||
1. 准确性:回答是否准确无误
|
||||
2. 完整性:是否完整覆盖了问题的各个方面
|
||||
3. 连贯性:回答是否逻辑清晰、结构合理
|
||||
4. 可读性:语言表达是否流畅、易懂
|
||||
|
||||
## 输出格式
|
||||
- **评分理由:**[详细说明]
|
||||
- **最终评分:**[0-5 的分数]`,
|
||||
|
||||
semantic: `# 角色
|
||||
你是一位专业的语义相似度评估专家,擅长计算两段文本之间的语义相似程度。
|
||||
|
||||
## 任务
|
||||
请评估生成文本与参考文本在语义上的相似程度,给出 0-1 之间的分数。
|
||||
|
||||
## 输出格式
|
||||
- **相似度分析:**[分析过程]
|
||||
- **相似度分数:**[0-1 的分数]`,
|
||||
|
||||
custom: `# 自定义评估器
|
||||
请在此编写您的自定义评估 Prompt...`,
|
||||
}
|
||||
144
frontend/src/constants/index.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 全局常量与映射表
|
||||
*/
|
||||
|
||||
/** 模型类型 */
|
||||
export const MODEL_TYPE_MAP: Record<string, string> = {
|
||||
LLM: '大语言模型',
|
||||
CV: '计算机视觉',
|
||||
NLP: '自然语言处理',
|
||||
Embedding: '向量模型',
|
||||
Other: '其他',
|
||||
}
|
||||
|
||||
/** 模型用途 */
|
||||
export const PURPOSE_MAP: Record<string, { text: string; type: string }> = {
|
||||
training: { text: '训练', type: 'primary' },
|
||||
inference: { text: '推理', type: 'success' },
|
||||
evaluation: { text: '评测', type: 'warning' },
|
||||
}
|
||||
|
||||
/** 模型来源 */
|
||||
export const MODEL_SOURCE_MAP: Record<string, string> = {
|
||||
local: '本地模型',
|
||||
api: '在线模型',
|
||||
online: '在线模型',
|
||||
}
|
||||
|
||||
/** 训练方式 */
|
||||
export const TRAIN_TYPE_MAP: Record<string, string> = {
|
||||
SFT: 'SFT',
|
||||
DPO: 'DPO',
|
||||
CPT: 'CPT',
|
||||
}
|
||||
|
||||
/** 训练方法 */
|
||||
export const TRAIN_METHOD_MAP: Record<string, string> = {
|
||||
lora: 'LoRA',
|
||||
qlora: 'QLoRA',
|
||||
full: '全量微调',
|
||||
prefix: 'Prefix Tuning',
|
||||
adapter: 'Adapter',
|
||||
peft: 'PEFT',
|
||||
adalora: 'AdaLoRA',
|
||||
longlora: 'LongLoRA',
|
||||
dpo: 'DPO',
|
||||
cpt: 'CPT',
|
||||
}
|
||||
|
||||
/** 数据集类型 */
|
||||
export const DATASET_TYPE_MAP: Record<string, string> = {
|
||||
train: '训练数据',
|
||||
test: '测试数据',
|
||||
eval: '评测数据',
|
||||
val: '验证数据',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
/** 存储位置 */
|
||||
export const STORAGE_MAP: Record<string, string> = {
|
||||
local: '本地存储',
|
||||
minio: 'MinIO',
|
||||
cloud: '云存储',
|
||||
}
|
||||
|
||||
/** 评测维度指标类型 */
|
||||
export const DIMENSION_TYPE_MAP: Record<string, string> = {
|
||||
classification: '大模型-分类型',
|
||||
metric: '大模型-指标型',
|
||||
text_similarity: '规则-文本相似度',
|
||||
}
|
||||
|
||||
/** 学习率调度器类型 */
|
||||
export const LR_SCHEDULER_OPTIONS = [
|
||||
{ label: 'cosine', value: 'cosine' },
|
||||
{ label: 'linear', value: 'linear' },
|
||||
{ label: 'constant', value: 'constant' },
|
||||
]
|
||||
|
||||
/** 训练模板分组(按模型系列) */
|
||||
export const TEMPLATE_GROUPS: { label: string; options: { label: string; value: string }[] }[] = [
|
||||
{
|
||||
label: 'Qwen 系列',
|
||||
options: [
|
||||
{ label: 'qwen', value: 'qwen' },
|
||||
{ label: 'qwen2', value: 'qwen2' },
|
||||
{ label: 'qwen2_5', value: 'qwen2_5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Llama 系列',
|
||||
options: [
|
||||
{ label: 'llama2', value: 'llama2' },
|
||||
{ label: 'llama3', value: 'llama3' },
|
||||
{ label: 'llama3_1', value: 'llama3_1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DeepSeek 系列',
|
||||
options: [
|
||||
{ label: 'deepseek', value: 'deepseek' },
|
||||
{ label: 'deepseek2', value: 'deepseek2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '其他系列',
|
||||
options: [
|
||||
{ label: 'glm4', value: 'glm4' },
|
||||
{ label: 'chatglm3', value: 'chatglm3' },
|
||||
{ label: 'gemma', value: 'gemma' },
|
||||
{ label: 'gemma2', value: 'gemma2' },
|
||||
{ label: 'phi', value: 'phi' },
|
||||
{ label: 'intern2', value: 'intern2' },
|
||||
{ label: 'mistral', value: 'mistral' },
|
||||
{ label: 'default', value: 'default' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 默认工具列表(其他工具页) */
|
||||
export const DEFAULT_TOOLS = [
|
||||
{ id: 'data-generate', name: '数据生成', icon: 'fa-database', description: '基于LLM生成微调数据集' },
|
||||
{ id: 'json2jsonl', name: 'JSON转JSONL', icon: 'fa-code', description: '将JSON文件转换为JSONL格式' },
|
||||
{ id: 'md-convert', name: '转换Markdown', icon: 'fa-file-text', description: '将Markdown文件转换为训练数据' },
|
||||
]
|
||||
|
||||
/** 自定义工具可选图标 */
|
||||
export const TOOL_ICONS = [
|
||||
'fa-cog', 'fa-database', 'fa-code', 'fa-file-text', 'fa-wrench', 'fa-cube',
|
||||
'fa-line-chart', 'fa-bar-chart', 'fa-server', 'fa-cogs', 'fa-tasks', 'fa-trophy',
|
||||
'fa-sliders', 'fa-rocket', 'fa-cloud', 'fa-cloud-download', 'fa-microchip',
|
||||
'fa-hdd-o', 'fa-globe', 'fa-info-circle', 'fa-question-circle-o', 'fa-user-o',
|
||||
'fa-lock-o', 'fa-sign-in', 'fa-sign-out', 'fa-search', 'fa-plus', 'fa-refresh',
|
||||
'fa-times', 'fa-check', 'fa-pencil', 'fa-trash', 'fa-play', 'fa-arrow-left',
|
||||
'fa-arrow-down', 'fa-arrow-up',
|
||||
]
|
||||
|
||||
/** 会话超时:5 分钟 */
|
||||
export const SESSION_TIMEOUT = 5 * 60 * 1000
|
||||
|
||||
/** 系统监控刷新间隔 */
|
||||
export const METRICS_INTERVAL = 30000
|
||||
|
||||
/** 训练进度刷新间隔 */
|
||||
export const TRAINING_REFRESH_INTERVAL = 5000
|
||||
7
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
116
frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useModelsStore } from '@/stores/models'
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
const modelsStore = useModelsStore()
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(() => {
|
||||
// 启动顶部栏系统监控轮询
|
||||
systemStore.start()
|
||||
// 预加载模型列表缓存(列表页渲染模型名用)
|
||||
modelsStore.load()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
systemStore.stop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<AppSidebar />
|
||||
<div class="layout-main">
|
||||
<AppHeader />
|
||||
<main class="layout-content">
|
||||
<div
|
||||
class="page-canvas"
|
||||
:class="{ 'is-self-surface': route.meta.pageSurface === 'self' }"
|
||||
>
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.main-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 8px 16px;
|
||||
background-color: var(--app-shell-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
&:has(.data-table-page),
|
||||
&:has(.eval-page),
|
||||
&:has(.manage-card-container) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
&:has(.create-wizard-layout) {
|
||||
overflow-y: hidden;
|
||||
|
||||
.page-canvas {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-canvas {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--app-page-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.page-canvas.is-self-surface {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.page-canvas:has(.has-fixed-footer) {
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
/* 路由直接输出 PageCard,或显式声明根卡片宿主时,复用外层白色画布 */
|
||||
.page-canvas:not(.is-self-surface) > :deep(.page-card),
|
||||
.page-canvas:not(.is-self-surface) > :deep(.page-card-host > .page-card) {
|
||||
margin-bottom: 0;
|
||||
background-color: transparent;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
25
frontend/src/main.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
// Font Awesome 图标
|
||||
import '@/assets/font-awesome/css/font-awesome.min.css'
|
||||
|
||||
// ECharts(按需引入)+ vue-echarts 组件
|
||||
import '@/plugins/echarts'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.component('VChart', VChart)
|
||||
|
||||
app.mount('#app')
|
||||
268
frontend/src/mock/adapter.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Axios Mock Adapter
|
||||
* 拦截所有 API 请求并返回 mock 数据
|
||||
* 通过 URL + method 路由到对应的 mock 响应
|
||||
*/
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import {
|
||||
mockLoginOk,
|
||||
mockHealth,
|
||||
mockSystemInfo,
|
||||
mockModels,
|
||||
mockTrainedModels,
|
||||
mockLocalModels,
|
||||
mockDatasets,
|
||||
mockFineTuneList,
|
||||
mockCompareList,
|
||||
mockEvalList,
|
||||
mockDimensions,
|
||||
mockLogFiles,
|
||||
mockTrainingLogFiles,
|
||||
mockLogContent,
|
||||
} from './data'
|
||||
|
||||
/** 模拟网络延迟 */
|
||||
function delay(ms = 200): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** 构造 Axios 风格的成功响应 */
|
||||
function ok(data: any, config: AxiosRequestConfig = {}) {
|
||||
return {
|
||||
data: { code: 0, message: 'ok', data },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造失败响应 */
|
||||
function fail(message: string, code = 500, config: AxiosRequestConfig = {}) {
|
||||
return {
|
||||
data: { code, message },
|
||||
status: code,
|
||||
statusText: message,
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/** 简易 URL 解析(去掉 baseURL 前缀) */
|
||||
function stripBaseURL(url: string): string {
|
||||
// url 可能带查询参数
|
||||
const [path] = url.split('?')
|
||||
return path
|
||||
}
|
||||
|
||||
/** 通过路径 + method 匹配 mock 响应 */
|
||||
async function handleMock(config: AxiosRequestConfig) {
|
||||
await delay(150) // 模拟网络延迟
|
||||
|
||||
const url = stripBaseURL(config.url || '')
|
||||
const method = (config.method || 'get').toLowerCase()
|
||||
const params = config.params || {}
|
||||
const body = typeof config.data === 'string' ? safeJSON(config.data) : config.data || {}
|
||||
|
||||
// ==================== 认证 ====================
|
||||
if (url === '/login' && method === 'post') {
|
||||
if (body.username === 'admin' && body.password === 'admin') {
|
||||
return ok(mockLoginOk.data)
|
||||
}
|
||||
return fail('账号或密码错误', 401)
|
||||
}
|
||||
if (url === '/web-log' && method === 'post') {
|
||||
return ok({ received: true })
|
||||
}
|
||||
|
||||
// ==================== 系统监控 ====================
|
||||
if (url === '/health' && method === 'get') return ok(mockHealth)
|
||||
if (url === '/system-info' && method === 'get') return ok(mockSystemInfo)
|
||||
|
||||
// ==================== 模型管理 ====================
|
||||
if (url === '/model-manage' && method === 'get') return ok(mockModels)
|
||||
if (url === '/model-manage/local-models' && method === 'get') return ok(mockLocalModels)
|
||||
if (url === '/model-manage/trained-models' && method === 'get') return ok(mockTrainedModels)
|
||||
if (url === '/model-manage/merge' && method === 'post') {
|
||||
return ok({ merged: true, path: '/data/saves/' + body.model_name + '-merged' })
|
||||
}
|
||||
// 模型详情 / 编辑 / 删除 / 用途更新
|
||||
let m = url.match(/^\/model-manage\/([^/]+)$/)
|
||||
if (m && method === 'get') {
|
||||
const id = m[1]
|
||||
const found = mockModels.find((x) => String(x.id) === id || x.name === id)
|
||||
return found ? ok(found) : fail('模型不存在', 404)
|
||||
}
|
||||
m = url.match(/^\/model-manage\/([^/]+)$/)
|
||||
if (m && (method === 'put' || method === 'delete')) {
|
||||
return ok({ id: m[1], ...body })
|
||||
}
|
||||
m = url.match(/^\/model-manage\/name\/([^/]+)$/)
|
||||
if (m && method === 'get') {
|
||||
const name = decodeURIComponent(m[1])
|
||||
const found = mockModels.find((x) => x.name === name)
|
||||
return found ? ok(found) : fail('模型不存在', 404)
|
||||
}
|
||||
m = url.match(/^\/model-manage\/trained-models\/([^/]+)$/)
|
||||
if (m && method === 'delete') {
|
||||
return ok({ deleted: m[1] })
|
||||
}
|
||||
m = url.match(/^\/model-manage\/([^/]+)\/purpose$/)
|
||||
if (m && method === 'put') {
|
||||
return ok({ id: m[1], purpose: body.purpose })
|
||||
}
|
||||
|
||||
// ==================== 数据集 ====================
|
||||
if (url === '/dataset-manage' && method === 'get') return ok(mockDatasets)
|
||||
if (url === '/dataset-manage' && method === 'post') {
|
||||
const newId = Math.max(...mockDatasets.map((d) => Number(d.id))) + 1
|
||||
return ok({ id: newId })
|
||||
}
|
||||
m = url.match(/^\/dataset-manage\/([^/]+)$/)
|
||||
if (m && method === 'get') {
|
||||
const found = mockDatasets.find((x) => String(x.id) === m[1])
|
||||
return found ? ok(found) : fail('数据集不存在', 404)
|
||||
}
|
||||
if (m && (method === 'put' || method === 'delete')) {
|
||||
return ok({ id: m[1] })
|
||||
}
|
||||
m = url.match(/^\/dataset-manage\/upload\/([^/]+)$/)
|
||||
if (m && method === 'post') return ok({ uploaded: true })
|
||||
m = url.match(/^\/dataset-manage\/preview\/([^/]+)$/)
|
||||
if (m && method === 'get') return ok(mockLogContent)
|
||||
|
||||
// ==================== 训练任务 ====================
|
||||
if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)
|
||||
if (url === '/fine-tune' && method === 'post') {
|
||||
return ok({ id: Math.floor(Math.random() * 10000) + 100 })
|
||||
}
|
||||
if (url === '/fine-tune/start' && method === 'post') {
|
||||
return ok({ started: true })
|
||||
}
|
||||
if (url === '/fine-tune/check-name' && method === 'get') {
|
||||
return ok({ exists: false })
|
||||
}
|
||||
m = url.match(/^\/fine-tune\/progress\/([^/]+)$/)
|
||||
if (m && method === 'get') {
|
||||
const task = mockFineTuneList.find((t) => String(t.id) === m[1])
|
||||
if (!task) return fail('任务不存在', 404)
|
||||
if (task.status === 'running') {
|
||||
return ok({
|
||||
status: task.status,
|
||||
progress: task.progress ?? 0,
|
||||
step: `${Math.floor((task.progress ?? 0) * 10)}/1000`,
|
||||
speed: '1.23s/it',
|
||||
eta: '00:23:45',
|
||||
})
|
||||
}
|
||||
return ok({ status: task.status, progress: task.progress ?? 0 })
|
||||
}
|
||||
m = url.match(/^\/fine-tune\/([^/]+)$/)
|
||||
if (m && method === 'get') {
|
||||
const found = mockFineTuneList.find((x) => String(x.id) === m[1])
|
||||
return found ? ok(found) : fail('任务不存在', 404)
|
||||
}
|
||||
m = url.match(/^\/fine-tune\/stop\/([^/]+)$/)
|
||||
if (m && method === 'post') return ok({ stopped: true })
|
||||
m = url.match(/^\/fine-tune\/([^/]+)$/)
|
||||
if (m && (method === 'put' || method === 'delete')) {
|
||||
return ok({ id: m[1] })
|
||||
}
|
||||
if (url === '/fine-tune/tensorboard/start' && method === 'post') {
|
||||
return ok({ url: 'http://10.10.10.77:6006' })
|
||||
}
|
||||
|
||||
// ==================== 模型推理/对比 ====================
|
||||
if (url === '/model-compare' && method === 'get') return ok(mockCompareList)
|
||||
if (url === '/model-compare' && method === 'post') {
|
||||
return ok({ id: Math.floor(Math.random() * 10000) + 100 })
|
||||
}
|
||||
m = url.match(/^\/model-compare\/([^/]+)$/)
|
||||
if (m && method === 'get') {
|
||||
const found = mockCompareList.find((x) => String(x.id) === m[1])
|
||||
return found ? ok(found) : fail('任务不存在', 404)
|
||||
}
|
||||
if (m && method === 'delete') return ok({ deleted: m[1] })
|
||||
m = url.match(/^\/model-compare\/([^/]+)\/load-status$/)
|
||||
if (m && method === 'get') {
|
||||
return ok({ all_ready: true, loaded_models: [] })
|
||||
}
|
||||
if (m && method === 'post') return ok({ updated: true })
|
||||
m = url.match(/^\/model-compare\/([^/]+)\/load$/)
|
||||
if (m && method === 'post') return ok({ loading: true })
|
||||
m = url.match(/^\/model-compare\/([^/]+)\/unload$/)
|
||||
if (m && method === 'post') return ok({ stopped: true })
|
||||
m = url.match(/^\/model-compare\/([^/]+)\/start-model$/)
|
||||
if (m && method === 'post') {
|
||||
return ok({ pid: 12345 + Math.floor(Math.random() * 100), port: 18000 + Math.floor(Math.random() * 1000) })
|
||||
}
|
||||
if (url === '/model-compare/all/stop-all' && method === 'post') return ok({ stopped: true })
|
||||
if (url === '/model-compare/stop-by-pid' && method === 'post') return ok({ stopped: true })
|
||||
if (url === '/model-compare/chat-with-port' && method === 'post') {
|
||||
// 模拟推理回答(用于对比结果页)
|
||||
const userQ = body?.messages?.find((m: any) => m.role === 'user')?.content || '你的问题'
|
||||
const answer = `这是一个针对「${userQ.slice(0, 30)}」的模拟回答。\n\n在真实环境中,对比结果页会通过端口代理调用对应模型服务,返回该模型的真实推理结果。\n\n模型参数量、温度、Top-p 等参数都会影响输出内容。\n\n- 模型:${body?.model_name || '未知'}\n- 端口:${body?.port || '-'}\n- 参数:temperature=${body?.temperature ?? 0.7}, max_tokens=${body?.max_tokens ?? 2048}`
|
||||
return ok({ response: answer, content: answer })
|
||||
}
|
||||
if (url === '/model-compare/stream-chat' && method === 'post') {
|
||||
// 模拟流式对话(前端 useStreamChat 会按块读取)
|
||||
const userQ = body?.user_question || ''
|
||||
const sysPrompt = body?.system_prompt || ''
|
||||
const answer = `${sysPrompt ? '【系统提示】' + sysPrompt.slice(0, 50) + '\n\n' : ''}关于「${userQ}」的回答:\n\n这是一段**模拟流式输出**。在真实部署中,后端会通过 SSE/WebSocket 逐字推送 token。\n\n## 模型参数\n- 温度(temperature):${body?.temperature ?? 0.7}\n- 最大长度(max_tokens):${body?.max_tokens ?? 2048}\n\n## 思考过程\n让我先分析这个问题...\n- 识别用户意图\n- 检索相关知识\n- 生成回答\n\n回答已生成。以上为前端 Mock 演示内容。`
|
||||
return ok({ response: answer })
|
||||
}
|
||||
if (url === '/model-compare/test-stream' && method === 'post') {
|
||||
return ok({ response: '测试流式输出' })
|
||||
}
|
||||
if (url === '/model-chat/batch' && method === 'post') return ok({ responses: [] })
|
||||
if (url === '/model-chat/local/chat' && method === 'post') return ok({ response: '本地模型回复' })
|
||||
if (url === '/model-chat/local/preload' && method === 'post') return ok({ loaded: true })
|
||||
if (url === '/model-chat/trained/preload' && method === 'post') return ok({ loaded: true })
|
||||
|
||||
// ==================== 模型评测 ====================
|
||||
if (url === '/model-eval' && method === 'get') return ok(mockEvalList)
|
||||
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 === '/dimension' && method === 'get') return ok(mockDimensions)
|
||||
if (url === '/dimension' && method === 'post') {
|
||||
return ok({ id: Math.floor(Math.random() * 1000) + 100 })
|
||||
}
|
||||
m = url.match(/^\/dimension\/([^/]+)$/)
|
||||
if (m && method === 'get') {
|
||||
const found = mockDimensions.find((x) => String(x.id) === m[1])
|
||||
return found ? ok(found) : fail('维度不存在', 404)
|
||||
}
|
||||
if (m && (method === 'put' || method === 'delete')) {
|
||||
return ok({ id: m[1] })
|
||||
}
|
||||
|
||||
// ==================== 日志 ====================
|
||||
if (url === '/log-files' && method === 'get') return ok(mockLogFiles)
|
||||
if (url === '/log-content' && method === 'get') return ok(mockLogContent)
|
||||
if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles)
|
||||
if (url === '/training-log-content' && method === 'get') return ok(mockLogContent)
|
||||
|
||||
// 未匹配的请求 → 兜底返回空成功(避免阻断 UI)
|
||||
console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params)
|
||||
return ok({ mocked: true, url, method, params, body })
|
||||
}
|
||||
|
||||
function safeJSON(str: string) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 给 axios instance 安装 mock adapter */
|
||||
export function installMockAdapter(instance: AxiosInstance) {
|
||||
instance.defaults.adapter = async (config: AxiosRequestConfig) => {
|
||||
try {
|
||||
const response = await handleMock(config)
|
||||
return response
|
||||
} catch (e: any) {
|
||||
return fail(e.message || 'Mock 错误', 500, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
230
frontend/src/mock/data.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 全量 Mock 数据
|
||||
* 为前端开发提供不依赖后端的模拟数据
|
||||
*/
|
||||
|
||||
import type {
|
||||
FineTuneTask,
|
||||
ModelItem,
|
||||
TrainedModel,
|
||||
DatasetItem,
|
||||
CompareTask,
|
||||
EvalTask,
|
||||
Dimension,
|
||||
SystemInfo,
|
||||
HealthMetrics,
|
||||
LogFile,
|
||||
TrainingLogFile,
|
||||
LogContent,
|
||||
} from '@/types'
|
||||
|
||||
// ============ 认证 ============
|
||||
export const mockLoginOk = { code: 0, message: 'ok', data: { token: 'mock-token' } }
|
||||
|
||||
// ============ 系统监控 ============
|
||||
export const mockHealth: HealthMetrics = {
|
||||
cpu_percent: 32,
|
||||
memory_percent: 58,
|
||||
disk_percent: 45,
|
||||
}
|
||||
|
||||
export const mockSystemInfo: SystemInfo = {
|
||||
cpu: {
|
||||
percent: 32,
|
||||
cores: 8,
|
||||
percents: [25, 38, 42, 30, 28, 36, 40, 18],
|
||||
},
|
||||
memory: {
|
||||
used_gb: 9.2,
|
||||
total_gb: 16,
|
||||
percent: 58,
|
||||
available_gb: 6.8,
|
||||
cached_gb: 2.3,
|
||||
},
|
||||
disk: {
|
||||
used_gb: 230,
|
||||
total_gb: 512,
|
||||
percent: 45,
|
||||
},
|
||||
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' },
|
||||
{ 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' },
|
||||
{ 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', 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
],
|
||||
network: {
|
||||
download_mb: 1024,
|
||||
upload_mb: 256,
|
||||
},
|
||||
system: {
|
||||
uptime_seconds: 86400 * 3 + 3600 * 7 + 1800,
|
||||
process_count: 256,
|
||||
os: 'Ubuntu 22.04 LTS',
|
||||
},
|
||||
}
|
||||
|
||||
// ============ 模型管理 ============
|
||||
export const mockModels: ModelItem[] = [
|
||||
{ id: 1, name: 'Qwen2.5-7B-Instruct', type: 'LLM', purpose: 'training', model_source: 'local', description: 'Qwen2.5 7B 指令微调基座', path: '/data/models/qwen2.5-7b', create_time: '2025-12-10T08:30:00Z' },
|
||||
{ id: 2, name: 'Qwen2.5-14B-Instruct', type: 'LLM', purpose: 'training', model_source: 'local', description: 'Qwen2.5 14B 指令微调基座', path: '/data/models/qwen2.5-14b', create_time: '2025-12-12T10:15:00Z' },
|
||||
{ id: 3, name: 'Llama3-8B-Instruct', type: 'LLM', purpose: 'inference', model_source: 'local', description: 'Llama3 8B 推理模型', path: '/data/models/llama3-8b', create_time: '2025-12-15T14:20:00Z' },
|
||||
{ id: 4, name: 'DeepSeek-V2-Lite', type: 'LLM', purpose: 'inference', model_source: 'local', description: 'DeepSeek V2 Lite', path: '/data/models/deepseek-v2-lite', create_time: '2026-01-05T09:00:00Z' },
|
||||
{ id: 5, name: 'GPT-4o', type: 'LLM', purpose: 'evaluation', model_source: 'api', description: 'OpenAI GPT-4o 在线模型', api_url: 'https://api.openai.com/v1', api_key: 'sk-***', online_model_name: 'gpt-4o', create_time: '2026-01-08T11:30:00Z' },
|
||||
{ id: 6, name: 'Claude-3.5-Sonnet', type: 'LLM', purpose: 'evaluation', model_source: 'api', description: 'Anthropic Claude 3.5 Sonnet', api_url: 'https://api.anthropic.com', api_key: 'sk-***', online_model_name: 'claude-3-5-sonnet-20241022', create_time: '2026-01-10T16:45:00Z' },
|
||||
{ id: 7, name: 'BGE-large-zh', type: 'Embedding', purpose: 'inference', model_source: 'local', description: '中文 embedding 模型', path: '/data/models/bge-large-zh', create_time: '2026-01-12T13:00:00Z' },
|
||||
]
|
||||
|
||||
export const mockTrainedModels: { models: TrainedModel[] } = {
|
||||
models: [
|
||||
{ id: 1, name: 'qwen-ft-finance-001', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-7b', merged: true, merging: false, merged_path: '/data/saves/qwen-ft-finance-001-merged', create_time: '2026-01-15T10:30:00Z' },
|
||||
{ id: 2, name: 'qwen-ft-legal-002', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-7b', merged: false, merging: true, create_time: '2026-01-18T14:20:00Z' },
|
||||
{ id: 3, name: 'llama3-ft-customer-service', train_methods: [{ name: 'qlora' }], base_model_path: '/data/models/llama3-8b', merged: true, merging: false, merged_path: '/data/saves/llama3-ft-customer-service-merged', create_time: '2026-01-22T09:45:00Z' },
|
||||
{ id: 4, name: 'qwen-ft-medical-003', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-14b', merged: false, merging: false, create_time: '2026-02-01T16:10:00Z' },
|
||||
],
|
||||
}
|
||||
|
||||
export const mockLocalModels = {
|
||||
models: [
|
||||
{ path: '/data/models/qwen2.5-7b', name: 'Qwen2.5-7B-Instruct' },
|
||||
{ path: '/data/models/qwen2.5-14b', name: 'Qwen2.5-14B-Instruct' },
|
||||
{ path: '/data/models/llama3-8b', name: 'Llama3-8B-Instruct' },
|
||||
{ path: '/data/models/deepseek-v2-lite', name: 'DeepSeek-V2-Lite' },
|
||||
{ path: '/data/models/bge-large-zh', name: 'BGE-large-zh' },
|
||||
],
|
||||
}
|
||||
|
||||
// ============ 数据集 ============
|
||||
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: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' },
|
||||
{ id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' },
|
||||
{ id: 5, name: '通用能力评测', type: 'eval', storage_type: 'local', size: '64 MB', count: 3500, description: '通用能力评测数据集', create_time: '2026-01-12T11:30:00Z' },
|
||||
{ id: 6, name: '医疗问答-训练集', type: 'train', storage_type: 'local', size: '180 MB', count: 9800, description: '医疗问答对', create_time: '2026-02-01T15:00:00Z' },
|
||||
]
|
||||
|
||||
// ============ 训练任务 ============
|
||||
export const mockFineTuneList: FineTuneTask[] = [
|
||||
{ id: 1, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' },
|
||||
{ id: 2, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' },
|
||||
{ id: 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: 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: 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: 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' },
|
||||
]
|
||||
|
||||
// ============ 模型推理/对比 ============
|
||||
export const mockCompareList: CompareTask[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '金融问答对比',
|
||||
model_name: '金融问答对比',
|
||||
description: '对比基座模型与微调模型',
|
||||
status: 'loaded',
|
||||
models: JSON.stringify([
|
||||
{ model_id: 1, model_name: 'Qwen2.5-7B-Instruct', model_path: '/data/models/qwen2.5-7b', gpu_id: 0, source: 'database', port: 18001 },
|
||||
{ model_id: 101, model_name: 'qwen-ft-finance-001', model_path: '/data/saves/qwen-ft-finance-001-merged', gpu_id: 1, source: 'trained', port: 18002 },
|
||||
]),
|
||||
load_status: JSON.stringify({
|
||||
loaded_models: [
|
||||
{ model_id: 1, model_name: 'Qwen2.5-7B-Instruct', status: 'ready', pid: 12345, port: 18001 },
|
||||
{ model_id: 101, model_name: 'qwen-ft-finance-001', status: 'ready', pid: 12346, port: 18002 },
|
||||
],
|
||||
}),
|
||||
create_time: '2026-02-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '客服场景推理',
|
||||
model_name: '客服场景推理',
|
||||
description: '客服对话推理测试',
|
||||
status: 'pending',
|
||||
models: JSON.stringify([{ model_id: 3, model_name: 'Llama3-8B-Instruct', model_path: '/data/models/llama3-8b', gpu_id: 2, source: 'database' }]),
|
||||
load_status: JSON.stringify({ loaded_models: [] }),
|
||||
create_time: '2026-02-18T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '法律文书推理',
|
||||
model_name: '法律文书推理',
|
||||
description: '法律文书推理测试',
|
||||
status: 'loaded',
|
||||
models: JSON.stringify([{ model_id: 102, model_name: 'qwen-ft-legal-002', model_path: '/data/saves/qwen-ft-legal-002-merged', gpu_id: 3, source: 'trained' }]),
|
||||
load_status: JSON.stringify({
|
||||
loaded_models: [
|
||||
{ model_id: 102, model_name: 'qwen-ft-legal-002', status: 'ready', pid: 12350, port: 18003 },
|
||||
],
|
||||
}),
|
||||
create_time: '2026-02-20T14:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
// ============ 模型评测 ============
|
||||
export const mockEvalList: EvalTask[] = [
|
||||
{ id: 1, eval_task_name: '金融模型评测-v1', model_name: 'qwen-ft-finance-001', dataset: '金融评测集', metric: 'accuracy', score: 87.5, status: 'completed', create_time: '2026-02-01T10:00:00Z' },
|
||||
{ id: 2, eval_task_name: '客服模型评测-v1', model_name: 'llama3-ft-customer-service', dataset: '通用能力评测', metric: 'rouge-1', score: 0.82, status: 'completed', create_time: '2026-02-05T11: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 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: 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: 3, name: '语义相似度', type: 'metric', description: '生成文本与参考答案的语义相似度', eval_model: 'GPT-4o', eval_method: 'semantic', eval_prompt: '# 角色\n你是语义相似度评估专家...', is_active: true, is_default: false, score_min: 0, score_max: 1, pass_threshold: 0.7, create_time: '2025-12-08T10:00:00Z' },
|
||||
{ id: 4, name: 'BLEU-4 相似度', type: 'text_similarity', description: '使用 BLEU-4 评估文本相似度', is_active: true, is_default: false, eval_method: ['bleu_4'], bleu_n: 4, output_precision: 3, create_time: '2025-12-10T11:00:00Z' },
|
||||
{ id: 5, name: 'ROUGE 多指标', type: 'text_similarity', description: 'ROUGE-1/2/4 多指标评估', is_active: false, is_default: false, eval_method: ['rouge_1', 'rouge_2', 'rouge_4'], bleu_n: 1, output_precision: 3, create_time: '2025-12-12T13:00:00Z' },
|
||||
]
|
||||
|
||||
// ============ 日志 ============
|
||||
export const mockLogFiles: LogFile[] = [
|
||||
{ file: 'system-2026-02-15.log', name: '系统日志-2026-02-15', size: '2.3 MB' },
|
||||
{ file: 'error-2026-02-15.log', name: '错误日志-2026-02-15', size: '156 KB' },
|
||||
{ file: 'system-2026-02-14.log', name: '系统日志-2026-02-14', size: '3.1 MB' },
|
||||
]
|
||||
|
||||
export const mockTrainingLogFiles: TrainingLogFile[] = [
|
||||
{ file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' },
|
||||
{ file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' },
|
||||
{ file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' },
|
||||
]
|
||||
|
||||
const fakeLogLines = [
|
||||
"[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b",
|
||||
"[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)",
|
||||
"[2026-02-15 08:30:15] INFO: Training started with batch_size=8, learning_rate=2e-5",
|
||||
"[2026-02-15 08:32:45] INFO: {'loss': 2.341, 'grad_norm': 1.234, 'learning_rate': 1.95e-05, 'epoch': 0.05}",
|
||||
"[2026-02-15 08:34:15] INFO: {'loss': 1.892, 'grad_norm': 0.987, 'learning_rate': 1.88e-05, 'epoch': 0.10}",
|
||||
"[2026-02-15 08:35:42] INFO: {'loss': 1.543, 'grad_norm': 0.876, 'learning_rate': 1.79e-05, 'epoch': 0.15}",
|
||||
"[2026-02-15 08:37:10] INFO: {'loss': 1.287, 'grad_norm': 0.765, 'learning_rate': 1.70e-05, 'epoch': 0.20}",
|
||||
"[2026-02-15 08:38:55] INFO: {'loss': 1.056, 'grad_norm': 0.654, 'learning_rate': 1.60e-05, 'epoch': 0.25}",
|
||||
"[2026-02-15 08:40:30] WARN: Gradient norm exceeds threshold (0.654 > 0.5)",
|
||||
"[2026-02-15 08:42:00] INFO: {'loss': 0.892, 'grad_norm': 0.543, 'learning_rate': 1.50e-05, 'epoch': 0.30}",
|
||||
"[2026-02-15 08:45:15] INFO: Saved checkpoint to /data/checkpoints/finance-sft-001-step-100",
|
||||
"[2026-02-15 08:46:30] INFO: {'loss': 0.754, 'grad_norm': 0.432, 'learning_rate': 1.40e-05, 'epoch': 0.35}",
|
||||
"[2026-02-15 08:48:00] INFO: {'loss': 0.623, 'grad_norm': 0.398, 'learning_rate': 1.30e-05, 'epoch': 0.40}",
|
||||
"[2026-02-15 08:50:15] INFO: {'loss': 0.512, 'grad_norm': 0.345, 'learning_rate': 1.20e-05, 'epoch': 0.45}",
|
||||
"[2026-02-15 08:52:30] ERROR: Failed to save model: No space left on device",
|
||||
"[2026-02-15 08:52:31] INFO: Retrying save with compressed format...",
|
||||
"[2026-02-15 08:53:00] INFO: Model saved successfully (size: 14.2 GB)",
|
||||
"[2026-02-15 08:55:00] INFO: {'loss': 0.421, 'grad_norm': 0.298, 'learning_rate': 1.10e-05, 'epoch': 0.50}",
|
||||
"[2026-02-15 08:57:30] INFO: {'loss': 0.356, 'grad_norm': 0.256, 'learning_rate': 1.00e-05, 'epoch': 0.55}",
|
||||
"[2026-02-15 09:00:00] INFO: Training completed successfully",
|
||||
"",
|
||||
"***** train metrics *****",
|
||||
" epoch = 1.0",
|
||||
" total_flos = 1234567890",
|
||||
" train_loss = 0.342",
|
||||
" train_runtime = 1785.2",
|
||||
" train_samples_per_second = 4.79",
|
||||
" train_steps_per_second = 0.60",
|
||||
"***** train metrics end *****",
|
||||
]
|
||||
|
||||
export const mockLogContent: LogContent = {
|
||||
file: 'system-2026-02-15.log',
|
||||
size: '2.3 MB',
|
||||
content: fakeLogLines.join('\n'),
|
||||
}
|
||||
24
frontend/src/plugins/echarts.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* ECharts 按需引入
|
||||
* 仅注册训练曲线所需的模块,避免引入全量包以减小体积
|
||||
*/
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
MarkLineComponent,
|
||||
} from 'echarts/components'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
MarkLineComponent,
|
||||
])
|
||||
239
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/fine-tune',
|
||||
children: [
|
||||
// 服务看板
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/components/PlaceholderView.vue'),
|
||||
meta: { title: '服务看板' },
|
||||
},
|
||||
// 模型调优
|
||||
{
|
||||
path: 'fine-tune',
|
||||
name: 'fine-tune',
|
||||
component: () => import('@/views/fine-tune/FineTuneListView.vue'),
|
||||
meta: { title: '模型微调', pageSurface: 'self' },
|
||||
},
|
||||
{
|
||||
path: 'fine-tune/create',
|
||||
name: 'fine-tune-create',
|
||||
component: () => import('@/views/fine-tune/FineTuneCreateView.vue'),
|
||||
meta: { title: '创建训练任务' },
|
||||
},
|
||||
// 模型评测
|
||||
{
|
||||
path: 'model-eval',
|
||||
name: 'model-eval',
|
||||
component: () => import('@/views/eval/EvalView.vue'),
|
||||
meta: { title: '模型评测', pageSurface: 'self' },
|
||||
},
|
||||
{
|
||||
path: 'model-eval/create',
|
||||
name: 'model-eval-create',
|
||||
component: () => import('@/views/eval/EvalCreateView.vue'),
|
||||
meta: { title: '新建评测' },
|
||||
},
|
||||
{
|
||||
path: 'model-eval/dimension/create',
|
||||
name: 'dimension-create',
|
||||
component: () => import('@/views/eval/DimensionCreateView.vue'),
|
||||
meta: { title: '添加评测维度' },
|
||||
},
|
||||
{
|
||||
path: 'model-eval/dimension/:id/edit',
|
||||
name: 'dimension-edit',
|
||||
component: () => import('@/views/eval/DimensionCreateView.vue'),
|
||||
meta: { title: '编辑评测维度' },
|
||||
},
|
||||
// 模型推理
|
||||
{
|
||||
path: 'model-inference',
|
||||
name: 'model-inference',
|
||||
component: () => import('@/views/inference/InferenceListView.vue'),
|
||||
meta: { title: '模型推理', pageSurface: 'self' },
|
||||
},
|
||||
{
|
||||
path: 'model-inference/create',
|
||||
name: 'model-inference-create',
|
||||
component: () => import('@/views/inference/InferenceCreateView.vue'),
|
||||
meta: { title: '新建推理' },
|
||||
},
|
||||
{
|
||||
path: 'model-inference/chat/:id',
|
||||
name: 'inference-chat',
|
||||
component: () => import('@/views/inference/InferenceChatView.vue'),
|
||||
meta: { title: '模型对话', pageSurface: 'self' },
|
||||
},
|
||||
// 模型对比
|
||||
{
|
||||
path: 'model-compare/chat/:id',
|
||||
name: 'compare-chat',
|
||||
component: () => import('@/views/compare/CompareChatView.vue'),
|
||||
meta: { title: '模型对比' },
|
||||
},
|
||||
{
|
||||
path: 'model-compare/result',
|
||||
name: 'compare-result',
|
||||
component: () => import('@/views/compare/CompareResultView.vue'),
|
||||
meta: { title: '对比结果' },
|
||||
},
|
||||
// 模型管理
|
||||
{
|
||||
path: 'model-manage',
|
||||
name: 'model-manage',
|
||||
component: () => import('@/views/model/ModelManageView.vue'),
|
||||
meta: { title: '模型管理', pageSurface: 'self' },
|
||||
},
|
||||
{
|
||||
path: 'model-manage/create',
|
||||
name: 'model-manage-create',
|
||||
component: () => import('@/views/model/ModelCreateView.vue'),
|
||||
meta: { title: '添加模型' },
|
||||
},
|
||||
{
|
||||
path: 'model-manage/:id/edit',
|
||||
name: 'model-manage-edit',
|
||||
component: () => import('@/views/model/ModelCreateView.vue'),
|
||||
meta: { title: '编辑模型' },
|
||||
},
|
||||
{
|
||||
path: 'model-manage/merge',
|
||||
name: 'merge-weights',
|
||||
component: () => import('@/views/model/MergeWeightsView.vue'),
|
||||
meta: { title: '合并权重' },
|
||||
},
|
||||
// 数据处理
|
||||
{
|
||||
path: 'data-process',
|
||||
name: 'data-process',
|
||||
component: () => import('@/views/data-process/DataProcessListView.vue'),
|
||||
meta: { title: '数据处理', pageSurface: 'self' },
|
||||
},
|
||||
{
|
||||
path: 'data-process/create',
|
||||
name: 'data-process-create',
|
||||
component: () => import('@/views/data-process/DataProcessCreateView.vue'),
|
||||
meta: { title: '新建数据处理任务', pageSurface: 'self' },
|
||||
},
|
||||
// 数据集管理
|
||||
{
|
||||
path: 'dataset',
|
||||
name: 'dataset',
|
||||
component: () => import('@/views/dataset/DatasetListView.vue'),
|
||||
meta: { title: '数据集管理', pageSurface: 'self' },
|
||||
},
|
||||
{
|
||||
path: 'dataset/create',
|
||||
name: 'dataset-create',
|
||||
component: () => import('@/views/dataset/DatasetCreateView.vue'),
|
||||
meta: { title: '上传数据集' },
|
||||
},
|
||||
{
|
||||
path: 'dataset/:id/edit',
|
||||
name: 'dataset-edit',
|
||||
component: () => import('@/views/dataset/DatasetCreateView.vue'),
|
||||
meta: { title: '编辑数据集' },
|
||||
},
|
||||
{
|
||||
path: 'dataset/:id/preview',
|
||||
name: 'dataset-preview',
|
||||
component: () => import('@/views/dataset/DatasetPreviewView.vue'),
|
||||
meta: { title: '数据集预览' },
|
||||
},
|
||||
// 其他工具
|
||||
{
|
||||
path: 'tools',
|
||||
name: 'tools',
|
||||
component: () => import('@/views/tools/ToolsView.vue'),
|
||||
meta: { title: '其他工具' },
|
||||
},
|
||||
{
|
||||
path: 'tools/create',
|
||||
name: 'tools-create',
|
||||
component: () => import('@/views/tools/ToolCreateView.vue'),
|
||||
meta: { title: '添加自定义工具' },
|
||||
},
|
||||
{
|
||||
path: 'tools/:id/edit',
|
||||
name: 'tools-edit',
|
||||
component: () => import('@/views/tools/ToolCreateView.vue'),
|
||||
meta: { title: '编辑自定义工具' },
|
||||
},
|
||||
// 数据类型转换
|
||||
{
|
||||
path: 'data-convert',
|
||||
name: 'data-convert',
|
||||
component: () => import('@/components/PlaceholderView.vue'),
|
||||
meta: { title: '数据类型转换' },
|
||||
},
|
||||
// 系统设置
|
||||
{
|
||||
path: 'hardware',
|
||||
name: 'hardware',
|
||||
component: () => import('@/views/system/HardwareView.vue'),
|
||||
meta: { title: '平台性能' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
component: () => import('@/views/system/LogsView.vue'),
|
||||
meta: { title: '查看日志' },
|
||||
},
|
||||
{
|
||||
path: 'training-log/:id',
|
||||
name: 'training-log',
|
||||
component: () => import('@/views/system/TrainingLogView.vue'),
|
||||
meta: { title: '训练日志' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/fine-tune',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 全局守卫:登录校验 + 会话超时
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
document.title = to.meta.title ? `${to.meta.title} - 远光软件微调平台` : '远光软件微调平台'
|
||||
|
||||
if (to.meta.public) {
|
||||
// 已登录访问登录页则跳转主页
|
||||
if (to.name === 'login' && auth.isLoggedIn) {
|
||||
next('/fine-tune')
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (!auth.isLoggedIn) {
|
||||
next({ name: 'login' })
|
||||
return
|
||||
}
|
||||
|
||||
// 续期会话
|
||||
auth.refresh()
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
45
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi } from '@/api/modules/system'
|
||||
import { SESSION_TIMEOUT } from '@/constants'
|
||||
|
||||
/**
|
||||
* 认证 store
|
||||
* 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const username = ref<string>(localStorage.getItem('username') || '')
|
||||
const loginTime = ref<number>(parseInt(localStorage.getItem('loginTime') || '0', 10) || 0)
|
||||
|
||||
const isLoggedIn = computed(() => {
|
||||
if (!loginTime.value) return false
|
||||
return Date.now() - loginTime.value < SESSION_TIMEOUT
|
||||
})
|
||||
|
||||
/** 登录 */
|
||||
async function login(user: string, password: string) {
|
||||
await loginApi(user, password)
|
||||
username.value = user
|
||||
loginTime.value = Date.now()
|
||||
localStorage.setItem('username', user)
|
||||
localStorage.setItem('loginTime', String(loginTime.value))
|
||||
}
|
||||
|
||||
/** 续期会话(活跃时刷新) */
|
||||
function refresh() {
|
||||
if (isLoggedIn.value) {
|
||||
loginTime.value = Date.now()
|
||||
localStorage.setItem('loginTime', String(loginTime.value))
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出 */
|
||||
function logout() {
|
||||
username.value = ''
|
||||
loginTime.value = 0
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('loginTime')
|
||||
}
|
||||
|
||||
return { username, loginTime, isLoggedIn, login, refresh, logout }
|
||||
})
|
||||
34
frontend/src/stores/models.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getModelList } from '@/api/modules/model'
|
||||
import type { ModelItem } from '@/types'
|
||||
|
||||
/**
|
||||
* 模型列表缓存 store
|
||||
* 列表页根据 base_model id 渲染模型名时使用
|
||||
*/
|
||||
export const useModelsStore = defineStore('models', () => {
|
||||
const list = ref<ModelItem[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
async function load(force = false) {
|
||||
if (loaded.value && !force) return
|
||||
try {
|
||||
list.value = (await getModelList()) || []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
list.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据 id 获取模型名 */
|
||||
function getModelName(modelId: string | number): string {
|
||||
if (!modelId) return '-'
|
||||
const model = list.value.find(
|
||||
(m) => m.id == modelId || m.id === String(modelId) || m.id === Number(modelId),
|
||||
)
|
||||
return model ? model.name : `模型${modelId}`
|
||||
}
|
||||
|
||||
return { list, loaded, load, getModelName }
|
||||
})
|
||||
36
frontend/src/stores/system.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getHealth } from '@/api/modules/system'
|
||||
import type { HealthMetrics } from '@/types'
|
||||
|
||||
/**
|
||||
* 顶部栏系统监控 store
|
||||
* 30s 轮询 CPU/内存/磁盘使用率
|
||||
*/
|
||||
export const useSystemStore = defineStore('system', () => {
|
||||
const metrics = ref<HealthMetrics>({})
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchMetrics() {
|
||||
try {
|
||||
metrics.value = await getHealth()
|
||||
} catch {
|
||||
// 静默失败,顶部栏非关键
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer) return
|
||||
fetchMetrics()
|
||||
timer = setInterval(fetchMetrics, 30000)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
return { metrics, fetchMetrics, start, stop }
|
||||
})
|
||||
48
frontend/src/stores/tools.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { CustomTool } from '@/types'
|
||||
|
||||
/**
|
||||
* 自定义工具 store(localStorage 持久化)
|
||||
* 原 web 项目 customTools 仅存本地,无后端
|
||||
*/
|
||||
export const useToolsStore = defineStore('tools', () => {
|
||||
const STORAGE_KEY = 'customTools'
|
||||
const tools = ref<CustomTool[]>(loadFromStorage())
|
||||
|
||||
function loadFromStorage(): CustomTool[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 持久化
|
||||
watch(
|
||||
tools,
|
||||
(val) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function addTool(tool: CustomTool) {
|
||||
tools.value.push(tool)
|
||||
}
|
||||
|
||||
function updateTool(id: string, data: Partial<CustomTool>) {
|
||||
const idx = tools.value.findIndex((t) => t.id === id)
|
||||
if (idx !== -1) tools.value[idx] = { ...tools.value[idx], ...data }
|
||||
}
|
||||
|
||||
function removeTool(id: string) {
|
||||
tools.value = tools.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
function getTool(id: string): CustomTool | undefined {
|
||||
return tools.value.find((t) => t.id === id)
|
||||
}
|
||||
|
||||
return { tools, addTool, updateTool, removeTool, getTool }
|
||||
})
|
||||
190
frontend/src/styles/index.scss
Normal file
@@ -0,0 +1,190 @@
|
||||
/* Element Plus 主题变量覆盖:使用原生 CSS 变量替代 SCSS 编译以解决加载卡顿和 FOUC 问题 */
|
||||
:root {
|
||||
/* 全局页面层级:灰色仅作为外层留白,业务页面统一使用白色画布 */
|
||||
--app-shell-bg: #f3f5f8;
|
||||
--app-page-bg: #ffffff;
|
||||
--app-surface-bg: #ffffff;
|
||||
--el-bg-color-page: var(--app-shell-bg);
|
||||
|
||||
/* Element Plus 基础颜色覆盖 */
|
||||
--el-color-primary: #4f46e5;
|
||||
--el-color-primary-light-3: #818cf8;
|
||||
--el-color-primary-light-5: #a5b4fc;
|
||||
--el-color-primary-light-7: #c7d2fe;
|
||||
--el-color-primary-light-8: #e0e7ff;
|
||||
--el-color-primary-light-9: #eef2ff;
|
||||
--el-color-primary-dark-2: #4338ca;
|
||||
|
||||
--el-color-success: #10b981;
|
||||
--el-color-warning: #f59e0b;
|
||||
--el-color-danger: #ef4444;
|
||||
--el-color-info: #64748b;
|
||||
|
||||
/* Element Plus 圆角覆盖 */
|
||||
--el-border-radius-base: 8px;
|
||||
--el-border-radius-small: 6px;
|
||||
--el-border-radius-round: 20px;
|
||||
|
||||
/* 项目自定义 CSS 变量 */
|
||||
--primary-color: var(--el-color-primary);
|
||||
--primary-hover: var(--el-color-primary-light-3);
|
||||
--danger-color: var(--el-color-danger);
|
||||
--success-color: var(--el-color-success);
|
||||
--sidebar-bg: #f9fafb; /* Light Gray */
|
||||
--sidebar-text: #606266; /* Regular Text */
|
||||
--sidebar-active-bg: #f1f5f9; /* Active Item Bg */
|
||||
--sidebar-active-text: #303133; /* Darker Text for Active */
|
||||
--card-shadow: 0 4px 24px rgba(0, 0, 0, 0.04);
|
||||
--header-height: 60px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'PingFang SC', sans-serif;
|
||||
background-color: var(--app-shell-bg);
|
||||
color: var(--el-text-color-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.4);
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 覆盖 Element Plus 的一些默认样式,使其更高级 */
|
||||
|
||||
/* 1. 卡片 (el-card) 无边框,柔和阴影 */
|
||||
.el-card {
|
||||
background-color: var(--app-surface-bg);
|
||||
border: none !important;
|
||||
box-shadow: var(--card-shadow) !important;
|
||||
border-radius: 12px !important;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* 2. 表格 (el-table) 现代化 */
|
||||
.el-table {
|
||||
--el-table-border-color: #f1f5f9;
|
||||
--el-table-header-bg-color: #f8fafc;
|
||||
--el-table-header-text-color: #475569;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600 !important;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--el-table-border-color) !important;
|
||||
}
|
||||
.el-table td.el-table__cell {
|
||||
padding: 12px 0 !important;
|
||||
}
|
||||
.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
.el-table::before {
|
||||
display: none; /* 移除底部基础边框 */
|
||||
}
|
||||
|
||||
/* 3. 按钮动效 */
|
||||
.el-button {
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.el-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.el-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.el-button.is-link:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 4. 标签 (el-tag) 圆润化 */
|
||||
.el-tag {
|
||||
border-radius: 6px;
|
||||
border: none !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
.el-tag--primary {
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
color: #4f46e5;
|
||||
}
|
||||
.el-tag--success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
.el-tag--warning {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
.el-tag--danger {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
.el-tag--info {
|
||||
background-color: rgba(100, 116, 139, 0.1);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* 5. 分页组件 */
|
||||
.el-pagination {
|
||||
--el-pagination-bg-color: #ffffff;
|
||||
--el-pagination-hover-color: var(--primary-color);
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.el-pagination.is-background .el-pager li {
|
||||
border-radius: 6px;
|
||||
background-color: #f1f5f9;
|
||||
border: none;
|
||||
}
|
||||
.el-pagination.is-background .el-pager li.is-active {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
/* 6. 模态框 (Dialog) */
|
||||
.el-dialog {
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.el-dialog__header {
|
||||
margin-right: 0 !important;
|
||||
padding-bottom: 16px !important;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.el-dialog__title {
|
||||
font-weight: 600 !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
260
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 全局业务类型定义
|
||||
*/
|
||||
|
||||
/** 通用分页/列表响应暂用数组,后端未分页 */
|
||||
|
||||
// ============ 模型管理 ============
|
||||
export type ModelType = 'LLM' | 'CV' | 'NLP' | 'Embedding' | 'Other'
|
||||
export type ModelPurpose = 'training' | 'inference' | 'evaluation'
|
||||
export type ModelSource = 'local' | 'api' | 'online'
|
||||
|
||||
export interface TrainMethod {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 数据库配置模型 */
|
||||
export interface ModelItem {
|
||||
id: number | string
|
||||
name: string
|
||||
type: ModelType | string
|
||||
purpose: ModelPurpose | string
|
||||
model_source: ModelSource | string
|
||||
description?: string
|
||||
path?: string
|
||||
api_url?: string
|
||||
api_key?: string
|
||||
online_model_name?: string
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
/** 已训练模型 */
|
||||
export interface TrainedModel {
|
||||
id: number | string
|
||||
name: string
|
||||
train_methods?: TrainMethod[]
|
||||
base_model_path?: string
|
||||
create_time?: string
|
||||
merged?: boolean
|
||||
merging?: boolean
|
||||
merged_path?: string
|
||||
}
|
||||
|
||||
/** 创建/编辑模型请求体 */
|
||||
export interface ModelForm {
|
||||
id?: number | string
|
||||
name: string
|
||||
type: ModelType | string
|
||||
purpose: ModelPurpose | string
|
||||
model_source: ModelSource | string
|
||||
description?: string
|
||||
path?: string
|
||||
api_url?: string
|
||||
api_key?: string
|
||||
online_model_name?: string
|
||||
}
|
||||
|
||||
// ============ 数据集 ============
|
||||
export type DatasetType = 'train' | 'test' | 'eval' | 'val' | 'other'
|
||||
export type DatasetStorage = 'local' | 'cloud' | 'minio'
|
||||
|
||||
export interface DatasetFile {
|
||||
id?: string
|
||||
name: string
|
||||
size?: string | number
|
||||
[k: string]: any
|
||||
}
|
||||
|
||||
export interface DatasetItem {
|
||||
id: number | string
|
||||
name: string
|
||||
type: DatasetType | string
|
||||
storage_type: DatasetStorage | string
|
||||
size?: string | number
|
||||
count?: number
|
||||
description?: string
|
||||
create_time?: string
|
||||
files?: DatasetFile[]
|
||||
}
|
||||
|
||||
// ============ 训练任务 ============
|
||||
export type TrainType = 'SFT' | 'DPO' | 'CPT'
|
||||
export type TrainMethodType = 'lora' | 'full'
|
||||
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | string
|
||||
|
||||
export interface FineTuneTask {
|
||||
id: number | string
|
||||
name: string
|
||||
description?: string
|
||||
status: TaskStatus
|
||||
train_type: TrainType | string
|
||||
train_method?: TrainMethodType | string
|
||||
template?: string
|
||||
base_model: number | string
|
||||
train_dataset_id?: number | string
|
||||
output_model_name?: string
|
||||
gpus?: number[]
|
||||
batch_size?: number
|
||||
learning_rate?: number
|
||||
n_epochs?: number
|
||||
save_steps?: number
|
||||
lr_scheduler_type?: string
|
||||
max_length?: number
|
||||
warmup_ratio?: number
|
||||
weight_decay?: number
|
||||
lora_alpha?: number
|
||||
lora_dropout?: number
|
||||
lora_rank?: number
|
||||
progress?: number
|
||||
process_id?: number
|
||||
train_duration?: string
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface TrainingProgress {
|
||||
status?: string
|
||||
progress?: number
|
||||
step?: string
|
||||
speed?: string
|
||||
eta?: string
|
||||
}
|
||||
|
||||
// ============ 模型推理 / 对比 (model-compare) ============
|
||||
export interface LoadedModel {
|
||||
model_id?: number | string
|
||||
model_name?: string
|
||||
status?: string
|
||||
pid?: number
|
||||
port?: number
|
||||
}
|
||||
|
||||
export interface CompareTask {
|
||||
id: number | string
|
||||
model_name?: string
|
||||
name?: string
|
||||
description?: string
|
||||
status?: string
|
||||
models?: CompareModelRef[] | string
|
||||
load_status?: string | { loaded_models: LoadedModel[] }
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface CompareModelRef {
|
||||
model_id: number | string
|
||||
model_name: string
|
||||
model_path: string
|
||||
gpu_id: number
|
||||
source?: string
|
||||
port?: number
|
||||
}
|
||||
|
||||
// ============ 模型评测 ============
|
||||
export type EvalType = 'custom' | 'baseline'
|
||||
export type DimensionType = 'classification' | 'metric' | 'text_similarity'
|
||||
|
||||
export interface EvalTask {
|
||||
id: number | string
|
||||
eval_task_name?: string
|
||||
model_name?: string
|
||||
model_id?: number | string
|
||||
dataset?: string
|
||||
metric?: string
|
||||
score?: number
|
||||
status?: string
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface Dimension {
|
||||
id: number | string
|
||||
name: string
|
||||
type: DimensionType | string
|
||||
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
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
// ============ 系统监控 ============
|
||||
export interface GpuInfo {
|
||||
name: string
|
||||
gpu_percent: number
|
||||
memory_used_gb: number
|
||||
memory_total_gb: number
|
||||
temperature: number
|
||||
power_w: number
|
||||
fan_speed?: number
|
||||
clock_mhz?: number
|
||||
driver_version?: string
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
cpu?: {
|
||||
percent: number
|
||||
cores: number
|
||||
percents?: number[]
|
||||
}
|
||||
memory?: {
|
||||
used_gb: number
|
||||
total_gb: number
|
||||
percent: number
|
||||
available_gb: number
|
||||
cached_gb: number
|
||||
}
|
||||
disk?: {
|
||||
used_gb: number
|
||||
total_gb: number
|
||||
percent: number
|
||||
}
|
||||
gpu?: GpuInfo[]
|
||||
network?: {
|
||||
download_mb?: number
|
||||
upload_mb?: number
|
||||
}
|
||||
system?: {
|
||||
uptime_seconds?: number
|
||||
process_count?: number
|
||||
os?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HealthMetrics {
|
||||
cpu_percent?: number
|
||||
memory_percent?: number
|
||||
disk_percent?: number
|
||||
}
|
||||
|
||||
// ============ 日志 ============
|
||||
export interface LogFile {
|
||||
file: string
|
||||
name: string
|
||||
size: string
|
||||
pid?: number
|
||||
date?: string
|
||||
}
|
||||
|
||||
export interface LogContent {
|
||||
content: string
|
||||
file: string
|
||||
size: string
|
||||
}
|
||||
|
||||
export interface TrainingLogFile extends LogFile {
|
||||
pid?: number
|
||||
}
|
||||
|
||||
// ============ 其他工具 ============
|
||||
export interface CustomTool {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
icon: string
|
||||
}
|
||||
145
frontend/src/views/compare/CompareChatView.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { getCompare } from '@/api/modules/compare'
|
||||
import type { CompareTask, LoadedModel } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const taskId = route.params.id as string
|
||||
|
||||
const task = ref<CompareTask | null>(null)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const form = reactive({
|
||||
systemPrompt: '',
|
||||
question: '',
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
maxTokens: 2048,
|
||||
})
|
||||
|
||||
const loadedModels = computed<LoadedModel[]>(() => {
|
||||
if (!task.value?.load_status) return []
|
||||
try {
|
||||
const ls =
|
||||
typeof task.value.load_status === 'string'
|
||||
? JSON.parse(task.value.load_status)
|
||||
: task.value.load_status
|
||||
return ls.loaded_models || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const allReady = computed(
|
||||
() => loadedModels.value.length > 0 && loadedModels.value.every((m) => m.status === 'ready' || m.status === 'running'),
|
||||
)
|
||||
|
||||
const isStarting = computed(() => loadedModels.value.some((m) => m.status === 'starting'))
|
||||
|
||||
async function loadTask() {
|
||||
try {
|
||||
task.value = await getCompare(taskId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!form.question.trim()) {
|
||||
ElMessage.warning('请输入问题')
|
||||
return
|
||||
}
|
||||
if (isStarting.value) {
|
||||
ElMessage.warning('模型仍在启动中,请稍候')
|
||||
return
|
||||
}
|
||||
// 在新窗口打开结果页(原项目用 window.open)
|
||||
const params = new URLSearchParams({
|
||||
taskId,
|
||||
taskName: task.value?.model_name || task.value?.name || '',
|
||||
question: form.question,
|
||||
systemPrompt: form.systemPrompt,
|
||||
temperature: String(form.temperature),
|
||||
topP: String(form.topP),
|
||||
topK: String(form.topK),
|
||||
maxTokens: String(form.maxTokens),
|
||||
})
|
||||
const url = router.resolve(`/model-compare/result?${params.toString()}`).href
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTask()
|
||||
pollTimer = setInterval(loadTask, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageCard title="模型对比配置">
|
||||
<!-- 已启动模型 -->
|
||||
<el-divider content-position="left">已启动模型</el-divider>
|
||||
<div class="model-list">
|
||||
<el-tag
|
||||
v-for="(m, idx) in loadedModels"
|
||||
:key="idx"
|
||||
:type="m.status === 'ready' || m.status === 'running' ? 'success' : m.status === 'starting' ? 'warning' : 'danger'"
|
||||
size="large"
|
||||
>
|
||||
{{ m.model_name }} ({{ m.status }})
|
||||
</el-tag>
|
||||
<span v-if="!loadedModels.length" class="empty-hint">暂无已启动模型</span>
|
||||
</div>
|
||||
|
||||
<!-- 输入配置 -->
|
||||
<el-divider content-position="left">对话配置</el-divider>
|
||||
<el-form label-width="120px" style="max-width: 700px">
|
||||
<el-form-item label="系统提示词">
|
||||
<el-input v-model="form.systemPrompt" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="问题">
|
||||
<el-input v-model="form.question" type="textarea" :rows="4" placeholder="请输入要对比的问题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Temperature">
|
||||
<el-slider v-model="form.temperature" :min="0" :max="2" :step="0.1" show-input style="max-width: 500px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Top-p">
|
||||
<el-slider v-model="form.topP" :min="0" :max="1" :step="0.05" show-input style="max-width: 500px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Top-k">
|
||||
<el-slider v-model="form.topK" :min="1" :max="100" :step="1" show-input style="max-width: 500px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Max Tokens">
|
||||
<el-slider v-model="form.maxTokens" :min="256" :max="4096" :step="128" show-input style="max-width: 500px" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :disabled="!allReady || isStarting" @click="handleSubmit">
|
||||
开始对比
|
||||
</el-button>
|
||||
<el-button @click="router.back()">返回</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty-hint {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
225
frontend/src/views/compare/CompareResultView.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MarkdownView from '@/components/MarkdownView.vue'
|
||||
import {
|
||||
getCompare,
|
||||
chatWithPort,
|
||||
batchChat,
|
||||
} from '@/api/modules/compare'
|
||||
import { getModelByName } from '@/api/modules/model'
|
||||
import type { CompareTask, LoadedModel } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const taskId = route.query.taskId as string
|
||||
const question = decodeURIComponent(route.query.question as string || '')
|
||||
const systemPrompt = decodeURIComponent(route.query.systemPrompt as string || '')
|
||||
const temperature = Number(route.query.temperature || 0.7)
|
||||
const topP = Number(route.query.topP || 0.9)
|
||||
const topK = Number(route.query.topK || 40)
|
||||
const maxTokens = Number(route.query.maxTokens || 2048)
|
||||
|
||||
const taskName = route.query.taskName as string
|
||||
|
||||
interface ModelResult {
|
||||
name: string
|
||||
content: string
|
||||
displayContent: string
|
||||
status: 'loading' | 'done' | 'error'
|
||||
stats?: { charsPerSec?: number; totalTime?: number }
|
||||
}
|
||||
|
||||
const results = ref<ModelResult[]>([])
|
||||
const started = ref(false)
|
||||
|
||||
const loadedModels = ref<LoadedModel[]>([])
|
||||
|
||||
async function init() {
|
||||
if (started.value) return
|
||||
started.value = true
|
||||
try {
|
||||
const task: any = await getCompare(taskId)
|
||||
let models: LoadedModel[] = []
|
||||
if (task.load_status) {
|
||||
const ls = typeof task.load_status === 'string' ? JSON.parse(task.load_status) : task.load_status
|
||||
models = ls.loaded_models || []
|
||||
}
|
||||
loadedModels.value = models
|
||||
// 初始化结果占位
|
||||
results.value = models.map((m) => ({
|
||||
name: m.model_name || '模型',
|
||||
content: '',
|
||||
displayContent: '',
|
||||
status: 'loading',
|
||||
}))
|
||||
|
||||
// 并行推理
|
||||
await Promise.all(models.map((m, idx) => inferOne(m, idx)))
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** 单模型推理(带超时) */
|
||||
async function inferOne(model: LoadedModel, idx: number) {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
// 尝试通过端口代理调用
|
||||
const res: any = await Promise.race([
|
||||
chatWithPort({
|
||||
port: model.port,
|
||||
model_name: model.model_name,
|
||||
messages: [
|
||||
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
temperature,
|
||||
top_p: topP,
|
||||
top_k: topK,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('推理超时')), 300000)),
|
||||
])
|
||||
|
||||
const content = res?.response || res?.content || res?.data || JSON.stringify(res)
|
||||
const totalTime = (Date.now() - startTime) / 1000
|
||||
results.value[idx].content = content
|
||||
results.value[idx].status = 'done'
|
||||
results.value[idx].stats = {
|
||||
totalTime,
|
||||
charsPerSec: totalTime > 0 ? (content.length / totalTime).toFixed(1) as unknown as number : 0,
|
||||
}
|
||||
// 模拟打字机效果
|
||||
typewriterDisplay(idx, content)
|
||||
} catch (e: any) {
|
||||
results.value[idx].content = '推理失败: ' + (e.message || '')
|
||||
results.value[idx].status = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/** 打字机效果逐字展示 */
|
||||
function typewriterDisplay(idx: number, content: string) {
|
||||
let pos = 0
|
||||
const interval = setInterval(() => {
|
||||
pos += 2
|
||||
results.value[idx].displayContent = content.slice(0, pos)
|
||||
if (pos >= content.length) {
|
||||
clearInterval(interval)
|
||||
results.value[idx].displayContent = content
|
||||
}
|
||||
}, 20)
|
||||
}
|
||||
|
||||
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compare-result">
|
||||
<div class="result-header">
|
||||
<h2>对比结果{{ taskName ? ` - ${taskName}` : '' }}</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="$router.push('/model-inference')">返回列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 问题 -->
|
||||
<el-alert type="info" :closable="false" show-icon class="question-box">
|
||||
<template #title>
|
||||
<strong>问题:</strong>{{ question }}
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 模型结果网格 -->
|
||||
<div class="result-grid">
|
||||
<el-card v-for="(r, idx) in results" :key="idx" shadow="hover" class="result-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="model-name">{{ r.name }}</span>
|
||||
<el-tag v-if="r.status === 'loading'" type="warning" size="small">生成中...</el-tag>
|
||||
<el-tag v-else-if="r.status === 'done'" type="success" size="small">完成</el-tag>
|
||||
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div>
|
||||
<MarkdownView v-else-if="r.displayContent" :content="r.displayContent" />
|
||||
<div v-else class="loading-text">
|
||||
<i class="fa fa-spinner fa-spin" /> 正在生成回答...
|
||||
</div>
|
||||
|
||||
<div v-if="r.stats" class="result-stats">
|
||||
<span>耗时 {{ r.stats.totalTime?.toFixed(1) }}s</span>
|
||||
<span>速度 {{ r.stats.charsPerSec }} 字/秒</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.compare-result {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.question-box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.model-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.error-text {
|
||||
color: #909399;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
762
frontend/src/views/data-process/DataProcessCreateView.vue
Normal file
@@ -0,0 +1,762 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import TaskSetupStep from './create/TaskSetupStep.vue'
|
||||
import PreviewCompareStep from './create/PreviewCompareStep.vue'
|
||||
import GenerationStep from './create/GenerationStep.vue'
|
||||
import ResultEditorStep from './create/ResultEditorStep.vue'
|
||||
import { buildPreviewItems, createResults, DEFAULT_SOURCE_TEXT } from './create/previewModel'
|
||||
import type { GenerationState, PreviewItem, ProcessType, ResultItem, StepId } from './create/types'
|
||||
|
||||
const router = useRouter()
|
||||
const taskSetupRef = ref<InstanceType<typeof TaskSetupStep>>()
|
||||
const DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
|
||||
|
||||
const WIZARD_STEPS = [
|
||||
{ id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' },
|
||||
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
|
||||
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
|
||||
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
|
||||
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
|
||||
|
||||
const currentStep = ref(0)
|
||||
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')
|
||||
const task = reactive({ name: '', description: '' })
|
||||
const processType = ref<ProcessType>('unstructured')
|
||||
interface UploadedDataFile {
|
||||
uid: number | string
|
||||
name: string
|
||||
size: number
|
||||
count: number
|
||||
content: string
|
||||
}
|
||||
|
||||
const uploadedFiles = ref<UploadedDataFile[]>([])
|
||||
|
||||
const fileName = computed(() => uploadedFiles.value.map(f => f.name).join(', '))
|
||||
const fileSize = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.size, 0))
|
||||
const fileCount = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.count, 0))
|
||||
const previewSignature = ref('')
|
||||
const previewItems = ref<PreviewItem[]>([])
|
||||
const selectedPreviewFileId = ref<string | null>(null)
|
||||
const selectedPreviewId = ref<string | null>(null)
|
||||
const selectedPreviewIdsByFile = ref<Record<string, string>>({})
|
||||
const results = ref<ResultItem[]>([])
|
||||
const selectedResultId = ref<string | null>(null)
|
||||
const dirty = ref(false)
|
||||
const restoringDraft = ref(false)
|
||||
let allowLeave = false
|
||||
|
||||
const generation = reactive<GenerationState>({
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
message: '确认摘要后即可开始生成,过程中可查看实时进度。',
|
||||
})
|
||||
|
||||
let generationTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const modifiedPreviewCount = computed(() => previewItems.value.filter((item) => item.status !== 'original').length)
|
||||
const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value))
|
||||
const activePreviewItems = computed(() => previewItems.value.filter((item) => item.sourceFileId === selectedPreviewFileId.value))
|
||||
const activeSourceText = computed(() => activePreviewFile.value?.content ?? '')
|
||||
const previewFiles = computed(() => uploadedFiles.value.map((file) => {
|
||||
const items = previewItems.value.filter((item) => item.sourceFileId === String(file.uid))
|
||||
return {
|
||||
id: String(file.uid),
|
||||
name: file.name,
|
||||
count: items.length,
|
||||
modifiedCount: items.filter((item) => item.status !== 'original').length,
|
||||
}
|
||||
}))
|
||||
const primaryActionLabel = computed(() => {
|
||||
if (currentStepId.value === 'create') return '继续:数据预览'
|
||||
if (currentStepId.value === 'preview') return '确认预览并继续'
|
||||
if (currentStepId.value === 'results') return '保存任务'
|
||||
if (generation.status === 'running') return '正在生成'
|
||||
if (generation.status === 'success') return '查看生成结果'
|
||||
if (generation.status === 'failed') return '重新生成'
|
||||
return '开始生成'
|
||||
})
|
||||
|
||||
const primaryActionIcon = computed(() => {
|
||||
if (currentStepId.value === 'results') return 'fa-check'
|
||||
if (currentStepId.value === 'generate' && generation.status !== 'success') return 'fa-play'
|
||||
return 'fa-arrow-right'
|
||||
})
|
||||
|
||||
const previousStepLabel = computed(() => currentStep.value > 0
|
||||
? WIZARD_STEPS[currentStep.value - 1].title
|
||||
: '')
|
||||
|
||||
function draftSnapshot() {
|
||||
return {
|
||||
currentStep: currentStep.value,
|
||||
task: { ...task },
|
||||
processType: processType.value,
|
||||
uploadedFiles: uploadedFiles.value,
|
||||
previewSignature: previewSignature.value,
|
||||
previewItems: previewItems.value,
|
||||
selectedPreviewFileId: selectedPreviewFileId.value,
|
||||
selectedPreviewId: selectedPreviewId.value,
|
||||
selectedPreviewIdsByFile: selectedPreviewIdsByFile.value,
|
||||
results: results.value,
|
||||
selectedResultId: selectedResultId.value,
|
||||
generation: { ...generation },
|
||||
}
|
||||
}
|
||||
|
||||
function persistDraft() {
|
||||
if (restoringDraft.value) return
|
||||
try {
|
||||
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draftSnapshot()))
|
||||
} catch {
|
||||
ElMessage.warning('草稿保存失败,请检查浏览器存储空间')
|
||||
}
|
||||
}
|
||||
|
||||
type DraftSnapshot = ReturnType<typeof draftSnapshot> & {
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileCount?: number
|
||||
sourceText?: string
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const snapshot = JSON.parse(raw) as DraftSnapshot
|
||||
if (!snapshot?.uploadedFiles && (!snapshot?.fileName || !snapshot?.sourceText)) return
|
||||
|
||||
restoringDraft.value = true
|
||||
currentStep.value = Math.min(Math.max(Number(snapshot.currentStep) || 0, 0), WIZARD_STEPS.length - 1)
|
||||
task.name = snapshot.task?.name || ''
|
||||
task.description = snapshot.task?.description || ''
|
||||
processType.value = snapshot.processType === 'structured' ? 'structured' : 'unstructured'
|
||||
|
||||
if (snapshot.uploadedFiles) {
|
||||
uploadedFiles.value = Array.isArray(snapshot.uploadedFiles) ? snapshot.uploadedFiles : []
|
||||
} else if (snapshot.fileName && snapshot.sourceText) {
|
||||
// Migrate old draft
|
||||
uploadedFiles.value = [{
|
||||
uid: 'migrated-draft',
|
||||
name: snapshot.fileName,
|
||||
size: Number(snapshot.fileSize) || 0,
|
||||
count: Number(snapshot.fileCount) || 0,
|
||||
content: snapshot.sourceText
|
||||
}]
|
||||
}
|
||||
|
||||
previewSignature.value = snapshot.previewSignature || ''
|
||||
const defaultSourceFileId = String(uploadedFiles.value[0]?.uid ?? '')
|
||||
previewItems.value = Array.isArray(snapshot.previewItems)
|
||||
? snapshot.previewItems.map((item) => ({ ...item, sourceFileId: item.sourceFileId ?? defaultSourceFileId }))
|
||||
: []
|
||||
selectedPreviewFileId.value = snapshot.selectedPreviewFileId || defaultSourceFileId || null
|
||||
selectedPreviewIdsByFile.value = snapshot.selectedPreviewIdsByFile || {}
|
||||
selectedPreviewId.value = snapshot.selectedPreviewId
|
||||
|| selectedPreviewIdsByFile.value[selectedPreviewFileId.value ?? '']
|
||||
|| activePreviewItems.value[0]?.id
|
||||
|| null
|
||||
results.value = Array.isArray(snapshot.results) ? snapshot.results : []
|
||||
selectedResultId.value = snapshot.selectedResultId || results.value[0]?.id || null
|
||||
Object.assign(generation, snapshot.generation || {})
|
||||
dirty.value = false
|
||||
nextTick(() => { restoringDraft.value = false })
|
||||
ElMessage.info('已恢复上次保存的草稿')
|
||||
} catch {
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => task.name, () => task.description, processType],
|
||||
() => {
|
||||
if (!restoringDraft.value) dirty.value = true
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[currentStep, task, processType, uploadedFiles, previewSignature,
|
||||
previewItems, selectedPreviewFileId, selectedPreviewId, selectedPreviewIdsByFile,
|
||||
results, selectedResultId, generation],
|
||||
persistDraft,
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function scrollToStepTop() {
|
||||
document.querySelector<HTMLElement>('.layout-content')?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
watch(currentStep, () => nextTick(scrollToStepTop))
|
||||
|
||||
async function handleFileChange(uploadFile: UploadFile) {
|
||||
const raw = uploadFile.raw
|
||||
if (!raw) return
|
||||
if (raw.size > 200 * 1024 * 1024) {
|
||||
ElMessage.warning('单文件不能超过 200MB')
|
||||
return
|
||||
}
|
||||
|
||||
const extension = raw.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
const textExtensions = ['txt', 'md', 'json', 'jsonl', 'csv']
|
||||
let content = ''
|
||||
if (textExtensions.includes(extension)) {
|
||||
try {
|
||||
content = await raw.text()
|
||||
} catch {
|
||||
content = ''
|
||||
}
|
||||
}
|
||||
|
||||
const fileContent = content.trim() ? content : DEFAULT_SOURCE_TEXT
|
||||
const linesCount = fileContent.split('\n').filter((line) => line.trim()).length
|
||||
|
||||
// Prevent duplicate upload of the same file
|
||||
if (!uploadedFiles.value.some(f => f.name === raw.name && f.size === raw.size)) {
|
||||
uploadedFiles.value.push({
|
||||
uid: uploadFile.uid || Date.now() + Math.random(),
|
||||
name: raw.name,
|
||||
size: raw.size,
|
||||
count: linesCount,
|
||||
content: fileContent
|
||||
})
|
||||
}
|
||||
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function useSampleFile() {
|
||||
uploadedFiles.value = [{
|
||||
uid: 'sample-1',
|
||||
name: 'finance_qa.jsonl',
|
||||
size: 128 * 1024 * 1024,
|
||||
count: DEFAULT_SOURCE_TEXT.split('\n').filter((line) => line.trim()).length,
|
||||
content: DEFAULT_SOURCE_TEXT
|
||||
}]
|
||||
if (!task.name) task.name = '金融问答清洗任务'
|
||||
if (!task.description) task.description = '清洗金融领域问答数据,统一格式并生成高质量训练数据。'
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function handleRemoveFile(uid: string | number) {
|
||||
const index = uploadedFiles.value.findIndex(f => f.uid === uid)
|
||||
if (index > -1) {
|
||||
uploadedFiles.value.splice(index, 1)
|
||||
previewSignature.value = ''
|
||||
previewItems.value = []
|
||||
selectedPreviewId.value = null
|
||||
resetDownstream()
|
||||
dirty.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function resetDownstream() {
|
||||
stopGenerationTimer()
|
||||
generation.status = 'idle'
|
||||
generation.progress = 0
|
||||
generation.message = '确认摘要后即可开始生成,过程中可查看实时进度。'
|
||||
results.value = []
|
||||
selectedResultId.value = null
|
||||
}
|
||||
|
||||
async function nextFromCreate() {
|
||||
const valid = await taskSetupRef.value?.validate()
|
||||
if (!valid) return
|
||||
if (uploadedFiles.value.length === 0) {
|
||||
ElMessage.warning('请上传至少一个源数据文件')
|
||||
return
|
||||
}
|
||||
|
||||
const signature = `${processType.value}:${uploadedFiles.value.map((file) => `${file.uid}:${file.content}`).join('|')}`
|
||||
if (signature !== previewSignature.value) {
|
||||
previewItems.value = uploadedFiles.value.flatMap((file) =>
|
||||
buildPreviewItems(file.content, processType.value, String(file.uid)),
|
||||
)
|
||||
selectedPreviewFileId.value = String(uploadedFiles.value[0]?.uid ?? '') || null
|
||||
selectedPreviewId.value = activePreviewItems.value[0]?.id ?? null
|
||||
selectedPreviewIdsByFile.value = selectedPreviewId.value && selectedPreviewFileId.value
|
||||
? { [selectedPreviewFileId.value]: selectedPreviewId.value }
|
||||
: {}
|
||||
previewSignature.value = signature
|
||||
resetDownstream()
|
||||
}
|
||||
currentStep.value = 1
|
||||
}
|
||||
|
||||
function selectPreviewFile(fileId: string) {
|
||||
if (selectedPreviewFileId.value && selectedPreviewId.value) {
|
||||
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
|
||||
}
|
||||
selectedPreviewFileId.value = fileId
|
||||
selectedPreviewId.value = selectedPreviewIdsByFile.value[fileId]
|
||||
|| activePreviewItems.value[0]?.id
|
||||
|| null
|
||||
}
|
||||
|
||||
function selectPreviewItem(id: string) {
|
||||
selectedPreviewId.value = id
|
||||
if (selectedPreviewFileId.value) selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = id
|
||||
}
|
||||
|
||||
function updatePreviewContent(id: string, value: string) {
|
||||
const item = previewItems.value.find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
item.editedContent = value
|
||||
item.tokenCount = Math.max(1, Math.ceil(value.length / 2))
|
||||
item.status = value === item.originalContent ? 'original' : item.sourceStart == null ? 'manual' : 'modified'
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function restorePreviewItem(id: string) {
|
||||
const item = previewItems.value.find((entry) => entry.id === id)
|
||||
if (!item || item.sourceStart == null) return
|
||||
item.editedContent = item.originalContent
|
||||
item.tokenCount = Math.max(1, Math.ceil(item.originalContent.length / 2))
|
||||
item.status = 'original'
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function addPreviewItem() {
|
||||
if (!selectedPreviewFileId.value) return
|
||||
const id = `manual-${Date.now()}`
|
||||
previewItems.value.push({
|
||||
id,
|
||||
sourceFileId: selectedPreviewFileId.value,
|
||||
originalContent: '',
|
||||
editedContent: '',
|
||||
sourceStart: null,
|
||||
sourceEnd: null,
|
||||
sourceStartLine: null,
|
||||
sourceEndLine: null,
|
||||
tokenCount: 1,
|
||||
status: 'manual',
|
||||
})
|
||||
selectPreviewItem(id)
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
async function removePreviewItem(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('删除只影响本次处理,不会修改源文件。确认删除吗?', '删除预览内容', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const index = previewItems.value.findIndex((item) => item.id === id)
|
||||
if (index < 0) return
|
||||
previewItems.value.splice(index, 1)
|
||||
selectedPreviewId.value = activePreviewItems.value[Math.min(index, activePreviewItems.value.length - 1)]?.id ?? null
|
||||
if (selectedPreviewFileId.value && selectedPreviewId.value) {
|
||||
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
|
||||
}
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function stopGenerationTimer() {
|
||||
if (generationTimer) clearInterval(generationTimer)
|
||||
generationTimer = null
|
||||
}
|
||||
|
||||
function startGeneration() {
|
||||
stopGenerationTimer()
|
||||
generation.status = 'running'
|
||||
generation.progress = 0
|
||||
generation.message = '正在应用预览修改并生成标准化结果,请稍候。'
|
||||
|
||||
generationTimer = setInterval(() => {
|
||||
generation.progress = Math.min(100, generation.progress + 8)
|
||||
if (generation.progress < 100) return
|
||||
|
||||
stopGenerationTimer()
|
||||
generation.status = 'success'
|
||||
generation.message = `已完成 ${previewItems.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
|
||||
results.value = createResults(previewItems.value)
|
||||
selectedResultId.value = results.value[0]?.id ?? null
|
||||
dirty.value = true
|
||||
ElMessage.success('数据处理完成')
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
stopGenerationTimer()
|
||||
generation.status = 'failed'
|
||||
generation.message = '任务已停止,预览修改仍然保留,可以重新生成。'
|
||||
}
|
||||
|
||||
function updateResultField(id: string, field: 'instruction' | 'input' | 'output', value: string) {
|
||||
const item = results.value.find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
item[field] = value
|
||||
const valid = item.instruction.trim() && item.output.trim()
|
||||
item.error = valid ? undefined : 'Instruction 和 Output 不能为空'
|
||||
const changed = item.instruction !== item.originalInstruction
|
||||
|| item.input !== item.originalInput
|
||||
|| item.output !== item.originalOutput
|
||||
item.status = item.error ? 'invalid' : changed ? 'modified' : 'valid'
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function restoreResult(id: string) {
|
||||
const item = results.value.find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
item.instruction = item.originalInstruction
|
||||
item.input = item.originalInput
|
||||
item.output = item.originalOutput
|
||||
item.error = undefined
|
||||
item.status = 'valid'
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function validateResults() {
|
||||
let firstInvalidId: string | null = null
|
||||
for (const item of results.value) {
|
||||
if (!item.instruction.trim() || !item.output.trim()) {
|
||||
item.error = 'Instruction 和 Output 不能为空'
|
||||
item.status = 'invalid'
|
||||
firstInvalidId ??= item.id
|
||||
}
|
||||
}
|
||||
if (firstInvalidId) selectedResultId.value = firstInvalidId
|
||||
return firstInvalidId == null
|
||||
}
|
||||
|
||||
async function handlePrimaryAction() {
|
||||
if (currentStepId.value === 'create') {
|
||||
await nextFromCreate()
|
||||
return
|
||||
}
|
||||
if (currentStepId.value === 'preview') {
|
||||
if (!previewItems.value.length) {
|
||||
ElMessage.warning('当前没有可生成的预览内容')
|
||||
return
|
||||
}
|
||||
currentStep.value = 2
|
||||
return
|
||||
}
|
||||
if (currentStepId.value === 'generate') {
|
||||
if (generation.status === 'success') {
|
||||
currentStep.value = 3
|
||||
} else if (generation.status !== 'running') {
|
||||
startGeneration()
|
||||
}
|
||||
return
|
||||
}
|
||||
await saveTask()
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (generation.status === 'running') {
|
||||
ElMessage.warning('请先停止当前生成任务')
|
||||
return
|
||||
}
|
||||
if (currentStep.value > 0) currentStep.value -= 1
|
||||
}
|
||||
|
||||
|
||||
async function saveTask() {
|
||||
if (!validateResults()) {
|
||||
ElMessage.warning('请先修正校验失败的结果')
|
||||
return
|
||||
}
|
||||
dirty.value = false
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||
allowLeave = true
|
||||
ElMessage.success('数据处理任务已保存')
|
||||
await router.push('/data-process')
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!dirty.value) {
|
||||
allowLeave = true
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('当前存在未保存修改,确定离开吗?', '离开创建任务', {
|
||||
confirmButtonText: '放弃修改',
|
||||
cancelButtonText: '继续编辑',
|
||||
type: 'warning',
|
||||
})
|
||||
allowLeave = true
|
||||
router.back()
|
||||
} catch {
|
||||
// 用户继续编辑。
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (allowLeave || !dirty.value) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
next(window.confirm('当前存在未保存修改,确定离开吗?'))
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopGenerationTimer)
|
||||
onMounted(restoreDraft)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-wizard-layout">
|
||||
<main class="wizard-main">
|
||||
<div class="wizard-main-inner">
|
||||
<div class="wizard-steps-container">
|
||||
<div class="custom-wizard-steps">
|
||||
<div
|
||||
v-for="(step, index) in WIZARD_STEPS"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
:class="{
|
||||
'is-active': currentStep === index,
|
||||
'is-completed': currentStep > index
|
||||
}"
|
||||
>
|
||||
<div v-if="index !== 0" class="step-connector"></div>
|
||||
<div class="step-node">
|
||||
<div class="step-icon">
|
||||
<i v-if="currentStep > index" class="fa fa-check" />
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="step-text">
|
||||
<div class="step-title">{{ step.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-content">
|
||||
<TaskSetupStep
|
||||
v-if="currentStepId === 'create'"
|
||||
ref="taskSetupRef"
|
||||
v-model:name="task.name"
|
||||
v-model:description="task.description"
|
||||
v-model:process-type="processType"
|
||||
:uploaded-files="uploadedFiles"
|
||||
@file-change="handleFileChange"
|
||||
@remove-file="handleRemoveFile"
|
||||
@use-sample="useSampleFile"
|
||||
/>
|
||||
|
||||
<PreviewCompareStep
|
||||
v-else-if="currentStepId === 'preview'"
|
||||
:selected-id="selectedPreviewId"
|
||||
:selected-file-id="selectedPreviewFileId"
|
||||
:source-text="activeSourceText"
|
||||
:items="activePreviewItems"
|
||||
:process-type="processType"
|
||||
:file-name="activePreviewFile?.name ?? ''"
|
||||
:files="previewFiles"
|
||||
@update:selected-id="selectPreviewItem"
|
||||
@update:selected-file-id="selectPreviewFile"
|
||||
@update:item-content="updatePreviewContent"
|
||||
@restore:item="restorePreviewItem"
|
||||
@add:item="addPreviewItem"
|
||||
@remove:item="removePreviewItem"
|
||||
/>
|
||||
|
||||
<GenerationStep
|
||||
v-else-if="currentStepId === 'generate'"
|
||||
:task-name="task.name"
|
||||
:process-type="processType"
|
||||
:file-name="fileName"
|
||||
:preview-count="previewItems.length"
|
||||
:modified-count="modifiedPreviewCount"
|
||||
:generation="generation"
|
||||
@stop="stopGeneration"
|
||||
@retry="startGeneration"
|
||||
/>
|
||||
|
||||
<ResultEditorStep
|
||||
v-else
|
||||
v-model:selected-id="selectedResultId"
|
||||
:items="results"
|
||||
@update:field="updateResultField"
|
||||
@restore:item="restoreResult"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="wizard-footer">
|
||||
<div class="footer-left">
|
||||
<el-button v-if="currentStep > 0" @click="handleBack">
|
||||
<i class="fa fa-arrow-left" style="margin-right: 6px;" /> 返回:{{ previousStepLabel }}
|
||||
</el-button>
|
||||
<el-button v-else @click="handleCancel">取消</el-button>
|
||||
</div>
|
||||
<div class="footer-center">
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<el-button
|
||||
class="wizard-primary-action"
|
||||
type="primary"
|
||||
:loading="generation.status === 'running'"
|
||||
:disabled="currentStepId === 'generate' && generation.status === 'running'"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
{{ primaryActionLabel }} <i class="fa" :class="primaryActionIcon" style="margin-left: 6px;" />
|
||||
</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.create-wizard-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wizard-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
|
||||
/* 自定义滚动条 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-main-inner {
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wizard-steps-container {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.custom-wizard-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
flex: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background-color: #e2e8f0;
|
||||
margin: 0 16px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.step-item.is-completed .step-connector,
|
||||
.step-item.is-active .step-connector {
|
||||
background-color: #5146e5;
|
||||
}
|
||||
|
||||
.step-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #cbd5e1;
|
||||
background-color: #fff;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* State: Active */
|
||||
.step-item.is-active {
|
||||
.step-icon {
|
||||
border-color: #5146e5;
|
||||
background-color: #eef2ff;
|
||||
color: #5146e5;
|
||||
}
|
||||
.step-title {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
/* State: Completed */
|
||||
.step-item.is-completed {
|
||||
.step-icon {
|
||||
background-color: #5146e5;
|
||||
border-color: #5146e5;
|
||||
color: #fff;
|
||||
}
|
||||
.step-title {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
flex-shrink: 0;
|
||||
height: 64px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 0 32px;
|
||||
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.02);
|
||||
|
||||
.footer-left {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-primary-action {
|
||||
min-width: 160px;
|
||||
}
|
||||
</style>
|
||||
211
frontend/src/views/data-process/DataProcessListView.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||
|
||||
/** 数据处理任务类型 */
|
||||
interface DataProcessTask {
|
||||
id: number | string
|
||||
name: string
|
||||
status: string
|
||||
process_type: string
|
||||
source_dataset: string
|
||||
output_dataset?: string
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
// TODO: 接入真实接口前,先用本地 mock 数据
|
||||
const router = useRouter()
|
||||
const dataList = ref<DataProcessTask[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: '客服问答数据清洗',
|
||||
status: 'completed',
|
||||
process_type: '去重清洗',
|
||||
source_dataset: '客服对话原始集',
|
||||
output_dataset: '客服对话清洗集',
|
||||
create_time: '2026-07-08 14:23:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '指令微调数据构造',
|
||||
status: 'running',
|
||||
process_type: '指令构造',
|
||||
source_dataset: '通用语料库',
|
||||
output_dataset: 'SFT 指令集',
|
||||
create_time: '2026-07-09 09:10:00',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '敏感信息脱敏处理',
|
||||
status: 'pending',
|
||||
process_type: '脱敏处理',
|
||||
source_dataset: '用户反馈数据',
|
||||
create_time: '2026-07-09 16:45:00',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '多轮对话拼接',
|
||||
status: 'failed',
|
||||
process_type: '格式转换',
|
||||
source_dataset: '单轮问答集',
|
||||
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() {
|
||||
router.push('/data-process/create')
|
||||
}
|
||||
|
||||
/** 查看任务详情(功能开发中) */
|
||||
function viewDetail(_row: DataProcessTask) {
|
||||
ElMessage.info('查看详情功能开发中...')
|
||||
}
|
||||
|
||||
/** 删除任务(功能开发中) */
|
||||
function handleDelete(_row: DataProcessTask) {
|
||||
ElMessage.info('删除功能开发中...')
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-process-page" style="height: 100%;">
|
||||
<DataTablePage
|
||||
title=""
|
||||
:data="filteredDataList"
|
||||
searchable
|
||||
:search-fields="['name']"
|
||||
create-text="新建数据处理任务"
|
||||
create-to="/data-process/create"
|
||||
row-key="id"
|
||||
: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>
|
||||
<el-table-column label="任务名称" prop="name" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="任务状态" align="center" width="110">
|
||||
<template #default="{ row }">
|
||||
<ModelStatusTag :status="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理类型" align="center" width="140">
|
||||
<template #default="{ row }">{{ row.process_type || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="源数据集" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.source_dataset || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="输出数据集" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.output_dataset || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" align="center" width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" link size="small" @click="viewDetail(row)">
|
||||
<i class="fa fa-file-text-o" style="margin-right: 4px" />详情
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||