Compare commits
6 Commits
080ef6ab00
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c38e37b23 | ||
|
|
a5fbe5130a | ||
| b27c5f2cd6 | |||
| c9058a27b7 | |||
| 1468834116 | |||
|
|
7d5b345779 |
2
.gitignore
vendored
@@ -187,3 +187,5 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Local backend configuration may contain database credentials.
|
||||
backend/config.yaml
|
||||
|
||||
49
README.md
@@ -48,12 +48,36 @@ YG_FT/
|
||||
- 前端新增 `/compute` 算力节点页面,展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。
|
||||
- `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。
|
||||
|
||||
## 一键启动前端和后端
|
||||
|
||||
```bash
|
||||
./scripts/start-dev.sh --setup # 首次运行,初始化本地依赖
|
||||
./scripts/start-dev.sh # 后续直接启动
|
||||
./scripts/start-dev.sh --check # 仅检查依赖和 PostgreSQL 连接
|
||||
```
|
||||
|
||||
首次运行前可将 `backend/config.example.yaml` 复制为 `backend/config.yaml` 并填写
|
||||
本地数据库凭据;`config.yaml` 已加入 Git 忽略。脚本会检查 PostgreSQL 和两个服务
|
||||
的健康状态,并在退出时同时停止前端和后端。也可以按下面步骤分别启动服务。
|
||||
|
||||
一键启动使用的前后端端口也统一配置在同一个 YAML 中:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
frontend_port: 16801
|
||||
backend_port: 17861
|
||||
```
|
||||
|
||||
端口被占用时修改这两项后重新启动即可。未显式配置 `cors_allow_origins` 时,后端会
|
||||
根据 `frontend_port` 自动允许本机前端来源。环境变量 `FRONTEND_PORT` 和
|
||||
`BACKEND_PORT` 可以临时覆盖 YAML。
|
||||
|
||||
## 后端启动
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
/opt/miniconda3/bin/python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 17861
|
||||
```
|
||||
@@ -69,13 +93,22 @@ GET /modelTF/fine-tune
|
||||
GET /modelTF/compute/nodes
|
||||
```
|
||||
|
||||
本地运行时默认 PostgreSQL 连接:
|
||||
本地运行时默认通过 `backend/config.yaml` 连接 PostgreSQL:
|
||||
|
||||
```text
|
||||
DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft
|
||||
```yaml
|
||||
server:
|
||||
frontend_port: 16801
|
||||
backend_port: 17861
|
||||
|
||||
database:
|
||||
url: postgresql+psycopg://localhost:5432/yg_ft
|
||||
username: yg_ft
|
||||
password: "change_me"
|
||||
```
|
||||
|
||||
本地启动前需要确保 PostgreSQL 已监听 `localhost:15432`,并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入内置管理员账号,运行数据统一写入 PostgreSQL。
|
||||
本地启动前需要确保 PostgreSQL 已监听 YAML 配置的地址(默认 `localhost:5432`),
|
||||
并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入
|
||||
内置管理员账号,运行数据统一写入 PostgreSQL。
|
||||
|
||||
开发阶段内置登录账号:
|
||||
|
||||
@@ -94,7 +127,9 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端开发服务默认运行在 `http://localhost:16801`,并通过 Vite proxy 将 `/modelTF` 转发到 `http://localhost:17861`。
|
||||
通过一键脚本启动时,前端端口和 Vite proxy 的后端端口均来自
|
||||
`backend/config.yaml`。单独运行 `npm run dev` 时,默认使用前端 `16801`、后端
|
||||
`17861`,也可以通过 `FRONTEND_PORT` 和 `BACKEND_PORT` 覆盖。
|
||||
|
||||
## 算力服务启动
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
APP_NAME=YG Fine-Tune Platform API
|
||||
APP_ENV=local
|
||||
API_PREFIX=/api
|
||||
LOG_LEVEL=INFO
|
||||
LOG_DIR=./logs
|
||||
LOG_FILE_PREFIX=backend
|
||||
LOG_ERROR_FILE_PREFIX=error
|
||||
LOG_MAX_BYTES=20971520
|
||||
LOG_RETENTION_DAYS=10
|
||||
# 默认自动读取 backend/config.yaml。设置此变量可切换到其他 YAML 文件。
|
||||
BACKEND_CONFIG_FILE=./config.yaml
|
||||
|
||||
# 下列环境变量按需启用,并优先于 YAML 中的配置。
|
||||
# DATABASE_BASE_URL=postgresql+psycopg://localhost:5432/yg_ft
|
||||
# DATABASE_USERNAME=yg_ft
|
||||
# DATABASE_PASSWORD=change_me
|
||||
# DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:5432/yg_ft
|
||||
# DATABASE_URL 是完整连接串覆盖项,优先级高于上面三个分离项。
|
||||
# MODELTF_ROUTE_PREFIX=/modelTF
|
||||
# FRONTEND_PORT=16801
|
||||
# BACKEND_PORT=17861
|
||||
# CORS_ALLOW_ORIGINS=http://localhost:16801,http://127.0.0.1:16801
|
||||
# LOG_LEVEL=INFO
|
||||
# LOG_DIR=./logs
|
||||
# COMPUTE_MODE=real
|
||||
# COMPUTE_STATUS_SYNC_MODE=polling
|
||||
# COMPUTE_POLL_INTERVAL_SECONDS=3
|
||||
|
||||
@@ -32,17 +32,65 @@ backend/
|
||||
services/ # 跨模块应用服务
|
||||
workers/ # 后台任务入口
|
||||
requirements.txt # 后端第三方依赖
|
||||
config.example.yaml # 可提交的脱敏配置模板
|
||||
config.yaml # 本地配置,已忽略,环境变量可覆盖
|
||||
logs/ # 本地开发日志目录,生产环境建议挂载到独立日志盘
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
先复制本地配置并填写数据库凭据:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
后端默认读取 `config.yaml`,配置优先级为:
|
||||
|
||||
```text
|
||||
环境变量 > BACKEND_CONFIG_FILE 指定的 YAML > backend/config.yaml > 代码默认值
|
||||
```
|
||||
|
||||
前后端端口、数据库地址、跨域来源、日志路径和计算状态轮询参数均可在 YAML 中
|
||||
配置。敏感信息或不同环境的差异建议通过环境变量覆盖,不要写入仓库。YAML 中的
|
||||
相对日志路径以该 YAML 文件所在目录为基准。使用其他配置文件时:
|
||||
|
||||
```bash
|
||||
BACKEND_CONFIG_FILE=./config.prod.yaml uvicorn app.main:app --reload --port 17861
|
||||
```
|
||||
|
||||
本机直连 PostgreSQL 时,数据库配置拆分为地址、用户名和密码:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
frontend_port: 16801
|
||||
backend_port: 17861
|
||||
|
||||
database:
|
||||
url: postgresql+psycopg://localhost:5432/yg_ft
|
||||
username: yg_ft
|
||||
password: "change_me"
|
||||
```
|
||||
|
||||
服务启动时会安全拼装完整连接串;用户名或密码中的特殊字符会自动编码。Docker
|
||||
和生产环境仍可用完整的 `DATABASE_URL` 环境变量覆盖以上三项。
|
||||
|
||||
这里的 `5432` 是宿主机 PostgreSQL 默认端口;如果数据库容器映射到 `15432`,
|
||||
将 YAML 中的端口改为 `15432` 即可。
|
||||
|
||||
`server.frontend_port` 和 `server.backend_port` 由根目录的
|
||||
`scripts/start-dev.sh` 读取,并分别传给 Vite 和 Uvicorn。未配置
|
||||
`app.cors_allow_origins` 时,本机 CORS 来源会跟随前端端口。直接手工执行
|
||||
`uvicorn` 时仍需通过 `--port` 指定监听端口。
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
/opt/miniconda3/bin/python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
uvicorn app.main:app --reload --port 17861
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
@@ -1,57 +1,326 @@
|
||||
from dataclasses import dataclass
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_CONFIG_PATH = BACKEND_ROOT / "config.yaml"
|
||||
DEFAULT_DATABASE_BASE_URL = "postgresql+psycopg://localhost:5432/yg_ft"
|
||||
DEFAULT_DATABASE_USERNAME = "yg_ft"
|
||||
DEFAULT_DATABASE_PASSWORD = "change_me"
|
||||
|
||||
|
||||
class ConfigurationError(ValueError):
|
||||
"""Raised when the backend configuration cannot be parsed or validated."""
|
||||
|
||||
|
||||
def _resolve_config_path(config_path: str | Path | None = None) -> tuple[Path, bool]:
|
||||
configured_path = config_path or os.getenv("BACKEND_CONFIG_FILE")
|
||||
is_explicit = configured_path is not None
|
||||
path = Path(configured_path).expanduser() if configured_path else DEFAULT_CONFIG_PATH
|
||||
if not path.is_absolute():
|
||||
path = BACKEND_ROOT / path
|
||||
return path.resolve(), is_explicit
|
||||
|
||||
|
||||
def _load_yaml(config_path: str | Path | None = None) -> tuple[dict[str, Any], Path]:
|
||||
path, is_explicit = _resolve_config_path(config_path)
|
||||
if not path.exists():
|
||||
if is_explicit:
|
||||
raise ConfigurationError(f"Backend config file does not exist: {path}")
|
||||
return {}, path
|
||||
|
||||
try:
|
||||
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as exc:
|
||||
raise ConfigurationError(f"Invalid YAML in backend config file {path}: {exc}") from exc
|
||||
|
||||
if loaded is None:
|
||||
return {}, path
|
||||
if not isinstance(loaded, dict):
|
||||
raise ConfigurationError(f"Backend config root must be a mapping: {path}")
|
||||
return loaded, path
|
||||
|
||||
|
||||
def _yaml_value(config: dict[str, Any], section: str, key: str, default: Any) -> Any:
|
||||
section_value = config.get(section, {})
|
||||
if section_value is None:
|
||||
return default
|
||||
return int(raw)
|
||||
if not isinstance(section_value, dict):
|
||||
raise ConfigurationError(f"Config section '{section}' must be a mapping")
|
||||
return section_value.get(key, default)
|
||||
|
||||
|
||||
def _list_env(name: str, default: list[str]) -> list[str]:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return default
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
def _string_setting(
|
||||
env_name: str,
|
||||
config: dict[str, Any],
|
||||
section: str,
|
||||
key: str,
|
||||
default: str,
|
||||
) -> str:
|
||||
env_value = os.getenv(env_name)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
value = _yaml_value(config, section, key, default)
|
||||
return default if value is None else str(value)
|
||||
|
||||
|
||||
def _int_setting(
|
||||
env_name: str,
|
||||
config: dict[str, Any],
|
||||
section: str,
|
||||
key: str,
|
||||
default: int,
|
||||
) -> int:
|
||||
env_value = os.getenv(env_name)
|
||||
value = (
|
||||
env_value
|
||||
if env_value not in (None, "")
|
||||
else _yaml_value(config, section, key, default)
|
||||
)
|
||||
if isinstance(value, (bool, float)):
|
||||
raise ConfigurationError(f"Config value {section}.{key} must be an integer")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConfigurationError(f"Config value {section}.{key} must be an integer") from exc
|
||||
|
||||
|
||||
def _port_setting(
|
||||
env_name: str,
|
||||
config: dict[str, Any],
|
||||
key: str,
|
||||
default: int,
|
||||
) -> int:
|
||||
port = _int_setting(env_name, config, "server", key, default)
|
||||
if not 1 <= port <= 65535:
|
||||
raise ConfigurationError(
|
||||
f"Config value server.{key} must be between 1 and 65535"
|
||||
)
|
||||
return port
|
||||
|
||||
|
||||
def _list_setting(
|
||||
env_name: str,
|
||||
config: dict[str, Any],
|
||||
section: str,
|
||||
key: str,
|
||||
default: list[str],
|
||||
) -> list[str]:
|
||||
env_value = os.getenv(env_name)
|
||||
value = (
|
||||
env_value
|
||||
if env_value and env_value.strip()
|
||||
else _yaml_value(config, section, key, default)
|
||||
)
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
raise ConfigurationError(
|
||||
f"Config value {section}.{key} must be a list or comma-separated string"
|
||||
)
|
||||
|
||||
|
||||
def _path_setting(
|
||||
env_name: str,
|
||||
config: dict[str, Any],
|
||||
section: str,
|
||||
key: str,
|
||||
default: str,
|
||||
config_dir: Path,
|
||||
) -> str:
|
||||
env_value = os.getenv(env_name)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
value = Path(str(_yaml_value(config, section, key, default))).expanduser()
|
||||
if not value.is_absolute():
|
||||
value = config_dir / value
|
||||
return str(value.resolve())
|
||||
|
||||
|
||||
def _build_database_url(base_url: str, username: str, password: str) -> str:
|
||||
try:
|
||||
parsed = urlsplit(base_url)
|
||||
host = parsed.hostname
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ConfigurationError("Config value database.url is not a valid URL") from exc
|
||||
|
||||
if not parsed.scheme or not host:
|
||||
raise ConfigurationError(
|
||||
"Config value database.url must include a scheme and host"
|
||||
)
|
||||
if parsed.scheme not in {"postgresql", "postgresql+psycopg"}:
|
||||
raise ConfigurationError(
|
||||
"Config value database.url must use postgresql or postgresql+psycopg"
|
||||
)
|
||||
if parsed.path in {"", "/"}:
|
||||
raise ConfigurationError("Config value database.url must include a database name")
|
||||
if not username:
|
||||
raise ConfigurationError("Config value database.username cannot be empty")
|
||||
if not password:
|
||||
raise ConfigurationError("Config value database.password cannot be empty")
|
||||
|
||||
formatted_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||
host_and_port = f"{formatted_host}:{port}" if port is not None else formatted_host
|
||||
credentials = f"{quote(username, safe='')}:{quote(password, safe='')}"
|
||||
parts = (
|
||||
parsed.scheme,
|
||||
f"{credentials}@{host_and_port}",
|
||||
parsed.path,
|
||||
parsed.query,
|
||||
parsed.fragment,
|
||||
)
|
||||
return urlunsplit(parts)
|
||||
|
||||
|
||||
def _database_url(config: dict[str, Any]) -> str:
|
||||
# 保留原有完整连接串环境变量,便于 Docker 和生产环境注入密钥。
|
||||
complete_url_override = os.getenv("DATABASE_URL")
|
||||
if complete_url_override is not None:
|
||||
return complete_url_override
|
||||
|
||||
base_url = _string_setting(
|
||||
"DATABASE_BASE_URL",
|
||||
config,
|
||||
"database",
|
||||
"url",
|
||||
DEFAULT_DATABASE_BASE_URL,
|
||||
)
|
||||
username = _string_setting(
|
||||
"DATABASE_USERNAME",
|
||||
config,
|
||||
"database",
|
||||
"username",
|
||||
DEFAULT_DATABASE_USERNAME,
|
||||
)
|
||||
password = _string_setting(
|
||||
"DATABASE_PASSWORD",
|
||||
config,
|
||||
"database",
|
||||
"password",
|
||||
DEFAULT_DATABASE_PASSWORD,
|
||||
)
|
||||
return _build_database_url(base_url, username, password)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
app_name: str = os.getenv("APP_NAME", "YG Fine-Tune Platform API")
|
||||
app_env: str = os.getenv("APP_ENV", "local")
|
||||
route_prefix: str = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF")
|
||||
app_mode: str = os.getenv("APP_MODE", "local")
|
||||
database_url: str = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft")
|
||||
cors_allow_origins: list[str] = None # type: ignore[assignment]
|
||||
compute_mode: str = os.getenv("COMPUTE_MODE", "real")
|
||||
compute_status_sync_mode: str = os.getenv("COMPUTE_STATUS_SYNC_MODE", "polling")
|
||||
compute_poll_interval_seconds: int = _int_env("COMPUTE_POLL_INTERVAL_SECONDS", 3)
|
||||
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
log_dir: str = os.getenv("LOG_DIR", "./logs")
|
||||
log_file_prefix: str = os.getenv("LOG_FILE_PREFIX", "backend")
|
||||
log_error_file_prefix: str = os.getenv("LOG_ERROR_FILE_PREFIX", "error")
|
||||
log_max_bytes: int = _int_env("LOG_MAX_BYTES", 20 * 1024 * 1024)
|
||||
log_retention_days: int = _int_env("LOG_RETENTION_DAYS", 10)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"cors_allow_origins",
|
||||
_list_env(
|
||||
"CORS_ALLOW_ORIGINS",
|
||||
[
|
||||
app_name: str = "YG Fine-Tune Platform API"
|
||||
app_env: str = "local"
|
||||
route_prefix: str = "/modelTF"
|
||||
app_mode: str = "local"
|
||||
frontend_port: int = 16801
|
||||
backend_port: int = 17861
|
||||
database_url: str = field(
|
||||
default="postgresql+psycopg://yg_ft:change_me@localhost:5432/yg_ft",
|
||||
repr=False,
|
||||
)
|
||||
cors_allow_origins: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
"http://localhost:16801",
|
||||
"http://127.0.0.1:16801",
|
||||
"http://localhost:17861",
|
||||
"http://127.0.0.1:17861",
|
||||
],
|
||||
]
|
||||
)
|
||||
compute_mode: str = "real"
|
||||
compute_status_sync_mode: str = "polling"
|
||||
compute_poll_interval_seconds: int = 3
|
||||
log_level: str = "INFO"
|
||||
log_dir: str = "./logs"
|
||||
log_file_prefix: str = "backend"
|
||||
log_error_file_prefix: str = "error"
|
||||
log_max_bytes: int = 20 * 1024 * 1024
|
||||
log_retention_days: int = 10
|
||||
|
||||
|
||||
def load_settings(config_path: str | Path | None = None) -> Settings:
|
||||
config, resolved_config_path = _load_yaml(config_path)
|
||||
frontend_port = _port_setting(
|
||||
"FRONTEND_PORT", config, "frontend_port", Settings.frontend_port
|
||||
)
|
||||
backend_port = _port_setting(
|
||||
"BACKEND_PORT", config, "backend_port", Settings.backend_port
|
||||
)
|
||||
if frontend_port == backend_port:
|
||||
raise ConfigurationError(
|
||||
"Config values server.frontend_port and server.backend_port must be different"
|
||||
)
|
||||
default_cors = [
|
||||
f"http://localhost:{frontend_port}",
|
||||
f"http://127.0.0.1:{frontend_port}",
|
||||
]
|
||||
return Settings(
|
||||
app_name=_string_setting("APP_NAME", config, "app", "name", Settings.app_name),
|
||||
app_env=_string_setting("APP_ENV", config, "app", "env", Settings.app_env),
|
||||
route_prefix=_string_setting(
|
||||
"MODELTF_ROUTE_PREFIX", config, "app", "route_prefix", Settings.route_prefix
|
||||
),
|
||||
app_mode=_string_setting("APP_MODE", config, "app", "mode", Settings.app_mode),
|
||||
frontend_port=frontend_port,
|
||||
backend_port=backend_port,
|
||||
database_url=_database_url(config),
|
||||
cors_allow_origins=_list_setting(
|
||||
"CORS_ALLOW_ORIGINS", config, "app", "cors_allow_origins", default_cors
|
||||
),
|
||||
compute_mode=_string_setting(
|
||||
"COMPUTE_MODE", config, "compute", "mode", Settings.compute_mode
|
||||
),
|
||||
compute_status_sync_mode=_string_setting(
|
||||
"COMPUTE_STATUS_SYNC_MODE",
|
||||
config,
|
||||
"compute",
|
||||
"status_sync_mode",
|
||||
Settings.compute_status_sync_mode,
|
||||
),
|
||||
compute_poll_interval_seconds=_int_setting(
|
||||
"COMPUTE_POLL_INTERVAL_SECONDS",
|
||||
config,
|
||||
"compute",
|
||||
"poll_interval_seconds",
|
||||
Settings.compute_poll_interval_seconds,
|
||||
),
|
||||
log_level=_string_setting(
|
||||
"LOG_LEVEL", config, "logging", "level", Settings.log_level
|
||||
),
|
||||
log_dir=_path_setting(
|
||||
"LOG_DIR",
|
||||
config,
|
||||
"logging",
|
||||
"directory",
|
||||
Settings.log_dir,
|
||||
resolved_config_path.parent,
|
||||
),
|
||||
log_file_prefix=_string_setting(
|
||||
"LOG_FILE_PREFIX", config, "logging", "file_prefix", Settings.log_file_prefix
|
||||
),
|
||||
log_error_file_prefix=_string_setting(
|
||||
"LOG_ERROR_FILE_PREFIX",
|
||||
config,
|
||||
"logging",
|
||||
"error_file_prefix",
|
||||
Settings.log_error_file_prefix,
|
||||
),
|
||||
log_max_bytes=_int_setting(
|
||||
"LOG_MAX_BYTES", config, "logging", "max_bytes", Settings.log_max_bytes
|
||||
),
|
||||
log_retention_days=_int_setting(
|
||||
"LOG_RETENTION_DAYS",
|
||||
config,
|
||||
"logging",
|
||||
"retention_days",
|
||||
Settings.log_retention_days,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
return load_settings()
|
||||
|
||||
@@ -78,7 +78,10 @@ def verify_password(password: str, stored: str) -> tuple[bool, bool]:
|
||||
|
||||
|
||||
def _psycopg_url(database_url: str) -> str:
|
||||
return database_url.replace("postgresql+psycopg://", "postgresql://")
|
||||
sqlalchemy_prefix = "postgresql+psycopg://"
|
||||
if database_url.startswith(sqlalchemy_prefix):
|
||||
return f"postgresql://{database_url[len(sqlalchemy_prefix):]}"
|
||||
return database_url
|
||||
|
||||
|
||||
def _pg_sql(sql: str) -> str:
|
||||
@@ -1147,4 +1150,3 @@ def get_platform_store() -> PlatformStore:
|
||||
if _store is None:
|
||||
_store = PlatformStore()
|
||||
return _store
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft")
|
||||
|
||||
DATABASE_URL = get_settings().database_url
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
@@ -37,4 +38,3 @@ def session_scope() -> Generator[Session, None, None]:
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
31
backend/config.example.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# 后端配置模板。复制为 config.yaml 后填写本地数据库凭据。
|
||||
# config.yaml 已加入 Git 忽略,不会提交真实密码。
|
||||
|
||||
app:
|
||||
name: YG Fine-Tune Platform API
|
||||
env: local
|
||||
mode: local
|
||||
route_prefix: /modelTF
|
||||
|
||||
server:
|
||||
# 一键启动脚本统一读取这里的端口;端口被占用时只需修改这两项。
|
||||
frontend_port: 16801
|
||||
backend_port: 17861
|
||||
|
||||
database:
|
||||
url: postgresql+psycopg://localhost:5432/yg_ft
|
||||
username: yg_ft
|
||||
password: "change_me"
|
||||
|
||||
compute:
|
||||
mode: real
|
||||
status_sync_mode: polling
|
||||
poll_interval_seconds: 3
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
directory: ./logs
|
||||
file_prefix: backend
|
||||
error_file_prefix: error
|
||||
max_bytes: 20971520
|
||||
retention_days: 10
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"PyJWT>=2.8.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"python-dotenv>=1.0.1",
|
||||
"PyYAML>=6.0.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -10,3 +10,4 @@ httpx>=0.27.0
|
||||
PyJWT>=2.8.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-dotenv>=1.0.1
|
||||
PyYAML>=6.0.2
|
||||
|
||||
196
backend/tests/test_config.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import ConfigurationError, load_settings
|
||||
|
||||
|
||||
CONFIG_ENV_NAMES = (
|
||||
"APP_NAME",
|
||||
"APP_ENV",
|
||||
"APP_MODE",
|
||||
"MODELTF_ROUTE_PREFIX",
|
||||
"FRONTEND_PORT",
|
||||
"BACKEND_PORT",
|
||||
"DATABASE_URL",
|
||||
"DATABASE_BASE_URL",
|
||||
"DATABASE_USERNAME",
|
||||
"DATABASE_PASSWORD",
|
||||
"CORS_ALLOW_ORIGINS",
|
||||
"COMPUTE_MODE",
|
||||
"COMPUTE_STATUS_SYNC_MODE",
|
||||
"COMPUTE_POLL_INTERVAL_SECONDS",
|
||||
"LOG_LEVEL",
|
||||
"LOG_DIR",
|
||||
"LOG_FILE_PREFIX",
|
||||
"LOG_ERROR_FILE_PREFIX",
|
||||
"LOG_MAX_BYTES",
|
||||
"LOG_RETENTION_DAYS",
|
||||
)
|
||||
|
||||
|
||||
def clear_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for env_name in CONFIG_ENV_NAMES:
|
||||
monkeypatch.delenv(env_name, raising=False)
|
||||
|
||||
|
||||
def write_config(path: Path) -> None:
|
||||
path.write_text(
|
||||
"""
|
||||
app:
|
||||
name: YAML API
|
||||
route_prefix: /yaml-api
|
||||
cors_allow_origins:
|
||||
- http://yaml.example
|
||||
server:
|
||||
frontend_port: 18001
|
||||
backend_port: 18002
|
||||
database:
|
||||
url: postgresql+psycopg://db:5432/yaml
|
||||
username: yaml-user
|
||||
password: yaml-secret
|
||||
compute:
|
||||
mode: simulated
|
||||
poll_interval_seconds: 9
|
||||
logging:
|
||||
directory: ./yaml-logs
|
||||
max_bytes: 1024
|
||||
retention_days: 2
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_load_settings_from_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clear_config_env(monkeypatch)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config(config_path)
|
||||
|
||||
settings = load_settings(config_path)
|
||||
|
||||
assert settings.app_name == "YAML API"
|
||||
assert settings.route_prefix == "/yaml-api"
|
||||
assert settings.frontend_port == 18001
|
||||
assert settings.backend_port == 18002
|
||||
assert settings.database_url == "postgresql+psycopg://yaml-user:yaml-secret@db:5432/yaml"
|
||||
assert settings.cors_allow_origins == ["http://yaml.example"]
|
||||
assert settings.compute_mode == "simulated"
|
||||
assert settings.compute_poll_interval_seconds == 9
|
||||
assert settings.log_dir == str(tmp_path / "yaml-logs")
|
||||
assert settings.log_max_bytes == 1024
|
||||
assert settings.log_retention_days == 2
|
||||
|
||||
|
||||
def test_environment_overrides_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clear_config_env(monkeypatch)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config(config_path)
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://env:secret@db:5432/env")
|
||||
monkeypatch.setenv("CORS_ALLOW_ORIGINS", "http://one.example,http://two.example")
|
||||
monkeypatch.setenv("COMPUTE_POLL_INTERVAL_SECONDS", "15")
|
||||
monkeypatch.setenv("FRONTEND_PORT", "19001")
|
||||
monkeypatch.setenv("BACKEND_PORT", "19002")
|
||||
|
||||
settings = load_settings(config_path)
|
||||
|
||||
assert settings.database_url == "postgresql+psycopg://env:secret@db:5432/env"
|
||||
assert settings.cors_allow_origins == ["http://one.example", "http://two.example"]
|
||||
assert settings.compute_poll_interval_seconds == 15
|
||||
assert settings.frontend_port == 19001
|
||||
assert settings.backend_port == 19002
|
||||
|
||||
|
||||
def test_separate_database_credentials_are_encoded(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
clear_config_env(monkeypatch)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config(config_path)
|
||||
monkeypatch.setenv("DATABASE_USERNAME", "user@example.com")
|
||||
monkeypatch.setenv("DATABASE_PASSWORD", "secret:/?#[]@")
|
||||
|
||||
settings = load_settings(config_path)
|
||||
|
||||
assert settings.database_url == (
|
||||
"postgresql+psycopg://user%40example.com:secret%3A%2F%3F%23%5B%5D%40@db:5432/yaml"
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_missing_config_is_rejected(tmp_path: Path) -> None:
|
||||
with pytest.raises(ConfigurationError, match="does not exist"):
|
||||
load_settings(tmp_path / "missing.yaml")
|
||||
|
||||
|
||||
def test_default_cors_follows_frontend_port(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
clear_config_env(monkeypatch)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"server:\n frontend_port: 28001\n backend_port: 28002\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
settings = load_settings(config_path)
|
||||
|
||||
assert settings.cors_allow_origins == [
|
||||
"http://localhost:28001",
|
||||
"http://127.0.0.1:28001",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frontend_port", [0, 65536, "invalid", True])
|
||||
def test_invalid_server_port_is_rejected(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
frontend_port: object,
|
||||
) -> None:
|
||||
clear_config_env(monkeypatch)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"server:\n"
|
||||
f" frontend_port: {str(frontend_port).lower()}\n"
|
||||
" backend_port: 28002\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigurationError, match="server.frontend_port"):
|
||||
load_settings(config_path)
|
||||
|
||||
|
||||
def test_frontend_and_backend_ports_must_differ(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
clear_config_env(monkeypatch)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"server:\n frontend_port: 28001\n backend_port: 28001\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigurationError, match="must be different"):
|
||||
load_settings(config_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("database_base_url", "message"),
|
||||
[
|
||||
("mysql://localhost:3306/yg_ft", "must use postgresql"),
|
||||
("postgresql+psycopg://localhost:5432", "must include a database name"),
|
||||
],
|
||||
)
|
||||
def test_invalid_database_url_is_rejected(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_base_url: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
clear_config_env(monkeypatch)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config(config_path)
|
||||
monkeypatch.setenv("DATABASE_BASE_URL", database_base_url)
|
||||
|
||||
with pytest.raises(ConfigurationError, match=message):
|
||||
load_settings(config_path)
|
||||
@@ -11,7 +11,7 @@ RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
&& pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
&& rm -f /tmp/requirements.txt
|
||||
|
||||
RUN python -c "import fastapi, uvicorn, psycopg, sqlalchemy, redis, jwt, passlib, httpx, alembic; print('backend dependency check ok')"
|
||||
RUN python -c "import fastapi, uvicorn, psycopg, sqlalchemy, redis, yaml, jwt, passlib, httpx, alembic; print('backend dependency check ok')"
|
||||
|
||||
RUN mkdir -p /opt/yg-ft/logs/backend /data/yg-ft \
|
||||
&& chmod -R 0775 /opt/yg-ft /data/yg-ft
|
||||
|
||||
@@ -22,9 +22,9 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
开发服务器默认运行在 `http://localhost:16801`。
|
||||
|
||||
后端 API 默认通过 Vite 代理转发到 `http://localhost:17861`(见 `vite.config.ts`)。
|
||||
通过根目录 `scripts/start-dev.sh` 启动时,开发服务器端口和后端代理端口来自
|
||||
`backend/config.yaml` 的 `server` 配置。单独运行 `npm run dev` 时默认使用前端
|
||||
`16801` 和后端 `17861`;可用 `FRONTEND_PORT`、`BACKEND_PORT` 环境变量覆盖。
|
||||
|
||||
开发环境默认联调真实后端接口。如需进行隔离前端开发,可显式启用 Mock:
|
||||
|
||||
|
||||
BIN
frontend/public/guide/screenshots/data-process-create.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
frontend/public/guide/screenshots/data-process-preview.jpg
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
frontend/public/guide/screenshots/fine-tune-create.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
frontend/public/guide/screenshots/model-edit.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
frontend/public/guide/screenshots/service-dashboard.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
frontend/public/guide/screenshots/training-detail.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
@@ -54,7 +54,8 @@ assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作
|
||||
assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示')
|
||||
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数')
|
||||
assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称')
|
||||
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?grid:\s*\{\s*top:\s*8,\s*right:\s*12,\s*bottom:\s*6,\s*left:\s*8,[\s\S]*?max:\s*Math\.ceil\(Math\.max[\s\S]*?position:\s*['"]insideRight['"]/, '登录时长图应收紧左右边距、按数据范围拉伸,并将数值置于柱内')
|
||||
assert.match(dashboardSource, /const loginDurationAxisMax\s*=\s*Math\.ceil\([\s\S]*?Math\.max/, '登录时长图应按数据范围计算横轴上限')
|
||||
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?position:\s*['"]right['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长标签应统一显示在柱体外侧')
|
||||
assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列')
|
||||
assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列')
|
||||
assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列')
|
||||
|
||||
26
frontend/scripts/regression-login-layout.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
const loginPath = resolve(root, 'src/views/login/LoginView.vue')
|
||||
const heroAssetPath = resolve(root, 'src/assets/login-hero-flow.jpg')
|
||||
const login = readFileSync(loginPath, 'utf8')
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
assert(login.includes('class="login-visual"'), '登录页缺少左侧品牌视觉区域')
|
||||
assert(login.includes('class="login-panel"'), '登录页缺少右侧登录区域')
|
||||
assert(login.includes('远光软件微调平台'), '登录页缺少平台标题')
|
||||
assert(login.includes('大模型微调、评测与推理的一体化工作台'), '登录页缺少平台定位文案')
|
||||
assert(login.includes('login-hero-flow.jpg'), '登录页未使用压缩后的流线主视觉资源')
|
||||
assert(login.includes('@media (max-width: 1440px)'), '登录页缺少笔记本宽度适配规则')
|
||||
assert(login.includes('@media (max-height: 820px) and (min-width: 901px)'), '登录页缺少笔记本短屏适配规则')
|
||||
assert(login.includes('@media (max-width: 900px)'), '登录页缺少窄屏响应式规则')
|
||||
assert(/\.login-page\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/.test(login), '桌面端登录页不应产生页面滚动')
|
||||
assert(/@media \(max-width: 900px\)[\s\S]*?\.login-page\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*auto;/.test(login), '窄屏登录页应保留内容滚动能力')
|
||||
assert(existsSync(heroAssetPath), '登录页流线主视觉资源不存在')
|
||||
|
||||
console.log('login-layout regression checks passed')
|
||||
@@ -76,6 +76,7 @@ const selfSurfaceRoutes = [
|
||||
'data-process',
|
||||
'data-process/create',
|
||||
'dataset',
|
||||
'compute',
|
||||
'user-settings',
|
||||
]
|
||||
for (const routePath of selfSurfaceRoutes) {
|
||||
|
||||
87
frontend/scripts/regression-user-guide.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const sourceRoot = path.resolve(scriptDir, '../src')
|
||||
const publicRoot = path.resolve(scriptDir, '../public')
|
||||
const [headerSource, routerSource, guideSource, guideContentSource] = await Promise.all([
|
||||
readFile(path.join(sourceRoot, 'components/AppHeader.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/guide/GuideView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/guide/guideContent.ts'), 'utf8'),
|
||||
])
|
||||
|
||||
assert.match(headerSource, /fa-book/, '顶部栏缺少书状使用文档图标')
|
||||
assert.match(headerSource, /window\.open\([\s\S]*?['"]_blank['"]/, '使用文档应在浏览器新标签页打开')
|
||||
assert.match(headerSource, /aria-label=["']打开使用文档["']/, '使用文档图标缺少无障碍名称')
|
||||
assert.match(routerSource, /path:\s*['"]\/guide['"][\s\S]*?GuideView\.vue/, '缺少独立使用文档路由')
|
||||
assert.match(routerSource, /name:\s*['"]guide['"][\s\S]*?skipPermission:\s*true/, '使用文档应允许所有已登录用户访问')
|
||||
assert.ok(routerSource.indexOf("path: '/guide'") < routerSource.indexOf("path: '/'"), '使用文档不应嵌入主应用布局')
|
||||
assert.match(guideSource, /class="docs-shell"/, '使用文档缺少独立文档站布局')
|
||||
assert.match(guideSource, /class="docs-sidebar"/, '使用文档缺少左侧分级导航')
|
||||
assert.match(guideSource, /class="article-toc"/, '使用文档缺少右侧页内目录')
|
||||
assert.match(guideSource, /v-model="searchQuery"/, '使用文档缺少全局搜索能力')
|
||||
assert.match(guideSource, /scrollIntoView/, '使用文档目录缺少锚点导航能力')
|
||||
assert.match(guideSource, /class="architecture-flow"/, '使用文档缺少架构或流程图')
|
||||
assert.match(guideSource, /articlePresentation\.stepsTitle/, '文档步骤标题应根据页面类型动态展示')
|
||||
assert.match(guideSource, /查看型页面/, '文档缺少查看型页面说明')
|
||||
assert.match(guideSource, /排查型页面/, '文档缺少排查型页面说明')
|
||||
assert.match(guideSource, /class="step-screenshot"/, '使用文档缺少步骤内联页面截图')
|
||||
assert.match(guideSource, /loading="lazy"/, '页面截图应使用懒加载')
|
||||
assert.match(guideSource, /class="image-preview-overlay"/, '页面截图缺少大图预览')
|
||||
assert.match(guideSource, /aria-modal="true"/, '截图预览缺少无障碍对话框语义')
|
||||
assert.match(guideSource, /:inert="previewImage \? true : undefined"/, '截图预览打开时应禁用背景交互')
|
||||
assert.match(guideSource, /event\.key === 'Tab'/, '截图预览缺少键盘焦点循环')
|
||||
assert.match(guideSource, /:width="step\.screenshot\.width"/, '页面截图应使用真实固有尺寸')
|
||||
|
||||
for (const moduleName of [
|
||||
'服务看板',
|
||||
'模型训练',
|
||||
'模型评测',
|
||||
'模型推理',
|
||||
'模型管理',
|
||||
'数据集管理',
|
||||
'数据处理',
|
||||
'数据类型转换',
|
||||
'用户设置',
|
||||
'平台性能',
|
||||
'查看日志',
|
||||
]) {
|
||||
assert.ok(guideContentSource.includes(moduleName), `使用文档缺少模块:${moduleName}`)
|
||||
}
|
||||
|
||||
assert.match(guideSource, /操作步骤/, '使用文档缺少分步骤教程')
|
||||
assert.match(guideContentSource, /title:\s*'快速开始'/, '使用文档缺少完整快速开始教程')
|
||||
assert.match(guideContentSource, /architecture:/, '使用文档内容缺少架构流程数据')
|
||||
assert.match(guideContentSource, /steps:/, '使用文档内容缺少模块操作步骤')
|
||||
assert.match(guideContentSource, /id:\s*'dashboard'[\s\S]*?mode:\s*'overview'/, '服务看板应标记为查看型页面')
|
||||
assert.match(guideContentSource, /id:\s*'hardware'[\s\S]*?mode:\s*'overview'/, '平台性能应标记为查看型页面')
|
||||
assert.match(guideContentSource, /id:\s*'logs'[\s\S]*?mode:\s*'troubleshooting'/, '查看日志应标记为排查型页面')
|
||||
assert.match(guideContentSource, /screenshot:/, '使用文档步骤缺少真实页面截图引用')
|
||||
assert.match(guideContentSource, /status:\s*'建设中'/, '使用文档应如实标记未开放功能')
|
||||
assert.match(guideSource, /@media \(max-width: 900px\)/, '使用文档缺少窄屏响应式布局')
|
||||
|
||||
for (const screenshotName of [
|
||||
'service-dashboard.png',
|
||||
'fine-tune-create.jpg',
|
||||
'training-detail.jpg',
|
||||
'model-edit.jpg',
|
||||
'data-process-create.jpg',
|
||||
'data-process-preview.jpg',
|
||||
]) {
|
||||
const screenshotPath = path.join(publicRoot, 'guide/screenshots', screenshotName)
|
||||
const screenshot = await readFile(screenshotPath)
|
||||
assert.ok(screenshot.length > 10_000, `页面截图不存在或内容异常:${screenshotName}`)
|
||||
const isValidSignature = screenshotName.endsWith('.png')
|
||||
? screenshot.subarray(1, 4).toString() === 'PNG'
|
||||
: screenshot[0] === 0xff && screenshot[1] === 0xd8 && screenshot[2] === 0xff
|
||||
assert.ok(isValidSignature, `页面截图扩展名与文件格式不一致:${screenshotName}`)
|
||||
assert.ok(
|
||||
guideContentSource.includes(`/guide/screenshots/${screenshotName}`),
|
||||
`使用文档未引用页面截图:${screenshotName}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log('使用文档回归检查通过')
|
||||
116
frontend/scripts/regression-user-settings.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const sourceRoot = path.resolve(scriptDir, '../src')
|
||||
const [viewSource, createSource, permissionSource, sidebarSource, routerSource, apiSource, adapterSource, authSource, usersSource] = await Promise.all([
|
||||
readFile(path.join(sourceRoot, 'views/system/UserSettingsView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/system/UserCreateView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/system/UserPermissionView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'components/AppSidebar.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'api/modules/system.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'mock/adapter.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'stores/auth.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'mock/users.ts'), 'utf8'),
|
||||
])
|
||||
const userFeatureSource = [viewSource, createSource, permissionSource].join('\n')
|
||||
|
||||
const descriptor = parseSfc(viewSource).descriptor
|
||||
assert.ok(descriptor.template?.content, '用户设置页面缺少模板')
|
||||
assert.ok(descriptor.scriptSetup?.content, '用户设置页面缺少 script setup')
|
||||
|
||||
assert.match(sidebarSource, /label:\s*'用户设置'/, '系统设置菜单缺少用户设置入口')
|
||||
assert.match(sidebarSource, /to:\s*'\/user-settings'/, '用户设置菜单路由不正确')
|
||||
assert.match(sidebarSource, /visibleMenuGroups/, '侧边栏没有根据权限过滤菜单')
|
||||
assert.match(routerSource, /path:\s*'user-settings'/, '缺少用户设置路由')
|
||||
assert.match(routerSource, /permission:\s*'user-settings'/, '用户设置路由缺少权限声明')
|
||||
assert.match(routerSource, /permission-denied/, '缺少无权限访问反馈页面')
|
||||
assert.match(authSource, /hasPermission/, '认证状态缺少权限检查能力')
|
||||
|
||||
for (const marker of [
|
||||
'创建用户',
|
||||
'用户权限设置',
|
||||
'功能权限',
|
||||
'权限设置',
|
||||
'删除',
|
||||
'AppConfirmDialog',
|
||||
"tone: 'danger'",
|
||||
]) {
|
||||
assert.match(userFeatureSource, new RegExp(marker), `用户设置页面缺少关键交互:${marker}`)
|
||||
}
|
||||
assert.match(createSource, /createRules/, '创建用户缺少表单校验')
|
||||
assert.match(viewSource, /deleteDisabledReason/, '删除操作缺少当前用户和内置用户保护')
|
||||
assert.match(viewSource, /prefers-reduced-motion/, '页面未适配减少动态效果偏好')
|
||||
|
||||
for (const apiName of ['getUsers', 'createUser', 'deleteUser', 'updateUserAccess']) {
|
||||
assert.match(apiSource, new RegExp(`export const ${apiName}`), `用户 API 缺少 ${apiName}`)
|
||||
}
|
||||
assert.match(adapterSource, /url === '\/users' && method === 'get'/, 'Mock 缺少用户列表路由')
|
||||
assert.match(adapterSource, /url === '\/users' && method === 'post'/, 'Mock 缺少创建用户路由')
|
||||
assert.match(adapterSource, /userMatch && method === 'put'/, 'Mock 缺少权限更新路由')
|
||||
assert.match(adapterSource, /userMatch && method === 'delete'/, 'Mock 缺少删除用户路由')
|
||||
assert.match(usersSource, /mock:system-users:v1/, 'Mock 用户没有持久化存储')
|
||||
|
||||
const usersModuleCode = ts.transpileModule(usersSource, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
const usersModule = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(usersModuleCode).toString('base64')}`
|
||||
)
|
||||
|
||||
const initialUsers = usersModule.listMockUsers()
|
||||
assert.equal(initialUsers.length, 2, '初始状态应包含内置管理员和操作员')
|
||||
assert.equal(initialUsers[0].username, 'admin', '默认管理员账号应为 admin')
|
||||
assert.equal(initialUsers[0].permissions.length, 12, '默认管理员应拥有全部模块权限')
|
||||
assert.equal(initialUsers[1].username, 'operator', '默认操作员账号应为 operator')
|
||||
assert.ok(initialUsers[1].permissions.includes('compute'), '默认操作员应拥有算力节点权限')
|
||||
assert.ok(!initialUsers[1].permissions.includes('user-settings'), '默认操作员不应拥有用户管理权限')
|
||||
assert.ok(!('password' in initialUsers[0]), '用户列表不能向页面返回密码')
|
||||
|
||||
const operator = usersModule.createMockUser({
|
||||
username: 'operator01',
|
||||
display_name: '测试操作员',
|
||||
password: '123456',
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
})
|
||||
assert.equal(usersModule.listMockUsers().length, 3, '创建用户后列表数量应增加')
|
||||
assert.ok(!operator.permissions.includes('user-settings'), '操作员默认不应拥有用户管理权限')
|
||||
|
||||
assert.throws(
|
||||
() => usersModule.createMockUser({ username: 'OPERATOR01', display_name: '重复账号', password: '123456', role: 'viewer' }),
|
||||
/用户名已存在/,
|
||||
'用户名唯一校验应忽略大小写',
|
||||
)
|
||||
|
||||
const updated = usersModule.updateMockUserAccess(operator.id, {
|
||||
role: 'viewer',
|
||||
permissions: ['dashboard', 'dataset'],
|
||||
})
|
||||
assert.deepEqual(updated.permissions, ['dashboard', 'dataset'], '权限更新应准确保存勾选结果')
|
||||
assert.equal(updated.role, 'viewer', '权限设置应同步保存用户角色')
|
||||
|
||||
const loginResult = usersModule.authenticateMockUser('operator01', '123456')
|
||||
assert.equal(loginResult.user.id, operator.id, '新建用户应可使用初始密码登录')
|
||||
assert.ok(loginResult.user.last_login, '登录后应记录最近登录时间')
|
||||
|
||||
assert.throws(
|
||||
() => usersModule.deleteMockUser(operator.id, 'operator01'),
|
||||
/不能删除当前登录用户/,
|
||||
'不能删除当前登录账号',
|
||||
)
|
||||
assert.throws(
|
||||
() => usersModule.deleteMockUser(initialUsers[0].id, 'other-user'),
|
||||
/系统内置管理员不能删除/,
|
||||
'不能删除系统内置管理员',
|
||||
)
|
||||
|
||||
usersModule.deleteMockUser(operator.id, 'admin')
|
||||
assert.equal(usersModule.listMockUsers().length, 2, '确认删除后用户应从列表移除')
|
||||
|
||||
console.log('用户设置页回归检查通过')
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 187 KiB |
@@ -4,11 +4,13 @@ import type {
|
||||
PermissionCode,
|
||||
SystemUser,
|
||||
UpdateUserAccessPayload,
|
||||
UserRole,
|
||||
} from '@/types'
|
||||
|
||||
const STORAGE_KEY = 'mock:system-users'
|
||||
const LEGACY_STORAGE_KEY = 'mock:system-users:v1'
|
||||
|
||||
const allPermissions: PermissionCode[] = [
|
||||
export const ALL_PERMISSION_CODES: PermissionCode[] = [
|
||||
'dashboard',
|
||||
'fine-tune',
|
||||
'model-eval',
|
||||
@@ -23,30 +25,43 @@ const allPermissions: PermissionCode[] = [
|
||||
'user-settings',
|
||||
]
|
||||
|
||||
const defaultPasswords: Record<string, string> = {
|
||||
const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, PermissionCode[]> = {
|
||||
admin: ALL_PERMISSION_CODES,
|
||||
operator: ALL_PERMISSION_CODES.filter((permission) => permission !== 'user-settings'),
|
||||
viewer: ['dashboard', 'dataset', 'hardware', 'logs'],
|
||||
}
|
||||
|
||||
const DEFAULT_PASSWORDS: Record<string, string> = {
|
||||
admin: 'admin123',
|
||||
operator: 'operator123',
|
||||
}
|
||||
|
||||
export class UserMutationError extends Error {
|
||||
status: number
|
||||
interface StoredSystemUser extends SystemUser {
|
||||
password: string
|
||||
}
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
export class UserMutationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status = 400,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'UserMutationError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function defaultUsers(): SystemUser[] {
|
||||
let memoryUsers: StoredSystemUser[] | null = null
|
||||
|
||||
function createDefaultUsers(): StoredSystemUser[] {
|
||||
return [
|
||||
{
|
||||
id: 'u_admin',
|
||||
username: 'admin',
|
||||
display_name: 'Platform Admin',
|
||||
password: DEFAULT_PASSWORDS.admin,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
permissions: allPermissions,
|
||||
permissions: [...ALL_PERMISSION_CODES],
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
protected: true,
|
||||
},
|
||||
@@ -54,91 +69,206 @@ function defaultUsers(): SystemUser[] {
|
||||
id: 'u_operator',
|
||||
username: 'operator',
|
||||
display_name: 'Platform Operator',
|
||||
password: DEFAULT_PASSWORDS.operator,
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
permissions: allPermissions.filter((item) => item !== 'user-settings'),
|
||||
permissions: [...ROLE_DEFAULT_PERMISSIONS.operator],
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
protected: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function readUsers(): SystemUser[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return defaultUsers()
|
||||
try {
|
||||
const users = JSON.parse(raw) as SystemUser[]
|
||||
return users.length ? users : defaultUsers()
|
||||
} catch {
|
||||
return defaultUsers()
|
||||
function canUseLocalStorage() {
|
||||
return typeof localStorage !== 'undefined'
|
||||
}
|
||||
|
||||
function persistUsers(users: StoredSystemUser[]) {
|
||||
memoryUsers = users
|
||||
if (canUseLocalStorage()) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(users))
|
||||
}
|
||||
}
|
||||
|
||||
function writeUsers(users: SystemUser[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(users))
|
||||
function normalizePermissions(role: UserRole, permissions?: PermissionCode[]) {
|
||||
if (role === 'admin') return [...ALL_PERMISSION_CODES]
|
||||
const candidates = Array.isArray(permissions) ? permissions : ROLE_DEFAULT_PERMISSIONS[role]
|
||||
return ALL_PERMISSION_CODES.filter((permission) => candidates.includes(permission))
|
||||
}
|
||||
|
||||
function normalizeStoredUser(user: Partial<StoredSystemUser>): StoredSystemUser | null {
|
||||
if (!user.id || !user.username || !user.role) return null
|
||||
if (!['admin', 'operator', 'viewer'].includes(user.role)) return null
|
||||
const role = user.role as UserRole
|
||||
const username = String(user.username).trim()
|
||||
if (!username) return null
|
||||
return {
|
||||
id: String(user.id),
|
||||
username,
|
||||
display_name: String(user.display_name || username).trim(),
|
||||
password: String(user.password || DEFAULT_PASSWORDS[username] || 'platform123'),
|
||||
role,
|
||||
status: user.status === 'disabled' ? 'disabled' : 'active',
|
||||
permissions: normalizePermissions(role, user.permissions),
|
||||
create_time: user.create_time || new Date().toISOString(),
|
||||
last_login: user.last_login,
|
||||
protected: Boolean(user.protected),
|
||||
}
|
||||
}
|
||||
|
||||
function loadUsers(): StoredSystemUser[] {
|
||||
if (canUseLocalStorage()) {
|
||||
for (const storageKey of [STORAGE_KEY, LEGACY_STORAGE_KEY]) {
|
||||
const persisted = localStorage.getItem(storageKey)
|
||||
if (!persisted) continue
|
||||
try {
|
||||
const parsed = JSON.parse(persisted) as Partial<StoredSystemUser>[]
|
||||
const normalized = Array.isArray(parsed)
|
||||
? parsed.map(normalizeStoredUser).filter((user): user is StoredSystemUser => Boolean(user))
|
||||
: []
|
||||
if (normalized.length > 0) {
|
||||
persistUsers(normalized)
|
||||
return normalized
|
||||
}
|
||||
} catch {
|
||||
// 当前存储损坏时继续尝试旧版数据,全部不可用才恢复内置账号。
|
||||
}
|
||||
}
|
||||
} else if (memoryUsers) {
|
||||
return memoryUsers
|
||||
}
|
||||
|
||||
const users = createDefaultUsers()
|
||||
persistUsers(users)
|
||||
return users
|
||||
}
|
||||
|
||||
function publicUser(user: StoredSystemUser): SystemUser {
|
||||
const { password: _password, ...result } = user
|
||||
return {
|
||||
...result,
|
||||
permissions: [...result.permissions],
|
||||
}
|
||||
}
|
||||
|
||||
function nextUserId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `user-${crypto.randomUUID()}`
|
||||
}
|
||||
return `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function findUserOrThrow(users: StoredSystemUser[], id: string) {
|
||||
const user = users.find((item) => item.id === id)
|
||||
if (!user) throw new UserMutationError('用户不存在', 404)
|
||||
return user
|
||||
}
|
||||
|
||||
function isLastActiveAdmin(users: StoredSystemUser[], target: StoredSystemUser) {
|
||||
if (target.role !== 'admin' || target.status !== 'active') return false
|
||||
return users.filter((user) => user.role === 'admin' && user.status === 'active').length === 1
|
||||
}
|
||||
|
||||
function isLastAdmin(users: StoredSystemUser[], target: StoredSystemUser) {
|
||||
return target.role === 'admin' && users.filter((user) => user.role === 'admin').length === 1
|
||||
}
|
||||
|
||||
export function listMockUsers(): SystemUser[] {
|
||||
return readUsers()
|
||||
return loadUsers().map(publicUser)
|
||||
}
|
||||
|
||||
export function authenticateMockUser(username: string, password: string): LoginResponse {
|
||||
const users = readUsers()
|
||||
const user = users.find((item) => item.username === username)
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new UserMutationError('Invalid username or disabled account', 401)
|
||||
const users = loadUsers()
|
||||
const normalizedUsername = username.trim().toLocaleLowerCase()
|
||||
const user = users.find((item) => item.username.toLocaleLowerCase() === normalizedUsername)
|
||||
if (!user || user.password !== password) {
|
||||
throw new UserMutationError('账号或密码错误', 401)
|
||||
}
|
||||
const expected = defaultPasswords[username] || 'platform123'
|
||||
if (password !== expected) {
|
||||
throw new UserMutationError('Invalid username or password', 401)
|
||||
if (user.status === 'disabled') {
|
||||
throw new UserMutationError('该账号已被禁用,请联系管理员', 403)
|
||||
}
|
||||
|
||||
user.last_login = new Date().toISOString()
|
||||
writeUsers(users)
|
||||
persistUsers(users)
|
||||
return {
|
||||
token: `mock-token-${user.id}`,
|
||||
user,
|
||||
token: `mock-token-${user.id}-${Date.now()}`,
|
||||
user: publicUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockUser(payload: CreateUserPayload): SystemUser {
|
||||
const users = readUsers()
|
||||
if (users.some((item) => item.username === payload.username)) {
|
||||
throw new UserMutationError('Username already exists', 409)
|
||||
const users = loadUsers()
|
||||
const username = String(payload.username || '').trim()
|
||||
const displayName = String(payload.display_name || '').trim()
|
||||
if (!username) throw new UserMutationError('用户名不能为空')
|
||||
if (!displayName) throw new UserMutationError('用户名称不能为空')
|
||||
if (!payload.password) throw new UserMutationError('密码不能为空')
|
||||
if (!['admin', 'operator', 'viewer'].includes(payload.role)) {
|
||||
throw new UserMutationError('用户角色不正确')
|
||||
}
|
||||
const user: SystemUser = {
|
||||
id: `u_${Date.now()}`,
|
||||
username: payload.username,
|
||||
display_name: payload.display_name || payload.username,
|
||||
if (payload.status && !['active', 'disabled'].includes(payload.status)) {
|
||||
throw new UserMutationError('用户状态不正确')
|
||||
}
|
||||
if (users.some((user) => user.username.toLocaleLowerCase() === username.toLocaleLowerCase())) {
|
||||
throw new UserMutationError('用户名已存在', 409)
|
||||
}
|
||||
|
||||
const user: StoredSystemUser = {
|
||||
id: nextUserId(),
|
||||
username,
|
||||
display_name: displayName,
|
||||
password: payload.password,
|
||||
role: payload.role,
|
||||
status: payload.status || 'active',
|
||||
permissions: payload.permissions || [],
|
||||
status: payload.status ?? 'active',
|
||||
permissions: normalizePermissions(payload.role, payload.permissions),
|
||||
create_time: new Date().toISOString(),
|
||||
protected: false,
|
||||
}
|
||||
defaultPasswords[user.username] = payload.password || 'platform123'
|
||||
users.push(user)
|
||||
writeUsers(users)
|
||||
return user
|
||||
persistUsers([...users, user])
|
||||
return publicUser(user)
|
||||
}
|
||||
|
||||
export function updateMockUserAccess(id: string, payload: UpdateUserAccessPayload): SystemUser {
|
||||
const users = readUsers()
|
||||
const user = users.find((item) => item.id === id)
|
||||
if (!user) throw new UserMutationError('User not found', 404)
|
||||
if (payload.role) user.role = payload.role
|
||||
if (payload.status) user.status = payload.status
|
||||
if (payload.permissions) user.permissions = payload.permissions
|
||||
writeUsers(users)
|
||||
return user
|
||||
const users = loadUsers()
|
||||
const user = findUserOrThrow(users, id)
|
||||
if (user.protected) {
|
||||
throw new UserMutationError('系统内置管理员的状态和权限不可修改', 409)
|
||||
}
|
||||
if (payload.role && !['admin', 'operator', 'viewer'].includes(payload.role)) {
|
||||
throw new UserMutationError('用户角色不正确')
|
||||
}
|
||||
if (payload.status && !['active', 'disabled'].includes(payload.status)) {
|
||||
throw new UserMutationError('用户状态不正确')
|
||||
}
|
||||
const nextRole = payload.role ?? user.role
|
||||
const nextStatus = payload.status ?? user.status
|
||||
|
||||
if (isLastActiveAdmin(users, user) && (nextRole !== 'admin' || nextStatus !== 'active')) {
|
||||
throw new UserMutationError('不能停用或降级最后一个管理员', 409)
|
||||
}
|
||||
|
||||
const nextPermissions = payload.permissions
|
||||
?? (payload.role && payload.role !== user.role ? undefined : user.permissions)
|
||||
user.role = nextRole
|
||||
user.status = nextStatus
|
||||
user.permissions = normalizePermissions(nextRole, nextPermissions)
|
||||
persistUsers(users)
|
||||
return publicUser(user)
|
||||
}
|
||||
|
||||
export function deleteMockUser(id: string, currentUsername: string): { deleted: string } {
|
||||
const users = readUsers()
|
||||
const user = users.find((item) => item.id === id)
|
||||
if (!user) throw new UserMutationError('User not found', 404)
|
||||
if (user.protected || user.username === currentUsername) {
|
||||
throw new UserMutationError('Protected or current user cannot be deleted', 400)
|
||||
const users = loadUsers()
|
||||
const user = findUserOrThrow(users, id)
|
||||
if (user.protected) {
|
||||
throw new UserMutationError('系统内置管理员不能删除', 409)
|
||||
}
|
||||
writeUsers(users.filter((item) => item.id !== id))
|
||||
if (user.username.toLocaleLowerCase() === currentUsername.trim().toLocaleLowerCase()) {
|
||||
throw new UserMutationError('不能删除当前登录用户', 409)
|
||||
}
|
||||
if (isLastAdmin(users, user)) {
|
||||
throw new UserMutationError('不能删除最后一个管理员', 409)
|
||||
}
|
||||
|
||||
persistUsers(users.filter((item) => item.id !== id))
|
||||
return { deleted: id }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 平台性能页图表按需注册。 */
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { GaugeChart, LineChart } from 'echarts/charts'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 训练日志曲线按需注册。 */
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
|
||||
@@ -252,6 +252,9 @@ const loginDurationStats: LoginDurationStat[] = [
|
||||
{ id: 3, username: 'lisi', duration: 42 },
|
||||
{ id: 4, username: 'wangwu', duration: 18 },
|
||||
]
|
||||
const loginDurationAxisMax = Math.ceil(
|
||||
Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10,
|
||||
) * 10
|
||||
|
||||
const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
animationDuration: 500,
|
||||
@@ -263,7 +266,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10) * 10,
|
||||
max: loginDurationAxisMax,
|
||||
splitNumber: 4,
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
|
||||
axisLine: { show: false },
|
||||
@@ -286,7 +289,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
barMaxWidth: 18,
|
||||
barCategoryGap: '34%',
|
||||
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
|
||||
label: { show: true, position: 'insideRight', distance: 6, color: '#ffffff', fontSize: 11, formatter: '{c} 小时' },
|
||||
label: { show: true, position: 'right', distance: 6, color: '#475569', fontSize: 11, formatter: '{c} 小时' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
export interface BasicMetricSetupDraft {
|
||||
@@ -19,9 +19,30 @@ const form = defineModel<BasicMetricSetupDraft>({ required: true })
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const rules: FormRules<BasicMetricSetupDraft> = {
|
||||
bleu_n: [{ required: true, type: 'number', min: 1, max: 8, message: 'BLEU ngram must be between 1 and 8', trigger: 'change' }],
|
||||
output_precision: [{ required: true, type: 'number', min: 0, max: 8, message: 'Precision must be between 0 and 8', trigger: 'change' }],
|
||||
bleu_n: [
|
||||
{ required: true, type: 'number', min: 1, max: 8, message: 'BLEU n-gram 必须在 1 到 8 之间', trigger: 'change' },
|
||||
],
|
||||
rouge_methods: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (form.value.rouge_enabled && (!Array.isArray(value) || value.length === 0)) {
|
||||
callback(new Error('请至少选择一个 ROUGE 指标'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
output_precision: [
|
||||
{ required: true, type: 'number', min: 0, max: 8, message: '输出精度必须在 0 到 8 之间', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.value.rouge_enabled,
|
||||
() => formRef.value?.validateField('rouge_methods').catch(() => undefined),
|
||||
)
|
||||
|
||||
async function validate() {
|
||||
if (!formRef.value) return false
|
||||
@@ -40,39 +61,132 @@ defineExpose({ validate })
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
class="basic-metric-form"
|
||||
:disabled="disabled"
|
||||
label-position="top"
|
||||
>
|
||||
<el-form-item label="BLEU">
|
||||
<el-switch v-model="form.bleu_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.bleu_enabled" label="BLEU ngram" prop="bleu_n">
|
||||
<el-alert
|
||||
title="基础指标为可选参考项"
|
||||
description="可按需启用 BLEU、ROUGE 或余弦相似度;未启用时仅执行大模型评测指标。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="metric-alert"
|
||||
/>
|
||||
|
||||
<section class="metric-card" :class="{ 'is-enabled': form.bleu_enabled }">
|
||||
<div class="metric-card-header">
|
||||
<div>
|
||||
<h3>BLEU</h3>
|
||||
<p>基于 n-gram 精确率衡量生成内容与参考答案的重合程度。</p>
|
||||
</div>
|
||||
<el-switch v-model="form.bleu_enabled" aria-label="是否启用 BLEU" />
|
||||
</div>
|
||||
<div v-if="form.bleu_enabled" class="metric-options">
|
||||
<el-form-item label="n-gram" prop="bleu_n">
|
||||
<el-input-number v-model="form.bleu_n" :min="1" :max="8" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-form-item label="ROUGE">
|
||||
<el-switch v-model="form.rouge_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.rouge_enabled" label="ROUGE methods">
|
||||
<section class="metric-card" :class="{ 'is-enabled': form.rouge_enabled }">
|
||||
<div class="metric-card-header">
|
||||
<div>
|
||||
<h3>ROUGE</h3>
|
||||
<p>基于召回率衡量参考答案中的关键信息是否被生成内容覆盖。</p>
|
||||
</div>
|
||||
<el-switch v-model="form.rouge_enabled" aria-label="是否启用 ROUGE" />
|
||||
</div>
|
||||
<div v-if="form.rouge_enabled" class="metric-options">
|
||||
<el-form-item label="指标项" prop="rouge_methods">
|
||||
<el-checkbox-group v-model="form.rouge_methods">
|
||||
<el-checkbox value="rouge_1">ROUGE-1</el-checkbox>
|
||||
<el-checkbox value="rouge_2">ROUGE-2</el-checkbox>
|
||||
<el-checkbox value="rouge_l">ROUGE-L</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-form-item label="Cosine">
|
||||
<el-switch v-model="form.cosine_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Output precision" prop="output_precision">
|
||||
<section class="metric-card" :class="{ 'is-enabled': form.cosine_enabled }">
|
||||
<div class="metric-card-header">
|
||||
<div>
|
||||
<h3>Cosine 余弦相似度</h3>
|
||||
<p>比较生成内容与参考答案的向量方向,作为语义接近程度的参考。</p>
|
||||
</div>
|
||||
<el-switch v-model="form.cosine_enabled" aria-label="是否启用余弦相似度" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-form-item label="结果输出精度" prop="output_precision" class="precision-field">
|
||||
<el-input-number v-model="form.output_precision" :min="0" :max="8" />
|
||||
<div class="field-tip">用于统一已启用基础指标的结果展示精度,支持 0 到 8 位小数。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.basic-metric-form {
|
||||
max-width: 760px;
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.metric-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card.is-enabled {
|
||||
border-color: #a5b4fc;
|
||||
background: #fafaff;
|
||||
}
|
||||
|
||||
.metric-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.metric-card h3 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.metric-card p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.metric-options {
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric-options :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.precision-field {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { DIMENSION_TYPE_MAP } from '@/constants'
|
||||
import { EVAL_METHODS } from '@/constants/dimension'
|
||||
import type { DatasetItem, GpuInfo, ModelItem, TrainedModel } from '@/types'
|
||||
import type { BasicMetricSetupDraft } from './BasicMetricSetupStep.vue'
|
||||
import type { EvalRuleSetupDraft } from './EvalRuleSetupStep.vue'
|
||||
@@ -14,31 +17,86 @@ const props = defineProps<{
|
||||
gpus: GpuInfo[]
|
||||
}>()
|
||||
|
||||
function nameOf<T extends { id: string | number; name?: string }>(items: T[], id: string | number) {
|
||||
return items.find((item) => item.id === id)?.name || String(id || '-')
|
||||
const taskModelName = computed(
|
||||
() => props.trainedModels.find((model) => String(model.id) === String(props.task.model_id))?.name || '未选择',
|
||||
)
|
||||
const datasetName = computed(() => {
|
||||
if (props.task.data_source === 'inference') return '最近一次可用的推理结果集'
|
||||
return props.evalDatasets.find((dataset) => String(dataset.id) === String(props.task.dataset_id))?.name || '未选择'
|
||||
})
|
||||
const gpuName = computed(() => {
|
||||
const matched = props.gpus.find((gpu) => String(gpu.id) === String(props.task.gpu_id))
|
||||
|| props.gpus[Number(props.task.gpu_id)]
|
||||
return matched ? `${matched.name}(GPU ${props.task.gpu_id})` : `GPU ${props.task.gpu_id}`
|
||||
})
|
||||
const llmMetricType = computed(() => DIMENSION_TYPE_MAP[props.llmMetric.type] || '未选择')
|
||||
const llmMetricMethod = computed(() => {
|
||||
const method = Array.isArray(props.llmMetric.eval_method)
|
||||
? props.llmMetric.eval_method[0]
|
||||
: props.llmMetric.eval_method
|
||||
return EVAL_METHODS[props.llmMetric.type]?.find((item) => item.value === method)?.name || method || '未选择'
|
||||
})
|
||||
const evaluatorName = computed(() => {
|
||||
const selected = props.evalModels.find(
|
||||
(model) => model.name === props.llmMetric.eval_model || String(model.id) === String(props.llmMetric.eval_model),
|
||||
)
|
||||
return selected?.name || props.llmMetric.eval_model || '未选择'
|
||||
})
|
||||
const enabledBasicMetrics = computed(() => {
|
||||
const labels: string[] = []
|
||||
if (props.basicMetrics.bleu_enabled) labels.push(`BLEU-${props.basicMetrics.bleu_n}`)
|
||||
if (props.basicMetrics.rouge_enabled) {
|
||||
labels.push(...props.basicMetrics.rouge_methods.map((method) => method.replace('_', '-').toUpperCase()))
|
||||
}
|
||||
if (props.basicMetrics.cosine_enabled) labels.push('Cosine')
|
||||
return labels
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="start-eval-step">
|
||||
<el-alert
|
||||
title="配置已完成"
|
||||
description="请确认以下信息,点击底部“开始评测”后将立即创建并启动评测任务。"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="ready-alert"
|
||||
/>
|
||||
|
||||
<section class="summary-section">
|
||||
<h3>任务信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="Task">{{ props.task.eval_task_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Model">{{ nameOf(props.trainedModels, props.task.model_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="GPU">GPU {{ props.task.gpu_id || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Dataset">
|
||||
{{ props.task.data_source === 'dataset' ? nameOf(props.evalDatasets, props.task.dataset_id) : 'Inference results' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Eval model">{{ nameOf(props.evalModels, props.llmMetric.eval_model || '') }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Eval method">{{ props.llmMetric.eval_method || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Basic metrics" :span="2">
|
||||
<el-space wrap>
|
||||
<el-tag v-if="props.basicMetrics.bleu_enabled">BLEU-{{ props.basicMetrics.bleu_n }}</el-tag>
|
||||
<el-tag v-if="props.basicMetrics.rouge_enabled">{{ props.basicMetrics.rouge_methods.join(', ') }}</el-tag>
|
||||
<el-tag v-if="props.basicMetrics.cosine_enabled">Cosine</el-tag>
|
||||
<el-tag effect="plain">Precision {{ props.basicMetrics.output_precision }}</el-tag>
|
||||
</el-space>
|
||||
<el-descriptions-item label="任务名称">{{ task.eval_task_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="评测模型">{{ taskModelName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据来源">{{ datasetName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计算资源">{{ gpuName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="加入排行榜">{{ task.leaderboard ? '是' : '否' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
|
||||
<section class="summary-section">
|
||||
<h3>大模型评测指标</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="指标类型">{{ llmMetricType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="评测大模型">{{ evaluatorName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="评估方式">{{ llmMetricMethod }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="llmMetric.type === 'metric'" label="通过阈值">
|
||||
{{ llmMetric.pass_threshold }}({{ llmMetric.score_min }}–{{ llmMetric.score_max }})
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
|
||||
<section class="summary-section">
|
||||
<h3>基础评测指标</h3>
|
||||
<div v-if="enabledBasicMetrics.length" class="metric-tags">
|
||||
<el-tag v-for="metric in enabledBasicMetrics" :key="metric" type="info" effect="plain">
|
||||
{{ metric }}
|
||||
</el-tag>
|
||||
<span class="precision-copy">输出精度:{{ basicMetrics.output_precision }} 位小数</span>
|
||||
</div>
|
||||
<el-empty v-else description="未启用基础评测指标,将仅执行大模型评测" :image-size="56" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -46,4 +104,53 @@ function nameOf<T extends { id: string | number; name?: string }>(items: T[], id
|
||||
.start-eval-step {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.ready-alert {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.summary-section + .summary-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.summary-section h3 {
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #5146e5;
|
||||
margin: 0 0 12px;
|
||||
color: #1e293b;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.metric-tags {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.precision-copy {
|
||||
margin-left: auto;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.summary-section :deep(.el-descriptions__body .el-descriptions__table) {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.metric-tags {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.precision-copy {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
381
frontend/src/views/guide/guideContent.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
export interface GuideScreenshot {
|
||||
src: string
|
||||
alt: string
|
||||
caption: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface GuideStep {
|
||||
title: string
|
||||
description: string
|
||||
screenshot?: GuideScreenshot
|
||||
}
|
||||
|
||||
export type GuideArticleMode = 'workflow' | 'overview' | 'troubleshooting'
|
||||
|
||||
export interface GuideArticle {
|
||||
id: string
|
||||
group: string
|
||||
mode: GuideArticleMode
|
||||
title: string
|
||||
summary: string
|
||||
capabilities: string[]
|
||||
architecture: string[]
|
||||
prerequisites: string[]
|
||||
steps: GuideStep[]
|
||||
validation: string[]
|
||||
notice?: string
|
||||
status?: '建设中'
|
||||
}
|
||||
|
||||
export interface GuideNavigationGroup {
|
||||
title: string
|
||||
articleIds: string[]
|
||||
}
|
||||
|
||||
const guideScreenshots = {
|
||||
serviceDashboard: {
|
||||
src: '/guide/screenshots/service-dashboard.png',
|
||||
alt: '远光软件微调平台服务看板主内容,展示运行指标、服务状态和训练任务列表',
|
||||
caption: '服务看板:集中查看平台指标、服务状态和最近训练任务。',
|
||||
width: 1243,
|
||||
height: 1058,
|
||||
},
|
||||
fineTuneCreate: {
|
||||
src: '/guide/screenshots/fine-tune-create.jpg',
|
||||
alt: '远光软件微调平台创建训练任务主内容',
|
||||
caption: '创建训练任务:在同一页面配置模型、数据集、GPU 和训练参数。',
|
||||
width: 1040,
|
||||
height: 720,
|
||||
},
|
||||
trainingDetail: {
|
||||
src: '/guide/screenshots/training-detail.jpg',
|
||||
alt: '远光软件微调平台训练任务详情主内容,展示任务信息和训练指标',
|
||||
caption: '训练任务详情:先核对任务信息与训练参数,再继续向下查看指标和日志。',
|
||||
width: 1200,
|
||||
height: 900,
|
||||
},
|
||||
modelEdit: {
|
||||
src: '/guide/screenshots/model-edit.jpg',
|
||||
alt: '远光软件微调平台编辑模型配置主内容',
|
||||
caption: '模型编辑页:配置模型用途、来源、本地路径和说明。',
|
||||
width: 1200,
|
||||
height: 1024,
|
||||
},
|
||||
dataProcessCreate: {
|
||||
src: '/guide/screenshots/data-process-create.jpg',
|
||||
alt: '远光软件微调平台新建数据处理任务主内容',
|
||||
caption: '新建数据处理任务:通过四步向导选择处理类型并上传源数据。',
|
||||
width: 1200,
|
||||
height: 1024,
|
||||
},
|
||||
dataProcessPreview: {
|
||||
src: '/guide/screenshots/data-process-preview.jpg',
|
||||
alt: '远光软件微调平台数据处理切分预览主内容',
|
||||
caption: '数据预览:定位切片并检查、编辑切分后的样本内容。',
|
||||
width: 1200,
|
||||
height: 1024,
|
||||
},
|
||||
}
|
||||
|
||||
export const guideArticles: GuideArticle[] = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
group: '入门',
|
||||
mode: 'workflow',
|
||||
title: '快速开始',
|
||||
summary: '通过一次完整的“模型准备 → 数据准备 → 模型训练 → 评测与推理”流程,快速理解微调平台的核心使用方式。',
|
||||
capabilities: [
|
||||
'理解平台各模块在模型微调生命周期中的位置',
|
||||
'完成一个最小可用的训练任务并查看运行状态',
|
||||
'使用评测与推理验证训练后的模型效果',
|
||||
],
|
||||
architecture: ['模型管理', '数据集管理', '模型训练', '模型评测', '模型推理', '性能与日志'],
|
||||
prerequisites: [
|
||||
'已获得平台账号,并拥有模型管理、数据集管理、模型训练相关权限。',
|
||||
'已准备可访问的基座模型,以及符合训练模板的 JSON 或 JSONL 数据。',
|
||||
'平台至少有一张空闲 GPU,显存能够满足所选模型和训练方法。',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: '登记或确认基座模型',
|
||||
description: '进入“模型管理”,确认目标基座模型已存在且路径可访问;若不存在,先添加模型并设置模型类型、用途和来源。',
|
||||
screenshot: guideScreenshots.modelEdit,
|
||||
},
|
||||
{
|
||||
title: '上传并检查训练数据',
|
||||
description: '进入“数据集管理”上传 JSON/JSONL 文件,在预览页检查字段、编码和样本内容,确保数据格式与训练模板一致。',
|
||||
},
|
||||
{
|
||||
title: '创建训练任务',
|
||||
description: '进入“模型训练”,选择基座模型、数据集、训练类型、训练方法和 GPU,再配置学习率、批次大小与训练轮次。',
|
||||
screenshot: guideScreenshots.fineTuneCreate,
|
||||
},
|
||||
{
|
||||
title: '启动并观察训练',
|
||||
description: '核对配置后启动任务,通过任务详情观察 Loss、学习率、进度、GPU 使用情况和原始日志;异常时先停止任务再调整参数。',
|
||||
screenshot: guideScreenshots.trainingDetail,
|
||||
},
|
||||
{
|
||||
title: '评测训练结果',
|
||||
description: '训练完成并合并权重后创建评测任务,选择评测数据集与指标,查看综合评分、维度表现和逐条样本结果。',
|
||||
},
|
||||
{
|
||||
title: '启动推理验证',
|
||||
description: '创建推理服务并加载训练模型,在对话页用真实问题测试回答质量,按需调整 Temperature、Top P 和系统提示词。',
|
||||
},
|
||||
],
|
||||
validation: [
|
||||
'训练任务状态为“已完成”,训练日志中没有未处理的错误。',
|
||||
'模型权重已完成合并,可被评测和推理模块选择。',
|
||||
'评测结果达到预期,推理对话能够稳定回答目标场景问题。',
|
||||
],
|
||||
notice: '首次验证建议使用小规模数据集和 LoRA 训练,先跑通完整流程,再逐步扩大数据量和训练规模。',
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
group: '工作台',
|
||||
mode: 'overview',
|
||||
title: '服务看板',
|
||||
summary: '集中查看平台运行状态、训练趋势、模型服务、用户活跃情况和最近训练任务。',
|
||||
capabilities: ['平台健康与关键指标概览', '近 7 天训练趋势和服务状态', '最近任务、用户操作和登录情况'],
|
||||
architecture: ['资源与任务数据', '指标聚合', '服务看板', '任务详情 / 平台性能'],
|
||||
prerequisites: ['拥有服务看板访问权限。', '需要查看真实运行数据时,确认相关监控和任务服务已经正常连接。'],
|
||||
steps: [
|
||||
{
|
||||
title: '先看总体状态',
|
||||
description: '先查看在线服务、运行中任务和待处理告警,快速判断平台是否处于健康状态。',
|
||||
screenshot: guideScreenshots.serviceDashboard,
|
||||
},
|
||||
{ title: '再看训练趋势', description: '结合近 7 天训练次数、GPU 使用数和平均准确率,判断资源负载和训练质量变化。' },
|
||||
{ title: '对照服务状态', description: '查看模型推理、模型训练、模型评测和数据处理服务的状态与实例数量,确认异常来自哪个服务。' },
|
||||
{ title: '需要时下钻详情', description: '看板只负责发现问题;需要进一步处理时,再从训练任务进入详情或模型训练列表。' },
|
||||
],
|
||||
validation: [
|
||||
'能够判断平台整体是否健康,以及是否存在待处理告警。',
|
||||
'能够定位需要关注的训练趋势、异常服务或失败任务。',
|
||||
'需要处理时,能够从看板进入对应业务详情继续排查。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fine-tune',
|
||||
group: '模型服务',
|
||||
mode: 'workflow',
|
||||
title: '模型训练',
|
||||
summary: '创建和管理 SFT、DPO、CPT 训练任务,支持 LoRA 或全参数微调、权重合并与训练后量化。',
|
||||
capabilities: ['基座模型、模板、数据集和 GPU 选择', '训练方法与超参数配置', '训练进度、指标、GPU 状态和日志跟踪'],
|
||||
architecture: ['任务配置', '资源校验', 'GPU 调度', '模型训练', '权重保存', '合并 / 量化'],
|
||||
prerequisites: ['模型管理中已配置可用的基座模型。', '数据集已通过格式检查。', '已确认所需 GPU 空闲且显存充足。'],
|
||||
steps: [
|
||||
{
|
||||
title: '创建任务并填写基本信息',
|
||||
description: '点击“创建训练任务”,填写名称,选择 SFT、DPO 或 CPT,并选择 LoRA 或全参数训练。',
|
||||
screenshot: guideScreenshots.fineTuneCreate,
|
||||
},
|
||||
{ title: '选择模型和数据', description: '选择基座模型、训练模板、训练数据集和目标 GPU;确认数据字段与模板要求一致。' },
|
||||
{ title: '配置训练参数', description: '设置学习率、批次大小、梯度累积、训练轮次、保存频率等参数;首次运行优先采用保守配置。' },
|
||||
{ title: '配置训练后处理', description: '按使用场景决定是否自动合并 LoRA 权重,以及是否在训练完成后执行量化。' },
|
||||
{
|
||||
title: '核对并启动任务',
|
||||
description: '检查生成的训练命令和资源配置,确认无误后启动训练;返回列表确认任务已创建并进入正确状态。',
|
||||
},
|
||||
{
|
||||
title: '跟踪与处理异常',
|
||||
description: '在列表查看进度,在详情页观察 Loss、梯度、学习率和 GPU 状态;出现 OOM 或数据错误时停止任务并修正配置。',
|
||||
screenshot: guideScreenshots.trainingDetail,
|
||||
},
|
||||
],
|
||||
validation: ['任务状态变为“已完成”。', '最终权重目录存在且文件完整。', 'Loss 变化合理,日志中没有 OOM、NaN 或数据解析错误。'],
|
||||
notice: '全参数训练资源开销较大。首次训练建议优先使用 LoRA,并通过小数据集验证参数和模板。',
|
||||
},
|
||||
{
|
||||
id: 'model-eval',
|
||||
group: '模型服务',
|
||||
mode: 'workflow',
|
||||
title: '模型评测',
|
||||
summary: '使用大模型裁判和基础指标评估训练模型,并查看综合评分、维度表现和样本级结果。',
|
||||
capabilities: ['评测模型、数据集和 GPU 配置', '大模型裁判与基础指标组合', '综合评价、维度分析和样本判定'],
|
||||
architecture: ['待评模型', '评测数据集', '裁判与指标', '评测执行', '结果聚合', '报告分析'],
|
||||
prerequisites: ['待评模型已经完成训练和权重合并。', '评测数据集包含可用于输入和参考答案的字段。', '大模型裁判模式下已配置可用的裁判模型。'],
|
||||
steps: [
|
||||
{ title: '创建评测任务', description: '点击“创建评测任务”,选择待评模型、评测数据集和运行 GPU。' },
|
||||
{ title: '配置裁判方式', description: '选择评测大模型,设置 Prompt、评分区间、评估规则和需要关注的业务维度。' },
|
||||
{ title: '选择基础指标', description: '根据任务类型启用 BLEU、ROUGE、余弦相似度等指标,避免使用与任务目标无关的指标。' },
|
||||
{ title: '启动并等待完成', description: '核对配置后启动评测,通过列表观察任务状态和执行进度。' },
|
||||
{ title: '分析评测结果', description: '在详情页查看综合评分、改进建议、维度表现和逐条样本判定,定位模型薄弱场景。' },
|
||||
],
|
||||
validation: ['评测任务正常完成且样本覆盖数量符合预期。', '综合评分与维度评分均有结果。', '抽查样本判定,确认裁判 Prompt 与业务标准一致。'],
|
||||
},
|
||||
{
|
||||
id: 'model-inference',
|
||||
group: '模型服务',
|
||||
mode: 'workflow',
|
||||
title: '模型推理',
|
||||
summary: '启动本地模型或训练模型的推理服务,通过多轮对话验证回答效果并进行模型对比。',
|
||||
capabilities: ['推理服务创建、加载与停止', '系统提示词和多轮对话', 'Temperature、Top P 与生成长度配置'],
|
||||
architecture: ['选择模型', '分配 GPU', '加载推理服务', '发送请求', '生成回答', '效果验证'],
|
||||
prerequisites: ['目标模型已在模型管理中配置,或训练模型已完成权重合并。', '至少有一张满足模型显存需求的 GPU。'],
|
||||
steps: [
|
||||
{ title: '新建推理任务', description: '填写推理任务名称,选择本地模型或已合并的训练模型,并指定运行 GPU。' },
|
||||
{ title: '加载模型服务', description: '创建后点击“加载”,等待模型完成初始化并进入“已就绪”状态。' },
|
||||
{ title: '配置推理参数', description: '在对话设置中填写系统提示词,并根据任务调整 Temperature、Top P 和最大生成长度。' },
|
||||
{ title: '进行多轮验证', description: '输入真实业务问题,观察回答准确性、格式稳定性、上下文保持能力和边界行为。' },
|
||||
{ title: '结束并释放资源', description: '验证完成后返回列表停止服务,避免持续占用 GPU。' },
|
||||
],
|
||||
validation: ['服务状态为“已就绪”且请求能够正常返回。', '模型回答符合系统提示词和目标格式。', '停止服务后对应 GPU 资源得到释放。'],
|
||||
},
|
||||
{
|
||||
id: 'model-manage',
|
||||
group: '模型服务',
|
||||
mode: 'workflow',
|
||||
title: '模型管理',
|
||||
summary: '统一维护基座模型、推理模型、评测模型,以及训练产生的 LoRA 权重和合并模型。',
|
||||
capabilities: ['本地模型与在线 API 模型配置', '模型用途、来源和路径管理', 'LoRA 权重合并、导出与删除'],
|
||||
architecture: ['模型来源', '模型登记', '用途配置', '训练 / 评测 / 推理', '权重合并', '导出归档'],
|
||||
prerequisites: ['本地模型路径对平台服务可访问。', '在线模型已准备正确的 API 地址、模型名称和鉴权信息。'],
|
||||
steps: [
|
||||
{ title: '选择模型类型', description: '进入“配置模型”,根据来源选择本地模型或在线 API 模型。' },
|
||||
{
|
||||
title: '填写模型配置',
|
||||
description: '设置名称、模型类型、使用场景、来源和路径;在线模型还需配置接口信息。',
|
||||
screenshot: guideScreenshots.modelEdit,
|
||||
},
|
||||
{ title: '验证模型可用性', description: '保存后确认模型出现在列表,并能够被对应的训练、评测或推理页面选择。' },
|
||||
{ title: '管理训练权重', description: '在“训练模型”中查看训练方法、基座模型和权重状态。' },
|
||||
{ title: '合并或导出模型', description: '对 LoRA 权重执行合并,完成后用于评测和推理;不再使用的权重可按需导出或删除。' },
|
||||
],
|
||||
validation: ['模型配置保存成功且路径或接口可访问。', '模型用途与可选择的业务模块一致。', '权重合并完成后生成独立可加载模型。'],
|
||||
},
|
||||
{
|
||||
id: 'dataset',
|
||||
group: '数据治理',
|
||||
mode: 'workflow',
|
||||
title: '数据集管理',
|
||||
summary: '管理训练集与评测集,支持格式校验、样本编辑、存储配置和版本控制。',
|
||||
capabilities: ['JSON / JSONL 数据上传与预览', '样本搜索、编辑和错误修复', '版本创建、切换、删除与下载'],
|
||||
architecture: ['源文件', '格式校验', '样本预览', '内容编辑', '版本保存', '训练 / 评测引用'],
|
||||
prerequisites: ['文件使用 UTF-8 编码。', 'JSON/JSONL 结构合法,字段与目标训练或评测模板一致。'],
|
||||
steps: [
|
||||
{ title: '创建数据集', description: '点击“上传数据集”,填写名称,选择训练集或评测集,并选择本地或 MinIO 存储。' },
|
||||
{ title: '上传源文件', description: '上传 JSON 或 JSONL 文件,等待平台完成格式和基础字段校验。' },
|
||||
{ title: '预览与搜索样本', description: '进入详情页查看原始内容和表格预览,通过搜索快速定位目标样本。' },
|
||||
{ title: '修正数据内容', description: '逐条编辑异常样本,重点检查缺失字段、转义字符、空内容和格式不一致。' },
|
||||
{ title: '创建并启用版本', description: '暂存修改后创建新版本,复查无误后将目标版本设为当前版本。' },
|
||||
],
|
||||
validation: ['数据集状态正常且样本数量符合预期。', '预览页面没有格式错误提示。', '训练或评测任务能够正确选择当前版本。'],
|
||||
},
|
||||
{
|
||||
id: 'data-process',
|
||||
group: '数据治理',
|
||||
mode: 'workflow',
|
||||
title: '数据处理',
|
||||
summary: '将结构化数据、非结构化文档或外部数据源加工为可训练、可评测的数据。',
|
||||
capabilities: ['清洗、切分和数据集划分', '大模型生成与质量筛选', '结果预览、编辑和校验'],
|
||||
architecture: ['数据源', '清洗与切分', '模型生成', '质量筛选', '人工校验', '保存为数据集'],
|
||||
prerequisites: ['已准备本地文件,或可访问的数据库/API 数据源。', '需要模型生成时,已配置可用的生成模型。'],
|
||||
steps: [
|
||||
{
|
||||
title: '创建任务并选择数据源',
|
||||
description: '选择结构化、非结构化或外部数据源,并填写任务基本信息。',
|
||||
screenshot: guideScreenshots.dataProcessCreate,
|
||||
},
|
||||
{ title: '配置预处理规则', description: '设置清洗、切分、字段映射和训练集/验证集/测试集划分方式。' },
|
||||
{ title: '选择生成模型', description: '需要扩写、问答生成或内容转换时,选择生成模型并配置 Prompt 与生成参数。' },
|
||||
{
|
||||
title: '检查数据预览',
|
||||
description: '上传文件或连接数据源后检查预览,确认字段、切分结果和样本数量符合预期。',
|
||||
screenshot: guideScreenshots.dataProcessPreview,
|
||||
},
|
||||
{ title: '启动生成与筛选', description: '执行任务,观察生成进度和质量筛选结果,及时处理失败样本。' },
|
||||
{ title: '编辑并保存结果', description: '修正校验问题和低质量样本,确认结果后保存任务并生成可复用数据集。' },
|
||||
],
|
||||
validation: ['任务生成过程没有未处理失败项。', '结果样本通过字段和内容校验。', '生成数据集可在数据集管理和训练任务中正常使用。'],
|
||||
},
|
||||
{
|
||||
id: 'data-convert',
|
||||
group: '其他工具',
|
||||
mode: 'workflow',
|
||||
title: '数据类型转换',
|
||||
summary: '将 JSON 数组转换为逐行记录的 JSONL 文件,使数据能够被训练和评测模块直接使用。',
|
||||
capabilities: ['JSON 转 JSONL', '输出文件名和编码配置', '训练数据格式标准化'],
|
||||
architecture: ['JSON 源文件', '结构检查', '逐条展开', 'JSONL 输出', '数据集上传'],
|
||||
prerequisites: ['源文件为合法 JSON 数组。', '文件使用 UTF-8 编码,每个数组元素代表一条数据记录。'],
|
||||
steps: [
|
||||
{ title: '确认转换类型', description: '进入页面后确认转换方向为 JSON → JSONL。' },
|
||||
{ title: '配置输出信息', description: '设置输出文件名和 UTF-8 编码,避免中文内容或特殊字符出现乱码。' },
|
||||
{ title: '上传并执行转换', description: '功能开放后上传 JSON 文件,执行转换并下载 JSONL 结果。' },
|
||||
{ title: '校验并上传数据集', description: '抽查输出文件的首尾记录,确认每行都是完整 JSON 对象后再上传到数据集管理。' },
|
||||
],
|
||||
validation: ['输出文件每一行均可独立解析为 JSON。', '转换前后记录数量一致。'],
|
||||
notice: '当前页面为功能原型,文件上传和实际转换能力尚未开放。',
|
||||
status: '建设中',
|
||||
},
|
||||
{
|
||||
id: 'user-settings',
|
||||
group: '系统设置',
|
||||
mode: 'workflow',
|
||||
title: '用户设置',
|
||||
summary: '创建和维护平台账号,配置角色、启用状态以及各业务模块的访问权限。',
|
||||
capabilities: ['用户创建、状态维护和删除', '角色预设与模块权限分配', '内置账号和当前账号安全保护'],
|
||||
architecture: ['创建账号', '分配角色', '勾选模块权限', '保存生效', '路由访问校验'],
|
||||
prerequisites: ['当前账号拥有用户设置权限。', '已明确新用户的角色职责和最小权限范围。'],
|
||||
steps: [
|
||||
{ title: '创建用户', description: '填写登录账号、用户姓名、初始密码、角色和账号状态。' },
|
||||
{ title: '检查角色预设', description: '根据管理员、操作员或观察员角色加载默认权限,并确认是否符合岗位需求。' },
|
||||
{ title: '调整模块权限', description: '在“权限设置”中逐项勾选用户可访问的业务模块,遵循最小权限原则。' },
|
||||
{ title: '保存并验证', description: '保存后让目标用户重新进入平台,确认菜单显示和页面访问范围正确。' },
|
||||
],
|
||||
validation: ['新用户可以使用初始密码登录。', '侧边栏仅显示已授权模块。', '访问未授权地址时会进入无权访问页面。'],
|
||||
notice: '内置超级管理员和当前登录账号受到保护,不能在当前会话中删除或修改自身关键权限。',
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
group: '系统设置',
|
||||
mode: 'overview',
|
||||
title: '平台性能',
|
||||
summary: '实时监控 CPU、内存、磁盘、网络和 GPU 资源,辅助判断平台负载并定位设备异常。',
|
||||
capabilities: ['主机与资源池健康概览', 'CPU、内存、磁盘和网络趋势', 'GPU 温度、功耗、显存与占用进程'],
|
||||
architecture: ['主机 / GPU 指标', '周期采样', '趋势聚合', '性能看板', '单卡与进程详情'],
|
||||
prerequisites: ['拥有平台性能访问权限。', '监控服务能够读取主机和 GPU 指标。'],
|
||||
steps: [
|
||||
{ title: '先看健康概览', description: '查看 CPU、内存、磁盘、网络和 GPU 资源池是否存在告警,先判断整体负载。' },
|
||||
{ title: '选择观察频率', description: '日常观察使用较长刷新周期;临时排障时再选择 1、3 或 5 秒短周期,避免被瞬时波动误导。' },
|
||||
{ title: '再看资源趋势', description: '结合最近 60 次采样判断资源是否持续高位、突增或出现异常波动。' },
|
||||
{ title: '需要时查看单卡', description: '发现 GPU 异常后再进入单卡详情,对照温度、功耗、显存、利用率和占用进程。' },
|
||||
],
|
||||
validation: [
|
||||
'能够判断 CPU、内存、磁盘、网络和 GPU 是否处于健康范围。',
|
||||
'能够区分瞬时波动与持续高负载。',
|
||||
'发现异常时,能够定位到具体 GPU 或占用进程。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
group: '系统设置',
|
||||
mode: 'troubleshooting',
|
||||
title: '查看日志',
|
||||
summary: '集中查看系统日志和训练日志,通过刷新、筛选和关键词搜索定位异常。',
|
||||
capabilities: ['系统日志与训练日志分类', '按日期、任务和进程选择日志', '自动刷新、手动刷新和关键词过滤'],
|
||||
architecture: ['系统 / 训练进程', '日志文件', '类型与日期筛选', '关键词过滤', '异常定位'],
|
||||
prerequisites: ['拥有查看日志权限。', '已确认需要排查的时间范围、任务名称或进程 PID。'],
|
||||
steps: [
|
||||
{ title: '选择日志类型', description: '切换“系统日志”或“训练日志”页签,确定排查范围。' },
|
||||
{ title: '定位日志来源', description: '系统日志按日期选择文件;训练日志按任务名称和 PID 选择具体进程。' },
|
||||
{ title: '设置刷新方式', description: '持续观察时开启自动刷新;复盘历史问题时关闭自动刷新并手动定位。' },
|
||||
{ title: '搜索关键内容', description: '输入 error、warning、OOM、任务 ID 或业务关键词,结合上下文判断异常原因。' },
|
||||
],
|
||||
validation: ['日志时间与目标问题发生时间一致。', '异常前后的上下文信息完整。', '能够将错误关联到具体服务、任务或进程。'],
|
||||
},
|
||||
]
|
||||
|
||||
export const guideNavigation: GuideNavigationGroup[] = [
|
||||
{ title: '入门', articleIds: ['getting-started'] },
|
||||
{ title: '工作台', articleIds: ['dashboard'] },
|
||||
{ title: '模型服务', articleIds: ['fine-tune', 'model-eval', 'model-inference', 'model-manage'] },
|
||||
{ title: '数据治理', articleIds: ['dataset', 'data-process'] },
|
||||
{ title: '其他工具', articleIds: ['data-convert'] },
|
||||
{ title: '系统设置', articleIds: ['user-settings', 'hardware', 'logs'] },
|
||||
]
|
||||
|
||||
export const guideArticleMap = new Map(guideArticles.map((article) => [article.id, article]))
|
||||
@@ -1,12 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="simple-page">
|
||||
<h1>无权访问</h1>
|
||||
<p>当前账号没有访问该页面的权限,请联系管理员调整角色或页面权限。</p>
|
||||
</section>
|
||||
<div class="permission-denied-view">
|
||||
<el-result icon="warning" title="无权访问" sub-title="当前账号没有访问此功能的权限,请联系管理员调整权限。">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="goBack">返回上一页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simple-page {
|
||||
padding: 24px;
|
||||
.permission-denied-view {
|
||||
display: grid;
|
||||
min-height: 440px;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,93 +2,104 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { createUser } from '@/api/modules/system'
|
||||
import type { CreateUserPayload, PermissionCode } from '@/types'
|
||||
import type { CreateUserPayload, PermissionCode, UserRole } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const saving = ref(false)
|
||||
const createFormRef = ref<FormInstance>()
|
||||
|
||||
const form = reactive<CreateUserPayload>({
|
||||
const rolePermissionPresets: Record<UserRole, PermissionCode[]> = {
|
||||
admin: ['dashboard', 'fine-tune', 'model-eval', 'model-inference', 'model-manage', 'dataset', 'data-process', 'data-convert', 'compute', 'hardware', 'logs', 'user-settings'],
|
||||
operator: ['dashboard', 'fine-tune', 'model-eval', 'model-inference', 'model-manage', 'dataset', 'data-process', 'data-convert', 'compute', 'hardware', 'logs'],
|
||||
viewer: ['dashboard', 'hardware', 'logs'],
|
||||
}
|
||||
|
||||
const createForm = reactive<CreateUserPayload>({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: 'platform123',
|
||||
role: 'viewer',
|
||||
password: '',
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
permissions: ['dashboard'],
|
||||
permissions: [...rolePermissionPresets.operator],
|
||||
})
|
||||
|
||||
const permissionOptions: PermissionCode[] = [
|
||||
'dashboard',
|
||||
'fine-tune',
|
||||
'model-eval',
|
||||
'model-inference',
|
||||
'model-manage',
|
||||
'dataset',
|
||||
'data-process',
|
||||
'data-convert',
|
||||
'compute',
|
||||
'hardware',
|
||||
'logs',
|
||||
'user-settings',
|
||||
]
|
||||
const createRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z][A-Za-z0-9_]{2,31}$/, message: '账号需以字母开头,由 3-32 位字母、数字或下划线组成', trigger: 'blur' },
|
||||
],
|
||||
display_name: [{ required: true, message: '请输入用户姓名', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入初始密码', trigger: 'blur' },
|
||||
{ min: 6, message: '初始密码至少 6 位', trigger: 'blur' },
|
||||
],
|
||||
role: [{ required: true, message: '请选择用户角色', trigger: 'change' }],
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
function applyCreateRolePreset(role: UserRole) {
|
||||
createForm.permissions = [...rolePermissionPresets[role]]
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const valid = await createFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
await createUser(form)
|
||||
ElMessage.success('用户已创建')
|
||||
await createUser({ ...createForm, permissions: [...(createForm.permissions ?? [])] })
|
||||
ElMessage.success('用户创建成功')
|
||||
router.push('/user-settings')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="user-create">
|
||||
<h1>创建用户</h1>
|
||||
<el-form :model="form" label-width="110px" class="user-form">
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="form.username" />
|
||||
<PageCard title="创建用户">
|
||||
<el-form
|
||||
ref="createFormRef"
|
||||
:model="createForm"
|
||||
:rules="createRules"
|
||||
label-width="100px"
|
||||
style="max-width: 600px"
|
||||
>
|
||||
<el-form-item label="登录账号" prop="username">
|
||||
<el-input v-model.trim="createForm.username" maxlength="32" placeholder="例如:zhangsan" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称">
|
||||
<el-input v-model="form.display_name" />
|
||||
<el-form-item label="用户姓名" prop="display_name">
|
||||
<el-input v-model.trim="createForm.display_name" maxlength="40" placeholder="请输入姓名" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
<el-form-item label="初始密码" prop="password">
|
||||
<el-input v-model="createForm.password" type="password" show-password placeholder="至少 6 位" autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-form-item label="用户角色" prop="role">
|
||||
<el-select v-model="createForm.role" style="width: 100%" @change="applyCreateRolePreset">
|
||||
<el-option label="超级管理员" value="admin" />
|
||||
<el-option label="操作员" value="operator" />
|
||||
<el-option label="观察员" value="viewer" />
|
||||
</el-select>
|
||||
<div style="color: #909399; font-size: 12px; margin-top: 4px; line-height: 1.4;">角色会预设一组权限,创建后仍可单独调整。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio value="active">启用</el-radio>
|
||||
<el-radio value="disabled">禁用</el-radio>
|
||||
<el-form-item label="账号状态">
|
||||
<el-radio-group v-model="createForm.status">
|
||||
<el-radio value="active">立即启用</el-radio>
|
||||
<el-radio value="disabled">暂不启用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="页面权限">
|
||||
<el-checkbox-group v-model="form.permissions">
|
||||
<el-checkbox v-for="item in permissionOptions" :key="item" :value="item">{{ item }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="router.back()">返回</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitCreate">创建用户</el-button>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-create {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.user-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { getUsers, updateUserAccess } from '@/api/modules/system'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type {
|
||||
PermissionCode,
|
||||
SystemUser,
|
||||
UpdateUserAccessPayload,
|
||||
UserRole,
|
||||
} from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const selectedUser = ref<SystemUser | null>(null)
|
||||
|
||||
const accessForm = reactive<UpdateUserAccessPayload>({
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
permissions: [],
|
||||
})
|
||||
|
||||
const permissionGroups: Array<{
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ value: PermissionCode; label: string; description: string }>
|
||||
}> = [
|
||||
{
|
||||
title: '工作台',
|
||||
description: '平台入口及整体运行概览',
|
||||
items: [
|
||||
{ value: 'dashboard', label: '服务看板', description: '查看平台关键指标和任务概览' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '模型服务',
|
||||
description: '训练、评测、推理与模型资产管理',
|
||||
items: [
|
||||
{ value: 'fine-tune', label: '模型训练', description: '创建和管理模型训练任务' },
|
||||
{ value: 'model-eval', label: '模型评测', description: '创建评测任务并查看评测结果' },
|
||||
{ value: 'model-inference', label: '模型推理', description: '使用模型推理和模型对比功能' },
|
||||
{ value: 'model-manage', label: '模型管理', description: '维护模型配置和训练模型' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据治理',
|
||||
description: '数据集、数据加工及格式转换',
|
||||
items: [
|
||||
{ value: 'dataset', label: '数据集管理', description: '上传、编辑和管理数据集版本' },
|
||||
{ value: 'data-process', label: '数据处理', description: '创建和管理数据处理任务' },
|
||||
{ value: 'data-convert', label: '数据类型转换', description: '使用数据格式转换工具' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '算力资源',
|
||||
description: '算力节点、GPU 与任务队列',
|
||||
items: [
|
||||
{ value: 'compute', label: '算力节点', description: '查看算力节点、GPU 资源和调度队列' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
description: '平台运行信息及管理功能',
|
||||
items: [
|
||||
{ value: 'hardware', label: '平台性能', description: '查看服务器和 GPU 运行状态' },
|
||||
{ value: 'logs', label: '查看日志', description: '查看系统日志和训练日志' },
|
||||
{ value: 'user-settings', label: '用户设置', description: '创建、删除用户并管理权限' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const allPermissions = permissionGroups.flatMap((group) => group.items.map((item) => item.value))
|
||||
const rolePermissionPresets: Record<UserRole, PermissionCode[]> = {
|
||||
admin: [...allPermissions],
|
||||
operator: allPermissions.filter((permission) => permission !== 'user-settings'),
|
||||
viewer: ['dashboard', 'hardware', 'logs'],
|
||||
}
|
||||
|
||||
const accessLocked = computed(() =>
|
||||
Boolean(selectedUser.value?.protected || selectedUser.value?.username === auth.username),
|
||||
)
|
||||
|
||||
const accessLockMessage = computed(() =>
|
||||
selectedUser.value?.protected
|
||||
? '内置超级管理员的状态和权限不可修改。'
|
||||
: '不能在当前会话中修改自己的状态和权限。',
|
||||
)
|
||||
|
||||
async function loadData() {
|
||||
const id = route.params.id as string
|
||||
if (!id) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const users = await getUsers()
|
||||
const user = users.find((u) => u.id === id)
|
||||
if (user) {
|
||||
selectedUser.value = user
|
||||
Object.assign(accessForm, {
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
permissions: [...user.permissions],
|
||||
})
|
||||
} else {
|
||||
ElMessage.error('找不到用户信息')
|
||||
router.back()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyAccessRolePreset(role: UserRole) {
|
||||
accessForm.permissions = [...rolePermissionPresets[role]]
|
||||
}
|
||||
|
||||
async function saveAccess() {
|
||||
if (!selectedUser.value || accessLocked.value) return
|
||||
if (!accessForm.permissions?.length) {
|
||||
ElMessage.warning('请至少保留一项功能权限')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await updateUserAccess(selectedUser.value.id, {
|
||||
role: accessForm.role,
|
||||
status: accessForm.status,
|
||||
permissions: [...accessForm.permissions],
|
||||
})
|
||||
ElMessage.success('用户权限已更新')
|
||||
router.push('/user-settings')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="simple-page">
|
||||
<h1>权限设置</h1>
|
||||
<p>第一版已支持账号页面权限读取与保存,精细化项目/模型/数据集权限将在后续版本补齐。</p>
|
||||
</section>
|
||||
<PageCard title="权限设置" v-loading="loading">
|
||||
<el-form :model="accessForm" label-width="100px" v-if="selectedUser" style="max-width: 600px;">
|
||||
<el-form-item label="当前用户">
|
||||
<span style="font-weight: 500; margin-right: 8px; color: #303133;">{{ selectedUser.display_name }}</span>
|
||||
<span style="color: #909399;">@{{ selectedUser.username }}</span>
|
||||
<el-tag v-if="accessLocked" type="warning" size="small" style="margin-left: 12px;">
|
||||
{{ selectedUser.protected ? '系统内置账号' : '当前账号' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="accessLocked">
|
||||
<el-alert :title="accessLockMessage" type="warning" :closable="false" show-icon style="line-height: 1.4; padding: 8px 16px; width: 100%;" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户角色">
|
||||
<el-select v-model="accessForm.role" :disabled="accessLocked" @change="applyAccessRolePreset" style="width: 100%">
|
||||
<el-option label="超级管理员" value="admin" />
|
||||
<el-option label="操作员" value="operator" />
|
||||
<el-option label="观察员" value="viewer" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="账号状态">
|
||||
<el-radio-group v-model="accessForm.status" :disabled="accessLocked">
|
||||
<el-radio value="active">已启用</el-radio>
|
||||
<el-radio value="disabled">已禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="功能权限">
|
||||
<el-checkbox-group v-model="accessForm.permissions" :disabled="accessLocked" style="width: 100%;">
|
||||
<div v-for="group in permissionGroups" :key="group.title" style="margin-bottom: 16px; background: #f8fafc; padding: 12px 16px; border-radius: 4px; border: 1px solid #e4e7ed;">
|
||||
<div style="font-weight: 600; font-size: 13px; color: #303133; margin-bottom: 8px; display: flex; justify-content: space-between;">
|
||||
<span>{{ group.title }}</span>
|
||||
<span style="font-weight: normal; font-size: 12px; color: #909399;">{{ group.description }}</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<el-checkbox v-for="item in group.items" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
<span style="color: #909399; font-size: 12px; margin-left: 8px;">{{ item.description }}</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" :disabled="accessLocked" @click="saveAccess">保存设置</el-button>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</PageCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simple-page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getUsers } from '@/api/modules/system'
|
||||
import type { SystemUser } from '@/types'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppConfirmDialog from '@/components/AppConfirmDialog.vue'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { deleteUser, getUsers } from '@/api/modules/system'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type {
|
||||
PermissionCode,
|
||||
SystemUser,
|
||||
UserRole,
|
||||
} from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const users = ref<SystemUser[]>([])
|
||||
const confirmDialogRef = ref<InstanceType<typeof AppConfirmDialog>>()
|
||||
|
||||
const activeCount = computed(() => users.value.filter((user) => user.status === 'active').length)
|
||||
const adminCount = computed(() => users.value.filter((user) => user.role === 'admin').length)
|
||||
|
||||
function roleLabel(role: UserRole) {
|
||||
if (role === 'admin') return '超级管理员'
|
||||
if (role === 'operator') return '操作员'
|
||||
return '观察员'
|
||||
}
|
||||
|
||||
function roleTagType(role: UserRole) {
|
||||
if (role === 'admin') return 'danger'
|
||||
if (role === 'operator') return 'primary'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function permissionLabel(permission: PermissionCode) {
|
||||
const map: Record<string, string> = {
|
||||
'dashboard': '服务看板',
|
||||
'fine-tune': '模型训练',
|
||||
'model-eval': '模型评测',
|
||||
'model-inference': '模型推理',
|
||||
'model-manage': '模型管理',
|
||||
'dataset': '数据集管理',
|
||||
'data-process': '数据处理',
|
||||
'data-convert': '数据类型转换',
|
||||
'compute': '算力节点',
|
||||
'hardware': '平台性能',
|
||||
'logs': '查看日志',
|
||||
'user-settings': '用户设置'
|
||||
}
|
||||
return map[permission] || permission
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
@@ -15,51 +64,166 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
router.push('/user-settings/create')
|
||||
}
|
||||
|
||||
function openPermissionDrawer(user: SystemUser | Record<string, any>) {
|
||||
const systemUser = user as SystemUser
|
||||
router.push(`/user-settings/${systemUser.id}/permission`)
|
||||
}
|
||||
|
||||
function deleteDisabledReason(user: SystemUser | Record<string, any>) {
|
||||
if (user.protected) return '系统内置管理员不能删除'
|
||||
if (user.username === auth.username) return '不能删除当前登录用户'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleDelete(user: SystemUser | Record<string, any>) {
|
||||
const systemUser = user as SystemUser
|
||||
const disabledReason = deleteDisabledReason(systemUser)
|
||||
if (disabledReason) {
|
||||
ElMessage.warning(disabledReason)
|
||||
return
|
||||
}
|
||||
const confirmed = await confirmDialogRef.value?.open({
|
||||
title: `删除用户“${systemUser.display_name}”?`,
|
||||
message: `删除后,账号 ${systemUser.username} 将无法登录,已有任务和历史记录不会被删除。此操作无法撤销。`,
|
||||
confirmText: '确认删除',
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await deleteUser(systemUser.id, auth.username)
|
||||
ElMessage.success('用户已删除')
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="user-settings" v-loading="loading">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>用户设置</h1>
|
||||
<p>管理平台账号、角色状态和页面权限。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
</header>
|
||||
<div class="user-settings-page">
|
||||
|
||||
<el-table :data="users">
|
||||
<el-table-column prop="username" label="账号" min-width="140" />
|
||||
<el-table-column prop="display_name" label="显示名称" min-width="160" />
|
||||
<el-table-column prop="role" label="角色" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column label="权限数" width="120">
|
||||
<template #default="{ row }">{{ row.permissions?.length || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<DataTablePage
|
||||
class="user-table"
|
||||
title="用户"
|
||||
:data="users"
|
||||
:loading="loading"
|
||||
searchable
|
||||
:search-fields="['username', 'display_name', 'role', 'status']"
|
||||
create-text="创建用户"
|
||||
row-key="id"
|
||||
empty-text="暂无用户,点击右上角创建用户"
|
||||
@create="openCreateDialog"
|
||||
@refresh="loadUsers"
|
||||
>
|
||||
<template #title>
|
||||
<div class="table-heading">
|
||||
<h2>用户设置</h2>
|
||||
<div class="table-stats">
|
||||
<el-tag size="small" type="info">全部: {{ users.length }}</el-tag>
|
||||
<el-tag size="small" type="success">已启用: {{ activeCount }}</el-tag>
|
||||
<el-tag size="small" type="danger">管理员: {{ adminCount }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-settings {
|
||||
padding: 24px;
|
||||
}
|
||||
<template #columns>
|
||||
<el-table-column label="登录账号" prop="username" align="center" min-width="120" />
|
||||
<el-table-column label="用户姓名" prop="display_name" align="center" min-width="120" />
|
||||
<el-table-column label="角色" align="center" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="roleTagType(row.role)" size="small">{{ roleLabel(row.role) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'active' ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="功能权限" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="permission in row.permissions.slice(0, 3)" :key="permission" type="info" size="small" style="margin-right: 4px;">
|
||||
{{ permissionLabel(permission) }}
|
||||
</el-tag>
|
||||
<span v-if="row.permissions.length > 3" style="color: #909399; font-size: 12px; margin-left: 4px;">
|
||||
+{{ row.permissions.length - 3 }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" align="center" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.last_login) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
.page-header {
|
||||
<template #actions="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" link size="small" @click="openPermissionDrawer(row)">
|
||||
<i class="fa fa-shield" style="margin-right: 4px;" aria-hidden="true" />用户权限设置
|
||||
</el-button>
|
||||
<el-tooltip :disabled="!deleteDisabledReason(row)" :content="deleteDisabledReason(row)" placement="top">
|
||||
<span>
|
||||
<el-button type="danger" link size="small" :disabled="Boolean(deleteDisabledReason(row))" @click="handleDelete(row)">
|
||||
<i class="fa fa-trash-o" style="margin-right: 4px;" aria-hidden="true" />删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
|
||||
<AppConfirmDialog ref="confirmDialogRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-settings-page {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
|
||||
|
||||
.user-table {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.table-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #1e293b;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
.table-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,17 @@ import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import path from 'node:path'
|
||||
|
||||
function configuredPort(name: 'FRONTEND_PORT' | 'BACKEND_PORT', fallback: number) {
|
||||
const port = Number(process.env[name] ?? fallback)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`${name} must be an integer between 1 and 65535`)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
const frontendPort = configuredPort('FRONTEND_PORT', 16801)
|
||||
const backendPort = configuredPort('BACKEND_PORT', 17861)
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -22,11 +33,12 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 16801,
|
||||
port: frontendPort,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
// Frontend uses /modelTF and proxies to the local five-digit backend port.
|
||||
// 一键启动脚本会将 YAML 中的后端端口注入当前 Vite 进程。
|
||||
'/modelTF': {
|
||||
target: 'http://localhost:17861',
|
||||
target: `http://127.0.0.1:${backendPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
277
scripts/start-dev.sh
Executable file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set -m
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BACKEND_DIR="$ROOT_DIR/backend"
|
||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||
BACKEND_VENV="$BACKEND_DIR/.venv"
|
||||
BACKEND_PYTHON="$BACKEND_VENV/bin/python"
|
||||
|
||||
DO_SETUP=false
|
||||
CHECK_ONLY=false
|
||||
BACKEND_PID=""
|
||||
FRONTEND_PID=""
|
||||
CONFIG_PATH=""
|
||||
FRONTEND_PORT=""
|
||||
BACKEND_PORT=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/start-dev.sh 启动前端和后端
|
||||
./scripts/start-dev.sh --setup 同步本地依赖后启动
|
||||
./scripts/start-dev.sh --check 只检查依赖、配置和数据库连接
|
||||
|
||||
环境变量:
|
||||
PYTHON_BIN 创建虚拟环境时使用的 Python 3.12+ 可执行文件
|
||||
BACKEND_CONFIG_FILE 指定其他后端 YAML 配置文件
|
||||
FRONTEND_PORT 覆盖 YAML 中的前端端口
|
||||
BACKEND_PORT 覆盖 YAML 中的后端端口
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "❌ $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
process_group_alive() {
|
||||
local pid="$1"
|
||||
[[ -n "$pid" ]] && kill -0 -- "-$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
stop_process_group() {
|
||||
local pid="$1"
|
||||
local attempt=0
|
||||
[[ -n "$pid" ]] || return 0
|
||||
|
||||
if process_group_alive "$pid"; then
|
||||
kill -TERM -- "-$pid" 2>/dev/null || true
|
||||
while process_group_alive "$pid" && [[ $attempt -lt 50 ]]; do
|
||||
sleep 0.1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
if process_group_alive "$pid"; then
|
||||
kill -KILL -- "-$pid" 2>/dev/null || true
|
||||
fi
|
||||
elif kill -0 "$pid" 2>/dev/null; then
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
trap - EXIT
|
||||
stop_process_group "$FRONTEND_PID"
|
||||
stop_process_group "$BACKEND_PID"
|
||||
}
|
||||
|
||||
handle_signal() {
|
||||
echo
|
||||
echo "🛑 正在停止前端和后端……"
|
||||
exit 130
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local port="$1"
|
||||
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
|
||||
}
|
||||
|
||||
select_base_python() {
|
||||
if [[ -n "${PYTHON_BIN:-}" ]]; then
|
||||
printf '%s\n' "$PYTHON_BIN"
|
||||
elif [[ -x /opt/miniconda3/bin/python3 ]]; then
|
||||
printf '%s\n' /opt/miniconda3/bin/python3
|
||||
else
|
||||
command -v python3 || true
|
||||
fi
|
||||
}
|
||||
|
||||
setup_dependencies() {
|
||||
local base_python
|
||||
base_python="$(select_base_python)"
|
||||
[[ -n "$base_python" ]] || fail "未找到 Python 3,请通过 PYTHON_BIN 指定 Python 3.12+。"
|
||||
|
||||
"$base_python" -c \
|
||||
'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' \
|
||||
|| fail "后端要求 Python 3.12+,当前为 $($base_python --version 2>&1)。"
|
||||
|
||||
if [[ ! -x "$BACKEND_PYTHON" ]]; then
|
||||
echo "📦 创建后端虚拟环境……"
|
||||
"$base_python" -m venv "$BACKEND_VENV"
|
||||
fi
|
||||
|
||||
echo "📦 同步后端依赖……"
|
||||
"$BACKEND_PYTHON" -m pip install -r "$BACKEND_DIR/requirements.txt"
|
||||
|
||||
echo "📦 同步前端依赖……"
|
||||
(cd "$FRONTEND_DIR" && npm ci)
|
||||
|
||||
if [[ -z "${BACKEND_CONFIG_FILE:-}" && ! -f "$BACKEND_DIR/config.yaml" ]]; then
|
||||
cp "$BACKEND_DIR/config.example.yaml" "$BACKEND_DIR/config.yaml"
|
||||
echo "📝 已根据 config.example.yaml 创建本地 config.yaml,请填写数据库密码。"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_and_check_config() {
|
||||
local port_values
|
||||
|
||||
CONFIG_PATH="$(
|
||||
cd "$BACKEND_DIR"
|
||||
"$BACKEND_PYTHON" -c \
|
||||
'from app.core.config import _resolve_config_path; print(_resolve_config_path()[0])'
|
||||
)" || fail "无法解析后端配置文件路径。"
|
||||
|
||||
[[ -f "$CONFIG_PATH" ]] || fail "配置文件不存在:$CONFIG_PATH"
|
||||
port_values="$(
|
||||
cd "$BACKEND_DIR"
|
||||
"$BACKEND_PYTHON" -c \
|
||||
'from app.core.config import get_settings; settings = get_settings(); print(settings.frontend_port, settings.backend_port)'
|
||||
)" || fail "配置文件无法加载:$CONFIG_PATH"
|
||||
|
||||
read -r FRONTEND_PORT BACKEND_PORT <<< "$port_values"
|
||||
[[ -n "$FRONTEND_PORT" && -n "$BACKEND_PORT" ]] \
|
||||
|| fail "无法从配置文件读取前端和后端端口:$CONFIG_PATH"
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
command -v node >/dev/null 2>&1 || fail "未找到 Node.js。"
|
||||
command -v npm >/dev/null 2>&1 || fail "未找到 npm。"
|
||||
command -v curl >/dev/null 2>&1 || fail "未找到 curl。"
|
||||
command -v lsof >/dev/null 2>&1 || fail "未找到 lsof。"
|
||||
[[ -x "$FRONTEND_DIR/node_modules/.bin/vite" ]] \
|
||||
|| fail "前端依赖不完整,请先运行 ./scripts/start-dev.sh --setup。"
|
||||
[[ -x "$BACKEND_PYTHON" ]] \
|
||||
|| fail "后端虚拟环境不存在,请先运行 ./scripts/start-dev.sh --setup。"
|
||||
|
||||
"$BACKEND_PYTHON" -c 'import fastapi, psycopg, sqlalchemy, uvicorn, yaml' \
|
||||
|| fail "后端依赖不完整,请先运行 ./scripts/start-dev.sh --setup。"
|
||||
resolve_and_check_config
|
||||
}
|
||||
|
||||
check_database() {
|
||||
echo "🔍 检查 PostgreSQL 连接:$CONFIG_PATH"
|
||||
(
|
||||
cd "$BACKEND_DIR"
|
||||
"$BACKEND_PYTHON" -c '
|
||||
import psycopg
|
||||
from app.core.config import get_settings
|
||||
|
||||
url = get_settings().database_url
|
||||
prefix = "postgresql+psycopg://"
|
||||
if url.startswith(prefix):
|
||||
url = "postgresql://" + url[len(prefix):]
|
||||
connection = psycopg.connect(url, connect_timeout=3)
|
||||
connection.close()
|
||||
'
|
||||
) || fail "PostgreSQL 无法连接,请检查配置文件中的地址、用户名和密码:$CONFIG_PATH"
|
||||
}
|
||||
|
||||
wait_for_url() {
|
||||
local url="$1"
|
||||
local service_name="$2"
|
||||
local pid="$3"
|
||||
local attempt=0
|
||||
local max_attempts=30
|
||||
|
||||
while [[ $attempt -lt $max_attempts ]]; do
|
||||
if curl -fsS --max-time 1 "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "❌ $service_name 进程已提前退出。" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "❌ 等待 $service_name 就绪超时:$url" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--setup)
|
||||
DO_SETUP=true
|
||||
;;
|
||||
--check)
|
||||
CHECK_ONLY=true
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "未知参数:$1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ "$DO_SETUP" == true ]]; then
|
||||
setup_dependencies
|
||||
fi
|
||||
|
||||
check_dependencies
|
||||
check_database
|
||||
|
||||
if [[ "$CHECK_ONLY" == true ]]; then
|
||||
echo "✅ 前端、后端依赖、配置和 PostgreSQL 连接均正常。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
port_in_use "$BACKEND_PORT" \
|
||||
&& fail "后端端口 $BACKEND_PORT 已被占用,后端可能已经启动。"
|
||||
port_in_use "$FRONTEND_PORT" \
|
||||
&& fail "前端端口 $FRONTEND_PORT 已被占用,前端可能已经启动。"
|
||||
|
||||
trap cleanup EXIT
|
||||
trap handle_signal INT TERM
|
||||
|
||||
echo "🚀 启动后端:http://127.0.0.1:$BACKEND_PORT"
|
||||
(
|
||||
cd "$BACKEND_DIR"
|
||||
exec "$BACKEND_PYTHON" -m uvicorn app.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port "$BACKEND_PORT" \
|
||||
--reload
|
||||
) &
|
||||
BACKEND_PID=$!
|
||||
|
||||
wait_for_url "http://127.0.0.1:$BACKEND_PORT/modelTF/health" "后端" "$BACKEND_PID" \
|
||||
|| fail "后端启动失败。"
|
||||
|
||||
echo "🚀 启动前端:http://127.0.0.1:$FRONTEND_PORT"
|
||||
(
|
||||
cd "$FRONTEND_DIR"
|
||||
export FRONTEND_PORT BACKEND_PORT
|
||||
exec npm run dev -- --host 0.0.0.0 --port "$FRONTEND_PORT" --strictPort
|
||||
) &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
wait_for_url "http://127.0.0.1:$FRONTEND_PORT" "前端" "$FRONTEND_PID" \
|
||||
|| fail "前端启动失败。"
|
||||
|
||||
echo "✅ 前端和后端均已就绪,按 Ctrl+C 可同时停止。"
|
||||
|
||||
while kill -0 "$BACKEND_PID" 2>/dev/null && kill -0 "$FRONTEND_PID" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
set +e
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
wait "$BACKEND_PID"
|
||||
EXIT_CODE=$?
|
||||
echo "❌ 后端已退出,正在停止前端。" >&2
|
||||
else
|
||||
wait "$FRONTEND_PID"
|
||||
EXIT_CODE=$?
|
||||
echo "❌ 前端已退出,正在停止后端。" >&2
|
||||
fi
|
||||
set -e
|
||||
|
||||
exit "$EXIT_CODE"
|
||||