新增数据类型转换
This commit is contained in:
65
README.md
65
README.md
@@ -103,15 +103,37 @@ GET /modelTF/model-manage
|
||||
GET /modelTF/dataset-manage
|
||||
GET /modelTF/fine-tune
|
||||
GET /modelTF/compute/nodes
|
||||
GET /modelTF/data-convert
|
||||
```
|
||||
|
||||
本地运行时默认 PostgreSQL 连接:
|
||||
### 数据库配置
|
||||
|
||||
```text
|
||||
DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft
|
||||
后端通过 `backend/.env` 文件配置数据库连接(自动加载,`override=True`):
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql+psycopg://用户:密码@数据库地址:端口/库名
|
||||
COMPUTE_SERVICE_TOKEN=change_me
|
||||
```
|
||||
|
||||
本地启动前需要确保 PostgreSQL 已监听 `localhost:15432`,并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入内置管理员账号,运行数据统一写入 PostgreSQL。
|
||||
支持远程数据库。连接池参数已针对远程库优化(`connect_timeout=30`、`max_size=20`、`max_waiting=50`)。
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft` | 数据库连接串 |
|
||||
| `COMPUTE_SERVICE_TOKEN` | `""` | 算力服务认证 token,需与 compute 一致 |
|
||||
| `COMPUTE_STATUS_SYNC_MODE` | `polling` | `off` 禁用轮询(远程库慢时推荐) |
|
||||
| `COMPUTE_POLL_INTERVAL_SECONDS` | `3` | 轮询间隔秒数 |
|
||||
| `COMPUTE_REQUEST_TIMEOUT_SECONDS` | `5` | 调 compute 的超时秒数 |
|
||||
|
||||
### 推荐启动命令(远程数据库)
|
||||
|
||||
```cmd
|
||||
cd /d E:\yg_ft\backend
|
||||
set COMPUTE_STATUS_SYNC_MODE=off
|
||||
.\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --port 17861
|
||||
```
|
||||
|
||||
开发阶段内置登录账号:
|
||||
|
||||
@@ -138,12 +160,29 @@ npm run dev
|
||||
|
||||
### 方式一:Docker 启动(推荐)
|
||||
|
||||
```bash
|
||||
**1. 构建镜像**(首次或依赖变更后):
|
||||
|
||||
```cmd
|
||||
cd /d E:\yg_ft
|
||||
docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .
|
||||
```
|
||||
|
||||
**2. 启动容器**:
|
||||
|
||||
```cmd
|
||||
cd docker/compute
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**3. 验证**:
|
||||
|
||||
```cmd
|
||||
curl http://localhost:19100/health
|
||||
```
|
||||
|
||||
> 注意:构建上下文必须是项目根目录 `E:\yg_ft`(`docker build` 最后的 `.`),因为 Dockerfile 需要 `COPY compute/requirements.txt`。
|
||||
|
||||
### 方式二:本地开发启动
|
||||
|
||||
**Windows (cmd):**
|
||||
@@ -151,25 +190,35 @@ docker compose up -d
|
||||
```cmd
|
||||
cd /d E:\yg_ft\compute
|
||||
set PYTHONPATH=E:\yg_ft
|
||||
.\.venv\Scripts\python.exe -m uvicorn api.main:app --reload --port 19100
|
||||
.\.venv\Scripts\python.exe -m uvicorn api.main:app --reload --host 0.0.0.0 --port 19100
|
||||
```
|
||||
|
||||
> `PYTHONPATH=E:\yg_ft` 是必需的,因为代码使用 `from compute.agent...` 绝对导入。
|
||||
> `--host 0.0.0.0` 让其他机器可以通过 IP 访问(算力节点测试需要)。
|
||||
|
||||
**Linux / macOS:**
|
||||
|
||||
```bash
|
||||
cd compute
|
||||
PYTHONPATH=.. uvicorn api.main:app --reload --port 19100
|
||||
PYTHONPATH=.. uvicorn api.main:app --reload --host 0.0.0.0 --port 19100
|
||||
```
|
||||
|
||||
### 算力节点配置
|
||||
|
||||
在平台的「算力节点」页面新增节点,填入:
|
||||
- **Compute API**:`http://你的IP:19100`
|
||||
- **File Gateway**:`http://你的IP:19101`
|
||||
|
||||
本机测试用 `http://localhost:19100`。
|
||||
|
||||
### 环境变量说明
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `COMPUTE_MODE` | `real` | `real` / `simulator`,仅隔离联调用 simulator |
|
||||
| `COMPUTE_EXECUTION_MODE` | `real` | 训练执行模式 |
|
||||
| `COMPUTE_SERVICE_TOKEN` | `change_me` | 服务间认证 token |
|
||||
| `COMPUTE_SERVICE_TOKEN` | `change_me` | 服务间认证 token,需与 backend 一致 |
|
||||
| `COMPUTE_AUTH_ENABLED` | `true` | 是否开启 token 认证 |
|
||||
| `MODELTF_ROUTE_PREFIX` | `/modelTF` | API 路由前缀 |
|
||||
|
||||
应用平台通过数据库 `compute_nodes` 表中的 `api_base_url` 和 `file_gateway_url` 主动轮询算力节点状态。
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.modules.approval.router import router as approval_router
|
||||
from app.modules.system.router import router as system_router
|
||||
from app.modules.retention.router import router as retention_router
|
||||
from app.modules.resource.router import router as resource_router
|
||||
from app.modules.data_convert.router import router as data_convert_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health_router, tags=["health"])
|
||||
@@ -20,3 +21,4 @@ api_router.include_router(project_router, tags=["project"])
|
||||
api_router.include_router(approval_router, tags=["approval"])
|
||||
api_router.include_router(retention_router, tags=["retention"])
|
||||
api_router.include_router(resource_router, tags=["resource"])
|
||||
api_router.include_router(data_convert_router, tags=["data-convert"])
|
||||
|
||||
@@ -378,7 +378,7 @@ class PlatformStore:
|
||||
# request (notably expensive against the remote PostgreSQL instance).
|
||||
# TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。
|
||||
pool_kwargs = {
|
||||
"connect_timeout": 5,
|
||||
"connect_timeout": 30,
|
||||
"keepalives": 1,
|
||||
"keepalives_idle": 10,
|
||||
"keepalives_interval": 5,
|
||||
@@ -388,14 +388,14 @@ class PlatformStore:
|
||||
conninfo=self.database_url,
|
||||
kwargs=pool_kwargs,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
max_size=20,
|
||||
# 借出前校验连接可用性,避免执行 SQL 时才发现 [BAD] 再重建。
|
||||
check=ConnectionPool.check_connection,
|
||||
# 不主动回收空闲连接(远程库约 10s 断,由 keepalive 维持),
|
||||
# 减少无谓的重建握手。
|
||||
max_idle=0,
|
||||
# 请求最多排队等待 5s,避免雪崩时无限堆积。
|
||||
max_waiting=16,
|
||||
# 请求最多排队等待,调大以适应远程库慢查询。
|
||||
max_waiting=50,
|
||||
open=False,
|
||||
)
|
||||
# 注意:不要在此调用 pool.wait(),它会阻塞等待 min_size 个连接就绪,
|
||||
|
||||
3
backend/app/modules/data_convert/__init__.py
Normal file
3
backend/app/modules/data_convert/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
310
backend/app/modules/data_convert/router.py
Normal file
310
backend/app/modules/data_convert/router.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.db.platform_store import get_platform_store, new_id
|
||||
|
||||
|
||||
router = APIRouter(prefix="/data-convert", tags=["data-convert"])
|
||||
|
||||
# 存储根目录
|
||||
STORAGE_ROOT = Path(__file__).resolve().parents[3] / "storage" / "data-convert"
|
||||
|
||||
|
||||
def _task_dir(task_id: str) -> Path:
|
||||
return STORAGE_ROOT / task_id
|
||||
|
||||
|
||||
def _input_dir(task_id: str) -> Path:
|
||||
return _task_dir(task_id) / "input"
|
||||
|
||||
|
||||
def _output_dir(task_id: str) -> Path:
|
||||
return _task_dir(task_id) / "output"
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tasks(page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
with store.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM data_convert_tasks WHERE deleted_at IS NULL "
|
||||
"ORDER BY create_time DESC LIMIT %s OFFSET %s",
|
||||
(page_size, (page - 1) * page_size),
|
||||
).fetchall()
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM data_convert_tasks WHERE deleted_at IS NULL"
|
||||
).fetchone()[0]
|
||||
return ok({"items": [dict(r) for r in rows], "total": total})
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_task(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise fail(400, "name is required")
|
||||
task_id = new_id("dct")
|
||||
output_filename = str(payload.get("output_filename") or "converted-data.jsonl").strip()
|
||||
description = str(payload.get("description") or "").strip()
|
||||
store = get_platform_store()
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO data_convert_tasks (id, name, description, output_filename) "
|
||||
"VALUES (%s, %s, %s, %s)",
|
||||
(task_id, name, description, output_filename),
|
||||
)
|
||||
# 创建目录
|
||||
_input_dir(task_id).mkdir(parents=True, exist_ok=True)
|
||||
_output_dir(task_id).mkdir(parents=True, exist_ok=True)
|
||||
return ok(_get_task(task_id))
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
def get_task(task_id: str) -> dict[str, Any]:
|
||||
task = _get_task(task_id)
|
||||
if not task:
|
||||
raise fail(404, "task not found")
|
||||
# 附加输入文件列表
|
||||
input_dir = _input_dir(task_id)
|
||||
files = []
|
||||
if input_dir.exists():
|
||||
for f in sorted(input_dir.iterdir()):
|
||||
if f.is_file():
|
||||
files.append({"name": f.name, "size": f.stat().st_size})
|
||||
task["input_files"] = files
|
||||
return ok(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/source-files")
|
||||
async def upload_source_files(
|
||||
task_id: str,
|
||||
files: list[UploadFile] = File(...),
|
||||
) -> dict[str, Any]:
|
||||
task = _get_task(task_id)
|
||||
if not task:
|
||||
raise fail(404, "task not found")
|
||||
if task["status"] not in ("pending", "uploaded"):
|
||||
raise fail(400, "task is not editable")
|
||||
input_dir = _input_dir(task_id)
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
staged = []
|
||||
for upload in files:
|
||||
name = Path(upload.filename or "input.json").name
|
||||
if not name.lower().endswith(".json"):
|
||||
raise fail(415, f"only JSON files are supported: {name}")
|
||||
target = input_dir / name
|
||||
content = await upload.read()
|
||||
target.write_bytes(content)
|
||||
staged.append({"name": name, "size": len(content)})
|
||||
store = get_platform_store()
|
||||
# 标记上传完成
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE data_convert_tasks SET status='uploaded', update_time=NOW() WHERE id=%s",
|
||||
(task_id,),
|
||||
)
|
||||
# 自动转换并导入数据集
|
||||
try:
|
||||
output_dir = _output_dir(task_id)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / (task["output_filename"] or "converted-data.jsonl")
|
||||
# 清空旧输出(如果重新上传)
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
input_count = 0
|
||||
output_count = 0
|
||||
for json_file in sorted(input_dir.iterdir()):
|
||||
if not json_file.is_file() or not json_file.name.lower().endswith(".json"):
|
||||
continue
|
||||
input_count += 1
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
records = data
|
||||
elif isinstance(data, dict):
|
||||
records = [data]
|
||||
else:
|
||||
raise ValueError(f"JSON must be object or array: {json_file.name}")
|
||||
with open(output_path, "a", encoding="utf-8") as f:
|
||||
for record in records:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
output_count += 1
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE data_convert_tasks SET status='completed', "
|
||||
"input_count=%s, output_count=%s, update_time=NOW() WHERE id=%s",
|
||||
(input_count, output_count, task_id),
|
||||
)
|
||||
# 自动导入数据集
|
||||
content = output_path.read_text(encoding="utf-8")
|
||||
size_bytes = len(content.encode("utf-8"))
|
||||
dataset = store.create_dataset({
|
||||
"name": task["name"],
|
||||
"type": "train",
|
||||
"storage_type": "local",
|
||||
"source": "upload",
|
||||
"task_id": task_id,
|
||||
"size": f"{size_bytes} B",
|
||||
"count": output_count,
|
||||
"description": f"由数据类型转换任务 {task_id} 自动导入",
|
||||
})
|
||||
dataset_id = dataset["id"]
|
||||
with store.connect() as conn:
|
||||
store.add_dataset_file(conn, dataset_id, task["output_filename"] or "converted-data.jsonl", content)
|
||||
return ok({
|
||||
"staged_files": staged,
|
||||
"auto_converted": True,
|
||||
"dataset_id": dataset_id,
|
||||
"input_count": input_count,
|
||||
"output_count": output_count,
|
||||
})
|
||||
except Exception as exc:
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE data_convert_tasks SET status='failed', error_message=%s, update_time=NOW() WHERE id=%s",
|
||||
(str(exc)[:500], task_id),
|
||||
)
|
||||
return ok({"staged_files": staged, "auto_converted": False, "error": str(exc)[:500]})
|
||||
|
||||
|
||||
@router.post("/{task_id}/run")
|
||||
def run_convert(task_id: str) -> dict[str, Any]:
|
||||
task = _get_task(task_id)
|
||||
if not task:
|
||||
raise fail(404, "task not found")
|
||||
if task["status"] not in ("uploaded", "completed", "failed"):
|
||||
raise fail(400, "please upload source files first")
|
||||
# 标记运行中
|
||||
store = get_platform_store()
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE data_convert_tasks SET status='running', error_message='', update_time=NOW() WHERE id=%s",
|
||||
(task_id,),
|
||||
)
|
||||
try:
|
||||
input_dir = _input_dir(task_id)
|
||||
output_dir = _output_dir(task_id)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / (task["output_filename"] or "converted-data.jsonl")
|
||||
input_count = 0
|
||||
output_count = 0
|
||||
for json_file in sorted(input_dir.iterdir()):
|
||||
if not json_file.is_file() or not json_file.name.lower().endswith(".json"):
|
||||
continue
|
||||
input_count += 1
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
records = data
|
||||
elif isinstance(data, dict):
|
||||
records = [data]
|
||||
else:
|
||||
raise ValueError(f"JSON must be object or array: {json_file.name}")
|
||||
with open(output_path, "a", encoding="utf-8") as f:
|
||||
for record in records:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
output_count += 1
|
||||
# 更新任务状态
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE data_convert_tasks SET status='completed', "
|
||||
"input_count=%s, output_count=%s, update_time=NOW() WHERE id=%s",
|
||||
(input_count, output_count, task_id),
|
||||
)
|
||||
except Exception as exc:
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE data_convert_tasks SET status='failed', error_message=%s, update_time=NOW() WHERE id=%s",
|
||||
(str(exc)[:500], task_id),
|
||||
)
|
||||
raise fail(500, f"convert failed: {exc}")
|
||||
return ok(_get_task(task_id))
|
||||
|
||||
|
||||
@router.get("/{task_id}/download")
|
||||
def download_result(task_id: str):
|
||||
task = _get_task(task_id)
|
||||
if not task:
|
||||
raise fail(404, "task not found")
|
||||
if task["status"] != "completed":
|
||||
raise fail(400, "task is not completed")
|
||||
output_path = _output_dir(task_id) / (task["output_filename"] or "converted-data.jsonl")
|
||||
if not output_path.exists():
|
||||
raise fail(404, "output file not found")
|
||||
return FileResponse(
|
||||
str(output_path),
|
||||
media_type="application/octet-stream",
|
||||
filename=task["output_filename"] or "converted-data.jsonl",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/import-as-dataset")
|
||||
def import_as_dataset(
|
||||
task_id: str,
|
||||
payload: dict[str, Any] = Body(default={}),
|
||||
) -> dict[str, Any]:
|
||||
"""把已转换的 JSONL 文件导入为数据集管理中的上传任务记录(source='task')。"""
|
||||
task = _get_task(task_id)
|
||||
if not task:
|
||||
raise fail(404, "task not found")
|
||||
if task["status"] != "completed":
|
||||
raise fail(400, "task is not completed")
|
||||
output_path = _output_dir(task_id) / (task["output_filename"] or "converted-data.jsonl")
|
||||
if not output_path.exists():
|
||||
raise fail(404, "output file not found")
|
||||
content = output_path.read_text(encoding="utf-8")
|
||||
dataset_name = str(payload.get("name") or task["name"]).strip()
|
||||
description = str(payload.get("description") or f"由数据类型转换任务 {task_id} 导入").strip()
|
||||
size_bytes = len(content.encode("utf-8"))
|
||||
store = get_platform_store()
|
||||
# 用 store 提供的接口创建数据集与文件
|
||||
dataset = store.create_dataset({
|
||||
"name": dataset_name,
|
||||
"type": "train",
|
||||
"storage_type": "local",
|
||||
"source": "upload",
|
||||
"task_id": task_id,
|
||||
"size": f"{size_bytes} B",
|
||||
"count": task["output_count"],
|
||||
"description": description,
|
||||
})
|
||||
dataset_id = dataset["id"]
|
||||
with store.connect() as conn:
|
||||
store.add_dataset_file(conn, dataset_id, task["output_filename"] or "converted-data.jsonl", content)
|
||||
return ok({"dataset_id": dataset_id, "name": dataset_name})
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(task_id: str) -> dict[str, Any]:
|
||||
task = _get_task(task_id)
|
||||
if not task:
|
||||
raise fail(404, "task not found")
|
||||
store = get_platform_store()
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE data_convert_tasks SET deleted_at=NOW() WHERE id=%s",
|
||||
(task_id,),
|
||||
)
|
||||
# 清理文件
|
||||
import shutil
|
||||
task_dir = _task_dir(task_id)
|
||||
if task_dir.exists():
|
||||
shutil.rmtree(task_dir, ignore_errors=True)
|
||||
return ok({"deleted": task_id})
|
||||
|
||||
|
||||
def _get_task(task_id: str) -> dict[str, Any] | None:
|
||||
store = get_platform_store()
|
||||
with store.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM data_convert_tasks WHERE id=%s AND deleted_at IS NULL",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -531,6 +531,10 @@ class LocalDataProcessStorage:
|
||||
|
||||
@staticmethod
|
||||
def _fsync_directory(directory: Path) -> None:
|
||||
# Windows 不支持以 O_RDONLY 打开目录做 fsync,跳过即可。
|
||||
# 数据完整性在 Linux 生产环境保障,Windows 开发环境忽略。
|
||||
if os.name == "nt":
|
||||
return
|
||||
descriptor = os.open(directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
|
||||
43
frontend/src/api/modules/data-convert.ts
Normal file
43
frontend/src/api/modules/data-convert.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { del, get, post } from '../request'
|
||||
|
||||
export interface DataConvertTask {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
output_filename: string
|
||||
input_count: number
|
||||
output_count: number
|
||||
error_message: string
|
||||
create_time: string
|
||||
update_time?: string
|
||||
input_files?: Array<{ name: string; size: number }>
|
||||
}
|
||||
|
||||
export const getDataConvertTasks = (page = 1, pageSize = 20) =>
|
||||
get<{ items: DataConvertTask[]; total: number }>('/data-convert', { params: { page, page_size: pageSize } })
|
||||
|
||||
export const getDataConvertTask = (id: string) =>
|
||||
get<DataConvertTask>(`/data-convert/${id}`)
|
||||
|
||||
export const createDataConvertTask = (payload: Partial<DataConvertTask>) =>
|
||||
post<DataConvertTask>('/data-convert', payload)
|
||||
|
||||
export const uploadSourceFiles = (id: string, files: File[]) => {
|
||||
const formData = new FormData()
|
||||
files.forEach(f => formData.append('files', f))
|
||||
return post(`/data-convert/${id}/source-files`, formData)
|
||||
}
|
||||
|
||||
export const runConvert = (id: string) =>
|
||||
post<DataConvertTask>(`/data-convert/${id}/run`)
|
||||
|
||||
export const downloadResult = (id: string) =>
|
||||
get(`/data-convert/${id}/download`, { responseType: 'blob' })
|
||||
|
||||
export const deleteDataConvertTask = (id: string) =>
|
||||
del(`/data-convert/${id}`)
|
||||
|
||||
/** 把转换结果导入为数据集管理中的上传任务记录 */
|
||||
export const importAsDataset = (id: string, payload: { name?: string; description?: string } = {}) =>
|
||||
post(`/data-convert/${id}/import-as-dataset`, payload)
|
||||
@@ -5,6 +5,7 @@ import type { PermissionCode, SystemUser } from '@/types'
|
||||
|
||||
const USER_STORAGE_KEY = 'currentUser'
|
||||
const SESSION_STORAGE_KEY = 'sessionId'
|
||||
const AUTHED_FLAG_KEY = 'hasAuthed'
|
||||
|
||||
const allPermissions: PermissionCode[] = [
|
||||
'dashboard',
|
||||
@@ -21,25 +22,24 @@ const allPermissions: PermissionCode[] = [
|
||||
'user-settings',
|
||||
]
|
||||
|
||||
function restoreUser(): SystemUser | null {
|
||||
const persisted = localStorage.getItem(USER_STORAGE_KEY)
|
||||
if (persisted) {
|
||||
/**
|
||||
* 认证 store
|
||||
* - 用户信息持久化到 localStorage(刷新页面不丢失)
|
||||
* - 但每次新开标签页/窗口必须重新点击登录(sessionStorage 标记本次会话已登录)
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 仅当 sessionStorage 里标记了"已登录"时,才从 localStorage 恢复用户
|
||||
const hasAuthed = sessionStorage.getItem(AUTHED_FLAG_KEY) === '1'
|
||||
const persistedUser = hasAuthed ? localStorage.getItem(USER_STORAGE_KEY) : null
|
||||
let initialUser: SystemUser | null = null
|
||||
if (persistedUser) {
|
||||
try {
|
||||
return JSON.parse(persisted) as SystemUser
|
||||
initialUser = JSON.parse(persistedUser) as SystemUser
|
||||
} catch {
|
||||
localStorage.removeItem(USER_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证 store
|
||||
* 登录态管理:有 currentUser 即视为已登录。
|
||||
* 离开页面超时由 App.vue 的 visibilitychange 监听接管。
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const currentUser = ref<SystemUser | null>(restoreUser())
|
||||
const currentUser = ref<SystemUser | null>(initialUser)
|
||||
const username = computed(() => currentUser.value?.username || '')
|
||||
const displayName = computed(() => currentUser.value?.display_name || username.value)
|
||||
const roleLabel = computed(() => {
|
||||
@@ -54,10 +54,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
async function login(user: string, password: string) {
|
||||
const response = await loginApi(user, password)
|
||||
currentUser.value = response.user
|
||||
localStorage.setItem('username', response.user.username)
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
|
||||
sessionStorage.setItem(AUTHED_FLAG_KEY, '1')
|
||||
if (response.session_id) {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, response.session_id)
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, response.session_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,14 +69,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
/** 退出 */
|
||||
async function logout() {
|
||||
const sessionId = localStorage.getItem(SESSION_STORAGE_KEY)
|
||||
const sessionId = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
||||
if (sessionId) {
|
||||
try { await logoutApi(sessionId) } catch { /* 静默 */ }
|
||||
}
|
||||
currentUser.value = null
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem(USER_STORAGE_KEY)
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
sessionStorage.removeItem(AUTHED_FLAG_KEY)
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,305 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type { UploadRequestOptions } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import {
|
||||
getDataConvertTasks,
|
||||
createDataConvertTask,
|
||||
uploadSourceFiles,
|
||||
deleteDataConvertTask,
|
||||
type DataConvertTask,
|
||||
} from '@/api/modules/data-convert'
|
||||
|
||||
const settings = reactive({
|
||||
outputName: 'converted-data',
|
||||
encoding: 'UTF-8',
|
||||
})
|
||||
const loading = ref(false)
|
||||
const tasks = ref<DataConvertTask[]>([])
|
||||
const showCreate = ref(false)
|
||||
const form = ref({ name: '', outputName: 'converted-data' })
|
||||
|
||||
function showPrototypeNotice() {
|
||||
ElMessage.info('当前仅完成界面设计,转换功能将在后续接入')
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDataConvertTasks()
|
||||
tasks.value = res.items || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
Object.assign(settings, {
|
||||
outputName: 'converted-data',
|
||||
encoding: 'UTF-8',
|
||||
async function submitCreate() {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('请填写任务名称')
|
||||
return
|
||||
}
|
||||
await createDataConvertTask({
|
||||
name: form.value.name,
|
||||
output_filename: form.value.outputName + '.jsonl',
|
||||
})
|
||||
ElMessage.success('任务创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', outputName: 'converted-data' }
|
||||
load()
|
||||
}
|
||||
|
||||
async function customUpload(options: UploadRequestOptions) {
|
||||
const taskId = options.data?.taskId as string
|
||||
if (!taskId) {
|
||||
ElMessage.error('任务 ID 缺失')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await uploadSourceFiles(taskId, [options.file])
|
||||
const data = (res as any)?.data || res
|
||||
if (data?.auto_converted) {
|
||||
ElMessage.success(
|
||||
`上传并自动转换完成,输入 ${data.input_count} / 输出 ${data.output_count},已自动导入数据集`,
|
||||
)
|
||||
} else {
|
||||
ElMessage.warning('上传完成,但转换失败:' + (data?.error || '未知错误'))
|
||||
}
|
||||
load()
|
||||
} catch {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(task: DataConvertTask) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除任务「${task.name}」吗?`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteDataConvertTask(task.id)
|
||||
ElMessage.success('任务已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
function statusTag(status: string) {
|
||||
const map: Record<string, { type: string; label: string }> = {
|
||||
pending: { type: 'info', label: '待上传' },
|
||||
uploaded: { type: 'warning', label: '已上传' },
|
||||
running: { type: 'warning', label: '转换中' },
|
||||
completed: { type: 'success', label: '已完成' },
|
||||
failed: { type: 'danger', label: '失败' },
|
||||
}
|
||||
return map[status] || { type: 'info', label: status }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageCard
|
||||
class="data-convert-page"
|
||||
title="数据类型转换"
|
||||
subtitle="将 JSON 文件转换为便于训练和评测使用的 JSONL 格式"
|
||||
subtitle="将 JSON 文件转换为 JSONL 格式,转换结果自动导入到数据集管理"
|
||||
>
|
||||
<div class="converter-panel">
|
||||
<div class="panel-header">
|
||||
<div class="tool-icon" aria-hidden="true">
|
||||
<i class="fa fa-exchange" />
|
||||
</div>
|
||||
<div>
|
||||
<h3>JSON 转 JSONL</h3>
|
||||
<p>每条 JSON 数据将输出为 JSONL 文件中的一行记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form class="converter-form" label-position="top">
|
||||
<el-form-item label="转换类型">
|
||||
<div class="format-field" aria-label="JSON 转 JSONL">
|
||||
<span>JSON</span>
|
||||
<i class="fa fa-long-arrow-right" aria-hidden="true" />
|
||||
<span>JSONL</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="源文件" required>
|
||||
<button class="upload-zone" type="button" @click="showPrototypeNotice">
|
||||
<i class="fa fa-cloud-upload" aria-hidden="true" />
|
||||
<span class="upload-content">
|
||||
<strong>点击选择或拖拽 JSON 文件到此处</strong>
|
||||
<small>仅支持 .json 格式,单文件不超过 200 MB</small>
|
||||
</span>
|
||||
<span class="select-button">选择文件</span>
|
||||
</button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-row">
|
||||
<el-form-item label="输出文件名">
|
||||
<el-input v-model="settings.outputName">
|
||||
<template #append>.jsonl</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="字符编码">
|
||||
<el-select v-model="settings.encoding" style="width: 100%">
|
||||
<el-option label="UTF-8" value="UTF-8" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="format-tip">
|
||||
<i class="fa fa-info-circle" aria-hidden="true" />
|
||||
<span>支持由 JSON 数组转换为 JSONL,每个数组元素输出为一行。</span>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="panel-footer">
|
||||
<span class="prototype-label">当前为 UI 原型,暂不执行实际转换</span>
|
||||
<div class="actions">
|
||||
<el-button @click="resetSettings">重置</el-button>
|
||||
<el-tooltip content="转换功能将在后续开发中接入" placement="top">
|
||||
<span><el-button type="primary" disabled>开始转换</el-button></span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建转换任务</el-button>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tasks" v-loading="loading" border stripe>
|
||||
<el-table-column prop="name" label="任务名称" min-width="140" />
|
||||
<el-table-column label="状态" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status).type">{{ statusTag(row.status).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据量" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.output_count }} 条
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="output_filename" label="输出文件名" min-width="160" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-upload
|
||||
v-if="row.status === 'pending'"
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
accept=".json"
|
||||
:http-request="customUpload"
|
||||
:data="{ taskId: row.id }"
|
||||
style="display: inline-block; margin-right: 8px"
|
||||
>
|
||||
<el-button link type="primary">上传文件</el-button>
|
||||
</el-upload>
|
||||
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新建任务弹窗 -->
|
||||
<el-dialog v-model="showCreate" title="新建转换任务" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="任务名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入任务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="输出文件名">
|
||||
<el-input v-model="form.outputName" placeholder="converted-data">
|
||||
<template #append>.jsonl</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.converter-panel {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 220px);
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
min-height: 72px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.tool-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.converter-form {
|
||||
flex: 1;
|
||||
padding: 22px 24px 6px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
padding-bottom: 8px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.format-field {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #f5f7fa;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
i {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: 20px;
|
||||
border: 1px dashed #b8c4d1;
|
||||
border-radius: 6px;
|
||||
background: #fafcff;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--el-color-primary-light-9);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
> i {
|
||||
color: var(--primary-color);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.select-button {
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.format-tip {
|
||||
min-height: 38px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: #606266;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
|
||||
i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
min-height: 64px;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.prototype-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.converter-form {
|
||||
padding: 18px 16px 4px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.select-button {
|
||||
margin-left: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 12px 16px;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
}
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
45
测试脚本.md
45
测试脚本.md
@@ -13,7 +13,9 @@
|
||||
```
|
||||
3. **算力服务**(Docker):
|
||||
```cmd
|
||||
cd /d E:\yg_ft\docker\compute
|
||||
cd /d E:\yg_ft
|
||||
docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .
|
||||
cd docker/compute
|
||||
docker compose up -d
|
||||
```
|
||||
4. **前端**:
|
||||
@@ -40,7 +42,7 @@
|
||||
|------|------|---------|
|
||||
| 1 | 打开 `http://localhost:16801` | 显示登录页,不自动登录 |
|
||||
| 2 | 输入账号 `admin`,密码 `admin123`,点击登录 | 提示"登录成功",跳转看板页 |
|
||||
| 3 | F12 控制台执行 `localStorage.getItem('sessionId')` | 有值(非 null) |
|
||||
| 3 | F12 控制台执行 `sessionStorage.getItem('sessionId')` | 有值(非 null) |
|
||||
| 4 | 浏览器 Network 查看 `/modelTF/login` 响应 | 返回 `token`、`user`、`session_id` |
|
||||
|
||||
### 1.2 退出登录
|
||||
@@ -48,10 +50,23 @@
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 点击侧边栏底部"退出登录"按钮 | 跳转登录页 |
|
||||
| 2 | F12 控制台执行 `localStorage.getItem('currentUser')` | 返回 null |
|
||||
| 2 | F12 控制台执行 `sessionStorage.getItem('hasAuthed')` | 返回 null |
|
||||
| 3 | 浏览器 Network 查看 `/modelTF/logout` 请求 | 状态 200 |
|
||||
|
||||
### 1.3 会话超时
|
||||
### 1.3 刷新页面保持登录
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 登录后按 F5 刷新页面 | 仍在功能页,不跳登录页 |
|
||||
|
||||
### 1.4 关闭标签页再打开
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 登录后关闭浏览器标签页 | — |
|
||||
| 2 | 重新打开 `http://localhost:16801` | 跳转登录页(sessionStorage 已清空) |
|
||||
|
||||
### 1.5 会话超时
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
@@ -304,7 +319,19 @@
|
||||
| 3 | 上传文件 | 文件上传成功 |
|
||||
| 4 | 预览文件 | 显示内容 |
|
||||
|
||||
### 11.2 数据处理
|
||||
### 11.2 数据类型转换
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 侧边栏 → 数据类型转换 | 显示任务列表 |
|
||||
| 2 | 点击"新建转换任务",填写名称,点击创建 | 列表新增一行,状态为"待上传" |
|
||||
| 3 | 点击"上传文件",选择 `.json` 文件 | 上传成功后自动转换为 JSONL 并导入数据集 |
|
||||
| 4 | 检查任务状态 | 变为"已完成",显示输入/输出数 |
|
||||
| 5 | 进入数据集管理 → 上传任务 Tab | 可看到自动导入的数据集 |
|
||||
| 6 | 上传非 `.json` 文件 | 提示"仅支持 .json 文件" |
|
||||
| 7 | 点击"删除" | 确认后任务删除 |
|
||||
|
||||
### 11.3 数据处理
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
@@ -313,7 +340,7 @@
|
||||
| 3 | 启动任务 | 状态变为 running |
|
||||
| 4 | 回到看板 | "运行中任务"数字 +1 |
|
||||
|
||||
### 11.3 模型训练
|
||||
### 11.4 模型训练
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
@@ -323,7 +350,7 @@
|
||||
| 4 | 查看训练日志 | 显示实时日志 |
|
||||
| 5 | 训练完成 | 产出模型 |
|
||||
|
||||
### 11.4 模型评测
|
||||
### 11.5 模型评测
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
@@ -332,7 +359,7 @@
|
||||
| 3 | 启动评测 | 状态变为 running |
|
||||
| 4 | 评测完成 | 显示指标结果 |
|
||||
|
||||
### 11.5 模型推理
|
||||
### 11.6 模型推理
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
@@ -350,4 +377,4 @@
|
||||
| 算力服务停掉 | `docker compose down` | 算力测试返回失败 |
|
||||
| 未登录访问 | 直接访问 `/dashboard` | 跳转登录页 |
|
||||
| 权限不足 | 普通用户访问用户设置 | 跳转 403 页面 |
|
||||
| Token 过期 | 清掉 localStorage 后刷新 | 跳转登录页 |
|
||||
| Token 过期 | 关闭标签页再打开 | 跳转登录页(sessionStorage 清空) |
|
||||
|
||||
Reference in New Issue
Block a user