diff --git a/README.md b/README.md index 1e02c54..0956bc2 100644 --- a/README.md +++ b/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` 主动轮询算力节点状态。 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 35017f2..1ed548a 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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"]) diff --git a/backend/app/db/platform_store.py b/backend/app/db/platform_store.py index c08b877..e9de48a 100644 --- a/backend/app/db/platform_store.py +++ b/backend/app/db/platform_store.py @@ -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 个连接就绪, diff --git a/backend/app/modules/data_convert/__init__.py b/backend/app/modules/data_convert/__init__.py new file mode 100644 index 0000000..5bc0c2e --- /dev/null +++ b/backend/app/modules/data_convert/__init__.py @@ -0,0 +1,3 @@ +from .router import router + +__all__ = ["router"] diff --git a/backend/app/modules/data_convert/router.py b/backend/app/modules/data_convert/router.py new file mode 100644 index 0000000..60e167f --- /dev/null +++ b/backend/app/modules/data_convert/router.py @@ -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 diff --git a/backend/app/modules/data_process/storage.py b/backend/app/modules/data_process/storage.py index 23b1666..29f1017 100644 --- a/backend/app/modules/data_process/storage.py +++ b/backend/app/modules/data_process/storage.py @@ -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) diff --git a/frontend/src/api/modules/data-convert.ts b/frontend/src/api/modules/data-convert.ts new file mode 100644 index 0000000..1b30675 --- /dev/null +++ b/frontend/src/api/modules/data-convert.ts @@ -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(`/data-convert/${id}`) + +export const createDataConvertTask = (payload: Partial) => + post('/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(`/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) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 8d89cf9..97c48ea 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -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(restoreUser()) + const currentUser = ref(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 { diff --git a/frontend/src/views/data-convert/DataConvertView.vue b/frontend/src/views/data-convert/DataConvertView.vue index 511ac97..9038ef0 100644 --- a/frontend/src/views/data-convert/DataConvertView.vue +++ b/frontend/src/views/data-convert/DataConvertView.vue @@ -1,305 +1,165 @@ diff --git a/测试脚本.md b/测试脚本.md index 7f095cc..2e3618c 100644 --- a/测试脚本.md +++ b/测试脚本.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 清空) |