新增数据类型转换

This commit is contained in:
wangjiming
2026-08-05 16:23:00 +08:00
parent b5c6557341
commit c64fa1cd61
10 changed files with 620 additions and 322 deletions

View File

@@ -103,15 +103,37 @@ GET /modelTF/model-manage
GET /modelTF/dataset-manage GET /modelTF/dataset-manage
GET /modelTF/fine-tune GET /modelTF/fine-tune
GET /modelTF/compute/nodes GET /modelTF/compute/nodes
GET /modelTF/data-convert
``` ```
本地运行时默认 PostgreSQL 连接: ### 数据库配置
```text 后端通过 `backend/.env` 文件配置数据库连接(自动加载,`override=True`
DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft
```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 启动(推荐) ### 方式一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 cd docker/compute
cp .env.example .env cp .env.example .env
docker compose up -d docker compose up -d
``` ```
**3. 验证**
```cmd
curl http://localhost:19100/health
```
> 注意:构建上下文必须是项目根目录 `E:\yg_ft``docker build` 最后的 `.`),因为 Dockerfile 需要 `COPY compute/requirements.txt`。
### 方式二:本地开发启动 ### 方式二:本地开发启动
**Windows (cmd)** **Windows (cmd)**
@@ -151,25 +190,35 @@ docker compose up -d
```cmd ```cmd
cd /d E:\yg_ft\compute cd /d E:\yg_ft\compute
set PYTHONPATH=E:\yg_ft 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...` 绝对导入。 > `PYTHONPATH=E:\yg_ft` 是必需的,因为代码使用 `from compute.agent...` 绝对导入。
> `--host 0.0.0.0` 让其他机器可以通过 IP 访问(算力节点测试需要)。
**Linux / macOS** **Linux / macOS**
```bash ```bash
cd compute 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_MODE` | `real` | `real` / `simulator`,仅隔离联调用 simulator |
| `COMPUTE_EXECUTION_MODE` | `real` | 训练执行模式 | | `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 路由前缀 | | `MODELTF_ROUTE_PREFIX` | `/modelTF` | API 路由前缀 |
应用平台通过数据库 `compute_nodes` 表中的 `api_base_url``file_gateway_url` 主动轮询算力节点状态。 应用平台通过数据库 `compute_nodes` 表中的 `api_base_url``file_gateway_url` 主动轮询算力节点状态。

View File

@@ -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.system.router import router as system_router
from app.modules.retention.router import router as retention_router from app.modules.retention.router import router as retention_router
from app.modules.resource.router import router as resource_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 = APIRouter()
api_router.include_router(health_router, tags=["health"]) 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(approval_router, tags=["approval"])
api_router.include_router(retention_router, tags=["retention"]) api_router.include_router(retention_router, tags=["retention"])
api_router.include_router(resource_router, tags=["resource"]) api_router.include_router(resource_router, tags=["resource"])
api_router.include_router(data_convert_router, tags=["data-convert"])

View File

@@ -378,7 +378,7 @@ class PlatformStore:
# request (notably expensive against the remote PostgreSQL instance). # request (notably expensive against the remote PostgreSQL instance).
# TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。 # TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。
pool_kwargs = { pool_kwargs = {
"connect_timeout": 5, "connect_timeout": 30,
"keepalives": 1, "keepalives": 1,
"keepalives_idle": 10, "keepalives_idle": 10,
"keepalives_interval": 5, "keepalives_interval": 5,
@@ -388,14 +388,14 @@ class PlatformStore:
conninfo=self.database_url, conninfo=self.database_url,
kwargs=pool_kwargs, kwargs=pool_kwargs,
min_size=2, min_size=2,
max_size=10, max_size=20,
# 借出前校验连接可用性,避免执行 SQL 时才发现 [BAD] 再重建。 # 借出前校验连接可用性,避免执行 SQL 时才发现 [BAD] 再重建。
check=ConnectionPool.check_connection, check=ConnectionPool.check_connection,
# 不主动回收空闲连接(远程库约 10s 断,由 keepalive 维持), # 不主动回收空闲连接(远程库约 10s 断,由 keepalive 维持),
# 减少无谓的重建握手。 # 减少无谓的重建握手。
max_idle=0, max_idle=0,
# 请求最多排队等待 5s避免雪崩时无限堆积 # 请求最多排队等待,调大以适应远程库慢查询
max_waiting=16, max_waiting=50,
open=False, open=False,
) )
# 注意:不要在此调用 pool.wait(),它会阻塞等待 min_size 个连接就绪, # 注意:不要在此调用 pool.wait(),它会阻塞等待 min_size 个连接就绪,

View File

@@ -0,0 +1,3 @@
from .router import router
__all__ = ["router"]

View 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

View File

@@ -531,6 +531,10 @@ class LocalDataProcessStorage:
@staticmethod @staticmethod
def _fsync_directory(directory: Path) -> None: def _fsync_directory(directory: Path) -> None:
# Windows 不支持以 O_RDONLY 打开目录做 fsync跳过即可。
# 数据完整性在 Linux 生产环境保障Windows 开发环境忽略。
if os.name == "nt":
return
descriptor = os.open(directory, os.O_RDONLY) descriptor = os.open(directory, os.O_RDONLY)
try: try:
os.fsync(descriptor) os.fsync(descriptor)

View 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)

View File

@@ -5,6 +5,7 @@ import type { PermissionCode, SystemUser } from '@/types'
const USER_STORAGE_KEY = 'currentUser' const USER_STORAGE_KEY = 'currentUser'
const SESSION_STORAGE_KEY = 'sessionId' const SESSION_STORAGE_KEY = 'sessionId'
const AUTHED_FLAG_KEY = 'hasAuthed'
const allPermissions: PermissionCode[] = [ const allPermissions: PermissionCode[] = [
'dashboard', 'dashboard',
@@ -21,25 +22,24 @@ const allPermissions: PermissionCode[] = [
'user-settings', 'user-settings',
] ]
function restoreUser(): SystemUser | null { /**
const persisted = localStorage.getItem(USER_STORAGE_KEY) * 认证 store
if (persisted) { * - 用户信息持久化到 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 { try {
return JSON.parse(persisted) as SystemUser initialUser = JSON.parse(persistedUser) as SystemUser
} catch { } catch {
localStorage.removeItem(USER_STORAGE_KEY) localStorage.removeItem(USER_STORAGE_KEY)
} }
} }
return null const currentUser = ref<SystemUser | null>(initialUser)
}
/**
* 认证 store
* 登录态管理:有 currentUser 即视为已登录。
* 离开页面超时由 App.vue 的 visibilitychange 监听接管。
*/
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<SystemUser | null>(restoreUser())
const username = computed(() => currentUser.value?.username || '') const username = computed(() => currentUser.value?.username || '')
const displayName = computed(() => currentUser.value?.display_name || username.value) const displayName = computed(() => currentUser.value?.display_name || username.value)
const roleLabel = computed(() => { const roleLabel = computed(() => {
@@ -54,10 +54,10 @@ export const useAuthStore = defineStore('auth', () => {
async function login(user: string, password: string) { async function login(user: string, password: string) {
const response = await loginApi(user, password) const response = await loginApi(user, password)
currentUser.value = response.user currentUser.value = response.user
localStorage.setItem('username', response.user.username)
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user)) localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
sessionStorage.setItem(AUTHED_FLAG_KEY, '1')
if (response.session_id) { 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() { async function logout() {
const sessionId = localStorage.getItem(SESSION_STORAGE_KEY) const sessionId = sessionStorage.getItem(SESSION_STORAGE_KEY)
if (sessionId) { if (sessionId) {
try { await logoutApi(sessionId) } catch { /* 静默 */ } try { await logoutApi(sessionId) } catch { /* 静默 */ }
} }
currentUser.value = null currentUser.value = null
localStorage.removeItem('username')
localStorage.removeItem(USER_STORAGE_KEY) localStorage.removeItem(USER_STORAGE_KEY)
localStorage.removeItem(SESSION_STORAGE_KEY) sessionStorage.removeItem(AUTHED_FLAG_KEY)
sessionStorage.removeItem(SESSION_STORAGE_KEY)
} }
return { return {

View File

@@ -1,305 +1,165 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive } from 'vue' import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' 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 PageCard from '@/components/PageCard.vue'
import {
getDataConvertTasks,
createDataConvertTask,
uploadSourceFiles,
deleteDataConvertTask,
type DataConvertTask,
} from '@/api/modules/data-convert'
const settings = reactive({ const loading = ref(false)
outputName: 'converted-data', const tasks = ref<DataConvertTask[]>([])
encoding: 'UTF-8', const showCreate = ref(false)
}) const form = ref({ name: '', outputName: 'converted-data' })
function showPrototypeNotice() { async function load() {
ElMessage.info('当前仅完成界面设计,转换功能将在后续接入') loading.value = true
try {
const res = await getDataConvertTasks()
tasks.value = res.items || []
} finally {
loading.value = false
}
} }
function resetSettings() { async function submitCreate() {
Object.assign(settings, { if (!form.value.name) {
outputName: 'converted-data', ElMessage.warning('请填写任务名称')
encoding: 'UTF-8', 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> </script>
<template> <template>
<PageCard <PageCard
class="data-convert-page" class="data-convert-page"
title="数据类型转换" title="数据类型转换"
subtitle="将 JSON 文件转换为便于训练和评测使用的 JSONL 格式" subtitle="将 JSON 文件转换为 JSONL 格式,转换结果自动导入到数据集管理"
> >
<div class="converter-panel"> <div class="toolbar">
<div class="panel-header"> <el-button type="primary" :icon="Plus" @click="showCreate = true">新建转换任务</el-button>
<div class="tool-icon" aria-hidden="true"> <el-button :icon="Refresh" @click="load">刷新</el-button>
<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> </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> </PageCard>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.converter-panel { .toolbar {
width: 100%;
min-height: calc(100vh - 220px);
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
display: flex; display: flex;
flex-direction: column; gap: 10px;
} margin-bottom: 16px;
.panel-header {
min-height: 72px;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
background: #fafafa;
display: flex;
align-items: center;
gap: 12px;
box-sizing: border-box;
.tool-icon {
width: 38px;
height: 38px;
flex: 0 0 auto;
border-radius: 6px;
background: var(--el-color-primary-light-9);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
}
h3 {
margin: 0;
color: #303133;
font-size: 15px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: #909399;
font-size: 12px;
}
}
.converter-form {
flex: 1;
padding: 22px 24px 6px;
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item__label) {
padding-bottom: 8px;
color: #606266;
font-size: 13px;
}
}
.format-field {
width: 100%;
min-height: 40px;
padding: 0 14px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f5f7fa;
color: #303133;
display: flex;
align-items: center;
gap: 14px;
box-sizing: border-box;
font-size: 13px;
font-weight: 500;
i {
color: #909399;
}
}
.upload-zone {
width: 100%;
min-height: 112px;
padding: 20px;
border: 1px dashed #b8c4d1;
border-radius: 6px;
background: #fafcff;
color: #606266;
cursor: pointer;
font: inherit;
display: flex;
align-items: center;
gap: 14px;
text-align: left;
transition: border-color 0.2s ease, background 0.2s ease;
&:hover,
&:focus-visible {
border-color: var(--primary-color);
background: var(--el-color-primary-light-9);
outline: none;
}
> i {
color: var(--primary-color);
font-size: 24px;
}
.upload-content {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
}
strong {
color: #303133;
font-size: 13px;
font-weight: 500;
}
small {
color: #909399;
font-size: 12px;
}
.select-button {
min-height: 32px;
padding: 0 14px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
color: #606266;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
}
.form-row {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr);
gap: 16px;
}
.format-tip {
min-height: 38px;
padding: 9px 12px;
border-radius: 4px;
background: var(--el-color-primary-light-9);
color: #606266;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
font-size: 12px;
i {
color: var(--primary-color);
}
}
.panel-footer {
min-height: 64px;
padding: 12px 24px;
border-top: 1px solid #ebeef5;
background: #fafafa;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
box-sizing: border-box;
.prototype-label {
color: #909399;
font-size: 12px;
}
.actions {
display: flex;
gap: 10px;
}
}
@media (max-width: 640px) {
.converter-form {
padding: 18px 16px 4px;
}
.form-row {
grid-template-columns: 1fr;
gap: 0;
}
.upload-zone {
align-items: flex-start;
flex-wrap: wrap;
.select-button {
margin-left: 38px;
}
}
.panel-footer {
padding: 12px 16px;
align-items: flex-end;
flex-direction: column;
}
} }
</style> </style>

View File

@@ -13,7 +13,9 @@
``` ```
3. **算力服务**Docker 3. **算力服务**Docker
```cmd ```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 docker compose up -d
``` ```
4. **前端** 4. **前端**
@@ -40,7 +42,7 @@
|------|------|---------| |------|------|---------|
| 1 | 打开 `http://localhost:16801` | 显示登录页,不自动登录 | | 1 | 打开 `http://localhost:16801` | 显示登录页,不自动登录 |
| 2 | 输入账号 `admin`,密码 `admin123`,点击登录 | 提示"登录成功",跳转看板页 | | 2 | 输入账号 `admin`,密码 `admin123`,点击登录 | 提示"登录成功",跳转看板页 |
| 3 | F12 控制台执行 `localStorage.getItem('sessionId')` | 有值(非 null | | 3 | F12 控制台执行 `sessionStorage.getItem('sessionId')` | 有值(非 null |
| 4 | 浏览器 Network 查看 `/modelTF/login` 响应 | 返回 `token`、`user`、`session_id` | | 4 | 浏览器 Network 查看 `/modelTF/login` 响应 | 返回 `token`、`user`、`session_id` |
### 1.2 退出登录 ### 1.2 退出登录
@@ -48,10 +50,23 @@
| 步骤 | 操作 | 预期结果 | | 步骤 | 操作 | 预期结果 |
|------|------|---------| |------|------|---------|
| 1 | 点击侧边栏底部"退出登录"按钮 | 跳转登录页 | | 1 | 点击侧边栏底部"退出登录"按钮 | 跳转登录页 |
| 2 | F12 控制台执行 `localStorage.getItem('currentUser')` | 返回 null | | 2 | F12 控制台执行 `sessionStorage.getItem('hasAuthed')` | 返回 null |
| 3 | 浏览器 Network 查看 `/modelTF/logout` 请求 | 状态 200 | | 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 | 上传文件 | 文件上传成功 | | 3 | 上传文件 | 文件上传成功 |
| 4 | 预览文件 | 显示内容 | | 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 | | 3 | 启动任务 | 状态变为 running |
| 4 | 回到看板 | "运行中任务"数字 +1 | | 4 | 回到看板 | "运行中任务"数字 +1 |
### 11.3 模型训练 ### 11.4 模型训练
| 步骤 | 操作 | 预期结果 | | 步骤 | 操作 | 预期结果 |
|------|------|---------| |------|------|---------|
@@ -323,7 +350,7 @@
| 4 | 查看训练日志 | 显示实时日志 | | 4 | 查看训练日志 | 显示实时日志 |
| 5 | 训练完成 | 产出模型 | | 5 | 训练完成 | 产出模型 |
### 11.4 模型评测 ### 11.5 模型评测
| 步骤 | 操作 | 预期结果 | | 步骤 | 操作 | 预期结果 |
|------|------|---------| |------|------|---------|
@@ -332,7 +359,7 @@
| 3 | 启动评测 | 状态变为 running | | 3 | 启动评测 | 状态变为 running |
| 4 | 评测完成 | 显示指标结果 | | 4 | 评测完成 | 显示指标结果 |
### 11.5 模型推理 ### 11.6 模型推理
| 步骤 | 操作 | 预期结果 | | 步骤 | 操作 | 预期结果 |
|------|------|---------| |------|------|---------|
@@ -350,4 +377,4 @@
| 算力服务停掉 | `docker compose down` | 算力测试返回失败 | | 算力服务停掉 | `docker compose down` | 算力测试返回失败 |
| 未登录访问 | 直接访问 `/dashboard` | 跳转登录页 | | 未登录访问 | 直接访问 `/dashboard` | 跳转登录页 |
| 权限不足 | 普通用户访问用户设置 | 跳转 403 页面 | | 权限不足 | 普通用户访问用户设置 | 跳转 403 页面 |
| Token 过期 | 清掉 localStorage 后刷新 | 跳转登录页 | | Token 过期 | 关闭标签页再打开 | 跳转登录页sessionStorage 清空) |