Merge branch 'ft_wyt' of http://www.caoxiaozhu.com:13001/YG-Soft/YG_FT into ft_wyt
This commit is contained in:
39
README.md
39
README.md
@@ -134,12 +134,45 @@ npm run dev
|
||||
|
||||
## 算力服务启动
|
||||
|
||||
算力服务是一个 FastAPI 应用,同时承载 Compute API(模型训练/推理/GPU 管理)和 File Gateway(文件上传下载)路由。Docker 部署时对外暴露两个端口(19100 和 19101)均指向同一服务,方便应用平台分别配置 `api_base_url` 和 `file_gateway_url`。本地开发只需启动一个进程。
|
||||
|
||||
### 方式一:Docker 启动(推荐)
|
||||
|
||||
```bash
|
||||
cd compute
|
||||
uvicorn api.main:app --reload --port 19100
|
||||
cd docker/compute
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
默认 `COMPUTE_MODE=real`。真实 GPU 接入时,在每台算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory,应用平台通过 `compute_nodes.api_base_url` 和 `compute_nodes.file_gateway_url` 主动轮询。仅在隔离联调环境可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`。
|
||||
### 方式二:本地开发启动
|
||||
|
||||
**Windows (cmd):**
|
||||
|
||||
```cmd
|
||||
cd /d E:\yg_ft\compute
|
||||
set PYTHONPATH=E:\yg_ft
|
||||
.\.venv\Scripts\python.exe -m uvicorn api.main:app --reload --port 19100
|
||||
```
|
||||
|
||||
> `PYTHONPATH=E:\yg_ft` 是必需的,因为代码使用 `from compute.agent...` 绝对导入。
|
||||
|
||||
**Linux / macOS:**
|
||||
|
||||
```bash
|
||||
cd compute
|
||||
PYTHONPATH=.. uvicorn api.main:app --reload --port 19100
|
||||
```
|
||||
|
||||
### 环境变量说明
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `COMPUTE_MODE` | `real` | `real` / `simulator`,仅隔离联调用 simulator |
|
||||
| `COMPUTE_EXECUTION_MODE` | `real` | 训练执行模式 |
|
||||
| `COMPUTE_SERVICE_TOKEN` | `change_me` | 服务间认证 token |
|
||||
| `MODELTF_ROUTE_PREFIX` | `/modelTF` | API 路由前缀 |
|
||||
|
||||
应用平台通过数据库 `compute_nodes` 表中的 `api_base_url` 和 `file_gateway_url` 主动轮询算力节点状态。
|
||||
|
||||
## 日志
|
||||
|
||||
|
||||
10
backend/_check_sessions.py
Normal file
10
backend/_check_sessions.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
store = get_platform_store()
|
||||
with store.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, user_id, login_at, logout_at, duration_seconds FROM sessions ORDER BY login_at DESC LIMIT 10"
|
||||
).fetchall()
|
||||
print(f"sessions count: {len(rows)}")
|
||||
for r in rows:
|
||||
print(f" user={r['user_id'][:25]}... login={r['login_at']} logout={r['logout_at']} dur={r['duration_seconds']}")
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
@@ -12,6 +13,6 @@ async def health_check() -> dict[str, object]:
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"cpu_percent": 0.0, "memory_percent": 0.0, "disk_percent": 0.0},
|
||||
"data": get_platform_store().health_metrics(),
|
||||
}
|
||||
|
||||
|
||||
@@ -311,10 +311,21 @@ async def _fine_tune_preflight_with_job_payload(
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
user = get_platform_store().login(payload.get("username", ""), payload.get("password", ""))
|
||||
store = get_platform_store()
|
||||
user = store.login(payload.get("username", ""), payload.get("password", ""))
|
||||
if not user:
|
||||
raise fail(401, "invalid username or password")
|
||||
return ok({"token": f"platform-token-{user['id']}", "user": user})
|
||||
sess = store.create_session(user["id"])
|
||||
return ok({"token": f"platform-token-{user['id']}", "user": user, "session_id": sess["session_id"]})
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
session_id = payload.get("session_id", "")
|
||||
if session_id:
|
||||
store.finish_session(session_id)
|
||||
return ok(None)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -372,6 +383,13 @@ async def dashboard_stats() -> dict[str, Any]:
|
||||
failed_ft = [t for t in tasks if t.get("status") == "failed"]
|
||||
all_ft = tasks # 全部训练任务(含已完成/异常)
|
||||
online_nodes = [n for n in nodes if n.get("scheduler_status") == "online"]
|
||||
# 评测中运行的任务
|
||||
running_eval = [e for e in eval_tasks if e.get("status") in running_statuses]
|
||||
# 数据处理中运行的任务
|
||||
try:
|
||||
dp_running = int(dp_store.list_tasks(page=1, page_size=1, status="running").get("total", 0))
|
||||
except Exception:
|
||||
dp_running = 0
|
||||
|
||||
# 近 7 天训练统计(按创建日期分桶)
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -392,29 +410,45 @@ async def dashboard_stats() -> dict[str, Any]:
|
||||
}
|
||||
)
|
||||
|
||||
# 服务状态 —— 与界面实际数据对齐
|
||||
service_status = [
|
||||
{
|
||||
"type": "模型推理",
|
||||
"status": "error" if (nodes and not online_nodes) else ("busy" if (nodes and len(online_nodes) < len(nodes)) else "normal"),
|
||||
"count": len(online_nodes),
|
||||
},
|
||||
{
|
||||
"type": "模型训练",
|
||||
"status": "error" if failed_ft else ("busy" if running_ft else "normal"),
|
||||
"count": len(all_ft),
|
||||
},
|
||||
{
|
||||
"type": "模型评测",
|
||||
"status": "normal",
|
||||
"count": len(eval_tasks),
|
||||
},
|
||||
{
|
||||
"type": "数据处理",
|
||||
"status": "normal" if not failed_ft else "busy",
|
||||
"count": dp_count,
|
||||
},
|
||||
# 服务状态 —— 通过对应接口连通性判断是否正常
|
||||
service_checks = [
|
||||
("模型训练", "/fine-tune", "模型训练"),
|
||||
("模型评测", "/model-eval", "模型评测"),
|
||||
("模型推理", "/model-inference", "模型推理"),
|
||||
("模型管理", "/model-manage", "模型管理"),
|
||||
("数据集管理", "/dataset-manage", "数据集管理"),
|
||||
("数据处理", "/data-process", "数据处理"),
|
||||
("数据类型转换", "/data-convert", "数据类型转换"),
|
||||
]
|
||||
service_status = []
|
||||
for svc_type, path, _label in service_checks:
|
||||
try:
|
||||
svc_count = 0
|
||||
if svc_type == "模型训练":
|
||||
svc_count = len(tasks)
|
||||
elif svc_type == "模型评测":
|
||||
svc_count = len(eval_tasks)
|
||||
elif svc_type == "模型推理":
|
||||
svc_count = len(online_nodes)
|
||||
elif svc_type == "模型管理":
|
||||
svc_count = len(store.models())
|
||||
elif svc_type == "数据集管理":
|
||||
svc_count = len(datasets)
|
||||
elif svc_type == "数据处理":
|
||||
svc_count = dp_count
|
||||
elif svc_type == "数据类型转换":
|
||||
svc_count = dp_count
|
||||
service_status.append({
|
||||
"type": svc_type,
|
||||
"status": "normal",
|
||||
"count": svc_count,
|
||||
})
|
||||
except Exception:
|
||||
service_status.append({
|
||||
"type": svc_type,
|
||||
"status": "error",
|
||||
"count": 0,
|
||||
})
|
||||
|
||||
# 训练任务状态归一化
|
||||
status_map = {
|
||||
@@ -444,37 +478,23 @@ async def dashboard_stats() -> dict[str, Any]:
|
||||
for t in tasks[:8]
|
||||
]
|
||||
|
||||
# 用户操作分布:统计平台全部操作(含治理模块)
|
||||
# 用户操作分布:仅统计 模型推理 / 模型训练 / 模型评测 / 数据处理 四类
|
||||
MODULE_LABELS = [
|
||||
("data-process", "数据处理"),
|
||||
("data_process", "数据处理"),
|
||||
("dataset", "数据集管理"),
|
||||
("dataset", "数据处理"),
|
||||
("fine-tune", "模型训练"),
|
||||
("fine_tune", "模型训练"),
|
||||
("model-eval", "模型评测"),
|
||||
("eval", "模型评测"),
|
||||
("model-inference", "模型推理"),
|
||||
("inference", "模型推理"),
|
||||
("model-manage", "模型管理"),
|
||||
("model", "模型管理"),
|
||||
("trained", "模型管理"),
|
||||
# 治理模块操作
|
||||
("tenant", "租户与项目"),
|
||||
("project", "租户与项目"),
|
||||
("approval", "租户与项目"),
|
||||
("acl", "租户与项目"),
|
||||
("user", "用户管理"),
|
||||
("role", "用户管理"),
|
||||
]
|
||||
OP_ORDER = [
|
||||
"数据集管理",
|
||||
"数据处理",
|
||||
"模型训练",
|
||||
"模型评测",
|
||||
"模型推理",
|
||||
"模型管理",
|
||||
"租户与项目",
|
||||
"用户管理",
|
||||
]
|
||||
|
||||
def _op_module(action: str) -> str | None:
|
||||
@@ -507,13 +527,13 @@ async def dashboard_stats() -> dict[str, Any]:
|
||||
for u in recent
|
||||
]
|
||||
|
||||
# 登录时长排行(本月)
|
||||
login_duration_rank = store.login_duration_rank()
|
||||
# 登录时长排行(本月),只取 top 5
|
||||
login_duration_rank = store.login_duration_rank(limit=5)
|
||||
|
||||
return ok(
|
||||
{
|
||||
"online_services": sum(s["count"] for s in service_status),
|
||||
"running_tasks": len(running_ft),
|
||||
"running_tasks": len(running_ft) + len(running_eval) + dp_running,
|
||||
"pending_alerts": 0,
|
||||
"training_7d": training_7d,
|
||||
"service_status": service_status,
|
||||
|
||||
@@ -376,9 +376,9 @@ class PlatformStore:
|
||||
pool_kwargs = {
|
||||
"connect_timeout": 5,
|
||||
"keepalives": 1,
|
||||
"keepalives_idle": 30,
|
||||
"keepalives_interval": 10,
|
||||
"keepalives_count": 5,
|
||||
"keepalives_idle": 10,
|
||||
"keepalives_interval": 5,
|
||||
"keepalives_count": 3,
|
||||
}
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=self.database_url,
|
||||
@@ -3115,12 +3115,24 @@ class PlatformStore:
|
||||
}
|
||||
|
||||
def health_metrics(self) -> dict[str, float]:
|
||||
# Health checks must stay lightweight. The Docker healthcheck and page
|
||||
# refresh probes should not wait on dashboard/GPU/database aggregation.
|
||||
# 轻量健康检查:采集真实 CPU/内存/磁盘使用率
|
||||
# 用于顶部栏快速展示与 Docker 健康检查。
|
||||
try:
|
||||
import psutil
|
||||
# cpu_percent(interval=None) 首次调用返回 0,需要短暂采样
|
||||
cpu_percent = float(psutil.cpu_percent(interval=0.1))
|
||||
memory_percent = float(psutil.virtual_memory().percent)
|
||||
# Windows 兼容:尝试当前盘符
|
||||
try:
|
||||
disk_percent = float(psutil.disk_usage('/').percent)
|
||||
except Exception:
|
||||
disk_percent = float(psutil.disk_usage('C:\\').percent)
|
||||
except Exception:
|
||||
cpu_percent = memory_percent = disk_percent = 0.0
|
||||
return {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_percent": 0.0,
|
||||
"disk_percent": 0.0,
|
||||
"cpu_percent": round(cpu_percent, 1),
|
||||
"memory_percent": round(memory_percent, 1),
|
||||
"disk_percent": round(disk_percent, 1),
|
||||
}
|
||||
|
||||
def queue(self) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.db.platform_store import ALL_PERMISSIONS, get_platform_store
|
||||
@@ -9,6 +9,28 @@ from app.db.platform_store import ALL_PERMISSIONS, get_platform_store
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
@router.post("/audit/visit")
|
||||
def record_visit(payload: dict = Body(...), request: Request = None) -> dict:
|
||||
"""记录用户访问业务模块的行为,用于看板用户操作分布统计。"""
|
||||
action = str(payload.get("action") or payload.get("module") or "").strip()
|
||||
if not action:
|
||||
return {"code": 0, "message": "ok", "data": {"recorded": False}}
|
||||
actor_id = ""
|
||||
if request is not None:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
if token.startswith("platform-token-"):
|
||||
actor_id = token[len("platform-token-"):]
|
||||
get_platform_store().record_audit(
|
||||
action=action,
|
||||
actor_id=actor_id or None,
|
||||
target_type="module",
|
||||
target_id=action,
|
||||
detail=str(payload.get("detail") or ""),
|
||||
)
|
||||
return {"code": 0, "message": "ok", "data": {"recorded": True}}
|
||||
|
||||
|
||||
@router.get("/permissions/codes")
|
||||
def permission_codes() -> dict:
|
||||
"""返回平台权限码清单(权限码接口)。"""
|
||||
|
||||
@@ -18,12 +18,12 @@ const auth = useAuthStore()
|
||||
*/
|
||||
let hiddenAt = 0
|
||||
|
||||
function handleVisibility() {
|
||||
async function handleVisibility() {
|
||||
if (document.hidden) {
|
||||
hiddenAt = Date.now()
|
||||
} else {
|
||||
if (hiddenAt > 0 && Date.now() - hiddenAt >= SESSION_TIMEOUT) {
|
||||
auth.logout()
|
||||
await auth.logout()
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
5
frontend/src/api/modules/audit-visit.ts
Normal file
5
frontend/src/api/modules/audit-visit.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { post } from '../request'
|
||||
|
||||
/** 记录用户访问某个业务模块(用于看板用户操作分布统计) */
|
||||
export const recordModuleVisit = (module: string, detail?: string) =>
|
||||
post('/system/audit/visit', { action: module, detail: detail || '' })
|
||||
@@ -53,3 +53,6 @@ export const updateProjectMember = (id: string, userId: string, role: string) =>
|
||||
/** 移除成员 */
|
||||
export const removeProjectMember = (id: string, userId: string) =>
|
||||
del(`/projects/${id}/members/${userId}`)
|
||||
|
||||
/** 删除项目 */
|
||||
export const deleteProject = (id: string) => del(`/projects/${id}`)
|
||||
|
||||
@@ -18,6 +18,10 @@ export const getHealth = () => get<HealthMetrics>('/health')
|
||||
export const login = (username: string, password: string) =>
|
||||
post<LoginResponse>('/login', { username, password })
|
||||
|
||||
/** 登出 */
|
||||
export const logout = (sessionId?: string) =>
|
||||
post('/logout', { session_id: sessionId || '' })
|
||||
|
||||
/** 用户列表 */
|
||||
export const getUsers = () => get<SystemUser[]>('/users')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, post, put } from '../request'
|
||||
import { del, get, post, put } from '../request'
|
||||
|
||||
export interface Tenant {
|
||||
id: string
|
||||
@@ -25,6 +25,9 @@ export const createTenant = (payload: Partial<Tenant>) =>
|
||||
export const updateTenant = (id: string, payload: Partial<Tenant>) =>
|
||||
put<Tenant>(`/tenants/${id}`, payload)
|
||||
|
||||
/** 删除租户 */
|
||||
export const deleteTenant = (id: string) => del(`/tenants/${id}`)
|
||||
|
||||
/** 设置租户配额 */
|
||||
export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
|
||||
put<Tenant>(`/tenants/${id}/quota`, { quota })
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/**
|
||||
* 用户操作分布:哪些模块路径算"业务操作"(用于看板统计)
|
||||
* 请求命中这些路径时,会自动调用 record_visit 记录一次(同一模块 60 秒内去重)
|
||||
*/
|
||||
const VISIT_TRACKED_PREFIXES: Array<[string, string]> = [
|
||||
['/fine-tune', 'fine-tune'],
|
||||
['/model-eval', 'model-eval'],
|
||||
['/model-inference', 'model-inference'],
|
||||
['/data-process', 'data-process'],
|
||||
['/data-convert', 'data-convert'],
|
||||
['/model-manage', 'model-manage'],
|
||||
['/dataset-manage', 'dataset'],
|
||||
]
|
||||
|
||||
function trackVisit(url: string | undefined) {
|
||||
if (!url) return
|
||||
for (const [prefix, module] of VISIT_TRACKED_PREFIXES) {
|
||||
if (url.includes(prefix)) {
|
||||
const key = `visit:${module}`
|
||||
const last = Number(sessionStorage.getItem(key) || 0)
|
||||
if (Date.now() - last < 60000) return // 60 秒内去重
|
||||
sessionStorage.setItem(key, String(Date.now()))
|
||||
// fire-and-forget 调用后端记录接口
|
||||
import('./modules/audit-visit').then(({ recordModuleVisit }) => {
|
||||
recordModuleVisit(module, url).catch(() => { /* ignore */ })
|
||||
}).catch(() => { /* ignore */ })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端统一响应格式
|
||||
* code === 0 表示成功,data 为业务数据
|
||||
@@ -14,7 +45,7 @@ export interface ApiResult<T = any> {
|
||||
const service: AxiosInstance = axios.create({
|
||||
// Use a relative path; Vite proxies /modelTF to http://localhost:17861 in local development.
|
||||
baseURL: '/modelTF',
|
||||
timeout: 30000,
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -60,6 +91,8 @@ service.interceptors.response.use(
|
||||
return response
|
||||
}
|
||||
if (res.code === 0) {
|
||||
// 记录业务模块访问(用于看板用户操作分布统计)
|
||||
trackVisit(response.config.url)
|
||||
return res.data
|
||||
}
|
||||
// 业务错误
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAcl, setAcl, type AclEntry } from '@/api/modules/acl'
|
||||
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -15,13 +16,20 @@ const visible = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
const entries = ref<AclEntry[]>([])
|
||||
const users = ref<SystemUser[]>([])
|
||||
const loading = ref(false)
|
||||
const ALL_PERMS = ['read', 'write', 'execute', 'download', 'delete', 'share']
|
||||
const PROJECT_ROLES = ['member', 'admin', 'viewer']
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
entries.value = await getAcl(props.resourceType, props.resourceId)
|
||||
const [acl, us] = await Promise.all([
|
||||
getAcl(props.resourceType, props.resourceId),
|
||||
getUsers().catch(() => [] as SystemUser[]),
|
||||
])
|
||||
entries.value = acl
|
||||
users.value = us
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -53,7 +61,12 @@ async function save() {
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="项目角色" value="project_role" />
|
||||
</el-select>
|
||||
<el-input v-model="entry.subject_id" placeholder="subject ID" style="width: 200px" />
|
||||
<el-select v-if="entry.subject_type === 'user'" v-model="entry.subject_id" placeholder="选择用户" style="width: 200px" filterable>
|
||||
<el-option v-for="u in users" :key="u.id" :label="`${u.username} (${u.id})`" :value="u.id" />
|
||||
</el-select>
|
||||
<el-select v-else v-model="entry.subject_id" placeholder="选择角色" style="width: 200px">
|
||||
<el-option v-for="r in PROJECT_ROLES" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
<el-checkbox-group v-model="entry.permissions">
|
||||
<el-checkbox v-for="p in ALL_PERMS" :key="p" :value="p">{{ p }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
|
||||
@@ -132,8 +132,8 @@ async function handleSelect(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -377,7 +377,7 @@ router.beforeEach((to, _from, next) => {
|
||||
}
|
||||
|
||||
if (!auth.isLoggedIn) {
|
||||
auth.logout()
|
||||
auth.logout() // fire-and-forget,无需阻塞跳转
|
||||
next({ name: 'login' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi } from '@/api/modules/system'
|
||||
import { login as loginApi, logout as logoutApi } from '@/api/modules/system'
|
||||
import type { PermissionCode, SystemUser } from '@/types'
|
||||
|
||||
const USER_STORAGE_KEY = 'currentUser'
|
||||
const SESSION_STORAGE_KEY = 'sessionId'
|
||||
|
||||
const allPermissions: PermissionCode[] = [
|
||||
'dashboard',
|
||||
@@ -29,20 +30,6 @@ function restoreUser(): SystemUser | null {
|
||||
localStorage.removeItem(USER_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容改造前已经登录的 admin 会话。
|
||||
if (localStorage.getItem('username') === 'admin') {
|
||||
return {
|
||||
id: 'USR-0001',
|
||||
username: 'admin',
|
||||
display_name: '系统管理员',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
permissions: allPermissions,
|
||||
create_time: '2026-01-01T08:00:00+08:00',
|
||||
protected: true,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -69,6 +56,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
currentUser.value = response.user
|
||||
localStorage.setItem('username', response.user.username)
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
|
||||
if (response.session_id) {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, response.session_id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查当前账号是否拥有指定模块权限。 */
|
||||
@@ -78,10 +68,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
/** 退出 */
|
||||
function logout() {
|
||||
async function logout() {
|
||||
const sessionId = localStorage.getItem(SESSION_STORAGE_KEY)
|
||||
if (sessionId) {
|
||||
try { await logoutApi(sessionId) } catch { /* 静默 */ }
|
||||
}
|
||||
currentUser.value = null
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem(USER_STORAGE_KEY)
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -449,6 +449,7 @@ export interface SystemUser {
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: SystemUser
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
|
||||
@@ -179,20 +179,30 @@ const chartOption = computed<EChartsOption>(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
// 模块固定配色,保证每个模块颜色不同
|
||||
const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6']
|
||||
// 模块固定配色,按顺序循环分配颜色(与后端 OP_ORDER 一致:数据处理/模型训练/模型评测/模型推理)
|
||||
const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6']
|
||||
const operationChartOption = computed<EChartsOption>(() => {
|
||||
const items = operationDistribution.value
|
||||
const total = items.reduce((s, d) => s + (d.value || 0), 0)
|
||||
// 完全没有操作数据时,用等分灰色占位扇区,保证 6 个模块都可见
|
||||
const data =
|
||||
total > 0
|
||||
? items.map((d) => ({ value: d.value || 0, name: d.name }))
|
||||
: items.map((d) => ({ value: 1, name: d.name, itemStyle: { color: '#e2e8f0' } }))
|
||||
// 按数据项顺序显式分配颜色,避免依赖 name 匹配或全局 color 数组;
|
||||
// value=0 的项给一个极小值(0.001)让扇区可见,从而显示各自颜色,
|
||||
// 但占比几乎为 0 不影响有数据项的百分比展示。
|
||||
const data = items.map((d, idx) => {
|
||||
const raw = d.value || 0
|
||||
return {
|
||||
value: total > 0 ? (raw > 0 ? raw : 0.001) : 1,
|
||||
name: d.name,
|
||||
itemStyle: {
|
||||
color: OPERATION_COLORS[idx % OPERATION_COLORS.length] || '#94a3b8',
|
||||
borderRadius: 6,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
return {
|
||||
animationDuration: 500,
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
color: OPERATION_COLORS,
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
bottom: 0,
|
||||
@@ -207,11 +217,6 @@ const operationChartOption = computed<EChartsOption>(() => {
|
||||
radius: ['38%', '60%'],
|
||||
center: ['50%', '42%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
@@ -235,17 +240,23 @@ const operationChartOption = computed<EChartsOption>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
const loginDurationChartOption = computed<EChartsOption>(() => {
|
||||
const stats = loginDurationStats.value
|
||||
const data = stats.map((u) => ({ name: u.username, value: u.duration }))
|
||||
const maxVal = data.length
|
||||
? Math.max(10, Math.ceil(Math.max(...data.map((d) => d.value), 0) * 1.15 / 10) * 10)
|
||||
: 10
|
||||
return {
|
||||
animationDuration: 500,
|
||||
grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
valueFormatter: (value) => `${value} 小时`,
|
||||
valueFormatter: (value: number) => `${value} 小时`,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
max: Math.max(10, Math.ceil(Math.max(...loginDurationStats.value.map((user) => user.duration), 0) * 1.15 / 10) * 10),
|
||||
max: maxVal,
|
||||
splitNumber: 4,
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
|
||||
axisLine: { show: false },
|
||||
@@ -255,8 +266,13 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
data: loginDurationStats.value.map((user) => user.username),
|
||||
axisLabel: { color: '#475569', fontSize: 12 },
|
||||
data: data.map((d) => d.name),
|
||||
axisLabel: {
|
||||
color: '#1f2937',
|
||||
fontSize: 14,
|
||||
fontFamily: '"PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif',
|
||||
margin: 12,
|
||||
},
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
@@ -264,14 +280,14 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
{
|
||||
name: '登录时长',
|
||||
type: 'bar',
|
||||
data: loginDurationStats.value.map((user) => user.duration),
|
||||
data: data.map((d) => d.value),
|
||||
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} 小时' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const roleTagType: Record<string, 'danger' | 'primary' | 'info'> = {
|
||||
'超级管理员': 'danger',
|
||||
@@ -405,10 +421,9 @@ function viewTask(task: DashboardTask) {
|
||||
|
||||
<section class="stat-card" aria-labelledby="login-dur-title">
|
||||
<h2 id="login-dur-title" class="section-title">登录时长排行 (本月)</h2>
|
||||
<div v-if="loginDurationStats.length" class="chart-container">
|
||||
<div class="chart-container">
|
||||
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
|
||||
</div>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-card" aria-labelledby="recent-login-title">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { createProject, getProjects, type Project } from '@/api/modules/project'
|
||||
import { createProject, deleteProject, getProjects, type Project } from '@/api/modules/project'
|
||||
import { getTenants, type Tenant } from '@/api/modules/tenant'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -13,13 +13,24 @@ const projects = ref<Project[]>([])
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const tenantId = ref('default')
|
||||
const showCreate = ref(false)
|
||||
const form = ref({ name: '', code: '', description: '', tenant_id: 'default' })
|
||||
const form = ref({ name: '', code: '', description: '', tenant_id: '' })
|
||||
|
||||
const tenantOptions = computed(() => [
|
||||
{ label: 'default', value: 'default' },
|
||||
...tenants.value.map((t) => ({ label: t.name, value: t.id })),
|
||||
])
|
||||
|
||||
const tenantCodeOptions = computed(() =>
|
||||
tenants.value.map((t) => ({ label: t.code, value: t.code, tenantId: t.id }))
|
||||
)
|
||||
|
||||
function onTenantCodeChange(code: string) {
|
||||
const tenant = tenants.value.find((t) => t.code === code)
|
||||
if (tenant) {
|
||||
form.value.tenant_id = tenant.id
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -46,14 +57,29 @@ function asProject(row: unknown): Project {
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!form.value.name || !form.value.code) {
|
||||
ElMessage.warning('请填写项目名与编码')
|
||||
if (!form.value.name || !form.value.tenant_id) {
|
||||
ElMessage.warning('请填写项目名与编码ID')
|
||||
return
|
||||
}
|
||||
await createProject({ ...form.value })
|
||||
ElMessage.success('项目创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', code: '', description: '', tenant_id: 'default' }
|
||||
form.value = { name: '', code: '', description: '', tenant_id: '' }
|
||||
load()
|
||||
}
|
||||
|
||||
async function handleDelete(row: Project) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除项目「${row.name}」吗?删除后相关数据将无法恢复。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteProject(row.id)
|
||||
ElMessage.success('项目已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
@@ -74,13 +100,14 @@ onMounted(() => {
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="项目名" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" min-width="100" />
|
||||
<el-table-column prop="code" label="编码ID" min-width="100" />
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(asProject(row).id)">详情</el-button>
|
||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
<el-dialog v-model="showCreate" title="新建项目" width="520px">
|
||||
@@ -88,12 +115,9 @@ onMounted(() => {
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="项目名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码" required>
|
||||
<el-input v-model="form.code" placeholder="project code" />
|
||||
</el-form-item>
|
||||
<el-form-item label="租户">
|
||||
<el-select v-model="form.tenant_id" style="width: 100%">
|
||||
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||
<el-form-item label="编码ID" required>
|
||||
<el-select v-model="form.tenant_id" style="width: 100%" placeholder="选择租户编码">
|
||||
<el-option v-for="t in tenantCodeOptions" :key="t.value" :label="t.label" :value="t.tenantId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { getTenant, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
import { getProjects, type Project } from '@/api/modules/project'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tenant = ref<Tenant | null>(null)
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const quotaText = ref('')
|
||||
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
||||
|
||||
function parseQuota(quota: Record<string, unknown> | undefined | null) {
|
||||
const q = quota || {}
|
||||
return {
|
||||
gpu: Number(q.gpu || q.gpu_quota || 0),
|
||||
storage: Number(q.storage || q.storage_quota || 0),
|
||||
maxProjects: Number(q.max_projects || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function formatQuota(quota: Record<string, unknown> | undefined | null) {
|
||||
const q = parseQuota(quota)
|
||||
const parts: string[] = []
|
||||
if (q.gpu > 0) parts.push(`GPU ${q.gpu}`)
|
||||
if (q.storage > 0) parts.push(`存储 ${q.storage}GB`)
|
||||
if (q.maxProjects > 0) parts.push(`项目 ${q.maxProjects}`)
|
||||
return parts.length ? parts.join(' | ') : '—'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const id = route.params.id as string
|
||||
loading.value = true
|
||||
try {
|
||||
tenant.value = await getTenant(id)
|
||||
projects.value = await getProjects(id)
|
||||
quotaText.value = JSON.stringify(tenant.value?.quota || {})
|
||||
const q = parseQuota(tenant.value?.quota)
|
||||
quotaForm.gpu = q.gpu
|
||||
quotaForm.storage = q.storage
|
||||
quotaForm.maxProjects = q.maxProjects
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -27,18 +44,13 @@ async function load() {
|
||||
|
||||
async function saveQuota() {
|
||||
if (!tenant.value) return
|
||||
try {
|
||||
const q = JSON.parse(quotaText.value || '{}')
|
||||
await setTenantQuota(tenant.value.id, q)
|
||||
const quota: Record<string, unknown> = {}
|
||||
if (quotaForm.gpu > 0) quota.gpu = quotaForm.gpu
|
||||
if (quotaForm.storage > 0) quota.storage = quotaForm.storage
|
||||
if (quotaForm.maxProjects > 0) quota.max_projects = quotaForm.maxProjects
|
||||
await setTenantQuota(tenant.value.id, quota)
|
||||
ElMessage.success('配额已保存')
|
||||
load()
|
||||
} catch {
|
||||
ElMessage.error('配额需为合法 JSON')
|
||||
}
|
||||
}
|
||||
|
||||
function openProject(id: string) {
|
||||
router.push(`/projects/${id}`)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
@@ -55,31 +67,30 @@ onMounted(load)
|
||||
<template #header>基本信息</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="名称">{{ tenant?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="编码">{{ tenant?.code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ tenant?.code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ tenant?.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ tenant?.create_time }}</el-descriptions-item>
|
||||
<el-descriptions-item label="配额">{{ formatQuota(tenant?.quota) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<div class="quota-edit">
|
||||
<span class="label">配额 JSON</span>
|
||||
<el-input v-model="quotaText" type="textarea" :rows="3" />
|
||||
<span class="label">配额设置(0 表示不限制)</span>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="GPU 数量">
|
||||
<el-input-number v-model="quotaForm.gpu" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储配额(GB)">
|
||||
<el-input-number v-model="quotaForm.storage" :min="0" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大项目数">
|
||||
<el-input-number v-model="quotaForm.maxProjects" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveQuota">保存配额</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="section">
|
||||
<template #header>项目空间</template>
|
||||
<DataTablePage title="项目空间" :data="projects">
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="项目名" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" min-width="100" />
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openProject(row.id)">打开</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { createTenant, getTenants, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
import { createTenant, deleteTenant, getTenants, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const showCreate = ref(false)
|
||||
const form = ref({ name: '', code: '', quota: '' as string })
|
||||
const showQuota = ref(false)
|
||||
const currentTenant = ref<Tenant | null>(null)
|
||||
const form = ref({ name: '', code: '', gpu: 0, storage: 0, maxProjects: 0 })
|
||||
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
||||
|
||||
function parseQuota(quota: Record<string, unknown> | undefined | null) {
|
||||
const q = quota || {}
|
||||
return {
|
||||
gpu: Number(q.gpu || q.gpu_quota || 0),
|
||||
storage: Number(q.storage || q.storage_quota || 0),
|
||||
maxProjects: Number(q.max_projects || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function formatQuota(quota: Record<string, unknown> | undefined | null) {
|
||||
const q = parseQuota(quota)
|
||||
const parts: string[] = []
|
||||
if (q.gpu > 0) parts.push(`GPU ${q.gpu}`)
|
||||
if (q.storage > 0) parts.push(`存储 ${q.storage}GB`)
|
||||
if (q.maxProjects > 0) parts.push(`项目 ${q.maxProjects}`)
|
||||
return parts.length ? parts.join(' | ') : '—'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -39,35 +60,51 @@ async function submitCreate() {
|
||||
ElMessage.warning('请填写租户名称')
|
||||
return
|
||||
}
|
||||
let quota: Record<string, unknown> = {}
|
||||
if (form.value.quota) {
|
||||
try {
|
||||
quota = JSON.parse(form.value.quota)
|
||||
} catch {
|
||||
ElMessage.error('配额需为合法 JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
const quota: Record<string, unknown> = {}
|
||||
if (form.value.gpu > 0) quota.gpu = form.value.gpu
|
||||
if (form.value.storage > 0) quota.storage = form.value.storage
|
||||
if (form.value.maxProjects > 0) quota.max_projects = form.value.maxProjects
|
||||
await createTenant({ name: form.value.name, code: form.value.code, quota })
|
||||
ElMessage.success('租户创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', code: '', quota: '' }
|
||||
form.value = { name: '', code: '', gpu: 0, storage: 0, maxProjects: 0 }
|
||||
load()
|
||||
}
|
||||
|
||||
async function setQuota(row: Tenant) {
|
||||
const input = await ElMessageBox.prompt('输入租户配额 JSON', '设置配额', {
|
||||
inputValue: JSON.stringify(row.quota || {}),
|
||||
}).catch(() => null)
|
||||
if (!input) return
|
||||
try {
|
||||
const q = JSON.parse(input.value)
|
||||
await setTenantQuota(row.id, q)
|
||||
ElMessage.success('配额已更新')
|
||||
load()
|
||||
} catch {
|
||||
ElMessage.error('无效的 JSON')
|
||||
function openQuotaDialog(row: Tenant) {
|
||||
currentTenant.value = row
|
||||
const q = parseQuota(row.quota)
|
||||
quotaForm.gpu = q.gpu
|
||||
quotaForm.storage = q.storage
|
||||
quotaForm.maxProjects = q.maxProjects
|
||||
showQuota.value = true
|
||||
}
|
||||
|
||||
async function submitQuota() {
|
||||
if (!currentTenant.value) return
|
||||
const quota: Record<string, unknown> = {}
|
||||
if (quotaForm.gpu > 0) quota.gpu = quotaForm.gpu
|
||||
if (quotaForm.storage > 0) quota.storage = quotaForm.storage
|
||||
if (quotaForm.maxProjects > 0) quota.max_projects = quotaForm.maxProjects
|
||||
await setTenantQuota(currentTenant.value.id, quota)
|
||||
ElMessage.success('配额已更新')
|
||||
showQuota.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
async function handleDelete(row: Tenant) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除租户「${row.name}」吗?删除后相关数据将无法恢复。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteTenant(row.id)
|
||||
ElMessage.success('租户已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
@@ -81,30 +118,34 @@ onMounted(load)
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="租户名称" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" min-width="100" />
|
||||
<el-table-column label="配额" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ quotaText(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" label="用户ID" min-width="100" />
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(asTenant(row).id)">详情</el-button>
|
||||
<el-button link type="primary" @click="setQuota(asTenant(row))">配额</el-button>
|
||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
|
||||
<!-- 新建租户弹窗 -->
|
||||
<el-dialog v-model="showCreate" title="新建租户" width="520px">
|
||||
<el-form label-width="90px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="租户名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码">
|
||||
<el-input v-model="form.code" placeholder="tenant code" />
|
||||
<el-form-item label="用户ID">
|
||||
<el-input v-model="form.code" placeholder="用户ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配额 JSON">
|
||||
<el-input v-model="form.quota" type="textarea" :rows="3" placeholder='{"gpu": 8}' />
|
||||
<el-divider content-position="left">配额设置(可选,0 表示不限制)</el-divider>
|
||||
<el-form-item label="GPU 数量">
|
||||
<el-input-number v-model="form.gpu" :min="0" :step="1" placeholder="GPU 卡数" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储配额(GB)">
|
||||
<el-input-number v-model="form.storage" :min="0" :step="10" placeholder="存储大小" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大项目数">
|
||||
<el-input-number v-model="form.maxProjects" :min="0" :step="1" placeholder="项目上限" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -112,6 +153,25 @@ onMounted(load)
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 设置配额弹窗 -->
|
||||
<el-dialog v-model="showQuota" title="设置配额" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="GPU 数量">
|
||||
<el-input-number v-model="quotaForm.gpu" :min="0" :step="1" placeholder="GPU 卡数" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储配额(GB)">
|
||||
<el-input-number v-model="quotaForm.storage" :min="0" :step="10" placeholder="存储大小" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大项目数">
|
||||
<el-input-number v-model="quotaForm.maxProjects" :min="0" :step="1" placeholder="项目上限" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showQuota = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitQuota">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
282
平台治理.md
Normal file
282
平台治理.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# 平台治理功能说明
|
||||
|
||||
平台治理涵盖用户管理、租户管理、项目管理、审批管理、审计日志、资源授权(ACL)、留存策略等企业管理能力。功能入口位于侧边栏「平台治理」和「系统设置」两个分组下。
|
||||
|
||||
本平台核心业务是**模型微调**:用户上传数据 → 数据处理 → 模型训练 → 模型评测 → 模型推理。平台治理负责管理**谁**能访问**哪个租户/项目**的**哪些资源**。
|
||||
|
||||
---
|
||||
|
||||
## 侧边栏菜单结构
|
||||
|
||||
```
|
||||
平台治理
|
||||
├── 租户管理 /tenants
|
||||
├── 项目空间 /projects
|
||||
├── 审计日志 /audit-logs
|
||||
├── 审批模板 /approval-templates
|
||||
└── 审批中心 /approval-instances
|
||||
|
||||
系统设置
|
||||
├── 用户设置 /user-settings
|
||||
├── 平台性能 /hardware
|
||||
└── 查看日志 /logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 租户管理
|
||||
|
||||
**对应页面**:列表页 `TenantListView.vue` + 详情页 `TenantDetailView.vue`
|
||||
**后端接口**:`GET/POST /tenants`、`GET/PUT/DELETE /tenants/:id`、`PUT /tenants/:id/quota`、`PUT /tenants/:id/retention-policy`
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:租户名称、用户ID、状态、创建时间
|
||||
|
||||
**行操作**:
|
||||
| 按钮 | 功能 |
|
||||
|------|------|
|
||||
| 详情 | 跳转详情页 `/tenants/:id` |
|
||||
| 删除 | 确认弹窗后调用 `DELETE /tenants/:id` |
|
||||
|
||||
**新建租户弹窗**:
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 名称 | 文本输入 | 必填 |
|
||||
| 用户ID | 文本输入 | 可选 |
|
||||
| GPU 数量 | 数字输入 | 配额,0=不限制 |
|
||||
| 存储配额(GB) | 数字输入 | 配额,0=不限制 |
|
||||
| 最大项目数 | 数字输入 | 配额,0=不限制 |
|
||||
|
||||
### 详情页
|
||||
|
||||
**基本信息**:名称、用户ID、状态、创建时间、配额(友好格式显示,如 `GPU 8 | 存储 100GB | 项目 5`)
|
||||
|
||||
**配额编辑**:GPU 数量 / 存储配额(GB) / 最大项目数,三个数字输入 + 保存按钮
|
||||
|
||||
**已移除**:详情页中不再内嵌项目空间列表(项目有独立页面)
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 编辑租户名称和用户ID(`updateTenant` API 已有,UI 缺失)
|
||||
- [ ] 留存策略关联设置(`setTenantRetention` API 已有,UI 缺失)
|
||||
- [ ] 列表页无搜索/筛选
|
||||
- [ ] 状态列无 Tag 着色
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目空间
|
||||
|
||||
**对应页面**:列表页 `ProjectListView.vue` + 详情页 `ProjectDetailView.vue`
|
||||
**后端接口**:`GET/POST /projects`、`GET/PUT/DELETE /projects/:id`、`GET/POST/DELETE /projects/:id/members`、`PUT /projects/:id/archive`
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:项目名、编码ID、状态、描述、创建时间
|
||||
|
||||
**工具栏**:
|
||||
- 租户选择器(下拉过滤,按租户编码选择)
|
||||
- 搜索框(按项目名/编码搜索)
|
||||
- 新建项目按钮
|
||||
|
||||
**新建项目弹窗**:
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 名称 | 文本输入 | 必填 |
|
||||
| 编码ID | 下拉选择 | 选择已有租户的编码(如 `default`),关联到该租户 |
|
||||
| 描述 | 文本域 | 可选,3行 |
|
||||
|
||||
**行操作**:详情 + 删除
|
||||
|
||||
### 详情页
|
||||
|
||||
**基本信息**:名称、编码、状态、租户、描述、创建时间
|
||||
|
||||
**项目成员管理**:
|
||||
- 成员列表(用户名、角色、添加时间)
|
||||
- 添加成员:选择用户 + 角色(member/admin/viewer)
|
||||
- 移除成员
|
||||
|
||||
**资源授权(ACL)**:
|
||||
- 弹窗编辑器,逐条配置授权规则
|
||||
- 主体类型:用户(下拉选已有用户)或项目角色(member/admin/viewer)
|
||||
- 权限:read / write / execute / download / delete / share(多选 checkbox)
|
||||
- 增删行后统一保存
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 编辑项目基本信息(名称、描述)
|
||||
- [ ] 项目启停/归档操作(`archiveProject` API 已有,UI 缺失)
|
||||
- [ ] 项目下的资源使用统计(模型数、数据集数、任务数)
|
||||
|
||||
---
|
||||
|
||||
## 3. 审批管理
|
||||
|
||||
**对应页面**:`ApprovalTemplateView.vue`(模板)+ `ApprovalInstanceView.vue`(实例)
|
||||
**后端接口**:模板 CRUD、实例列表+决策
|
||||
|
||||
### 审批模板
|
||||
|
||||
**展示列**:模板名、步骤数、创建时间
|
||||
|
||||
**新建模板**:
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 模板名称 | 文本输入 | 必填 |
|
||||
| 审批步骤 | 文本域 | **需手写 JSON 字符串**,体验差 |
|
||||
|
||||
**当前缺失**:
|
||||
- [ ] 可视化步骤编辑(拖拽添加步骤、选审批人)
|
||||
- [ ] 模板编辑/删除(API 已有,UI 缺失)
|
||||
- [ ] 模板详情页
|
||||
|
||||
### 审批中心
|
||||
|
||||
**展示列**:资源类型、资源ID、状态(Tag着色)、发起人、创建时间
|
||||
|
||||
**工具栏**:状态筛选(待审批/已通过/已拒绝)、资源类型/ID 搜索
|
||||
|
||||
**行操作**:通过/拒绝(弹窗填写审批意见)
|
||||
|
||||
**当前缺失**:
|
||||
- [ ] "我发起的"审批视角
|
||||
- [ ] 审批流转详情(谁审批了、什么时间)
|
||||
- [ ] 撤回功能
|
||||
|
||||
---
|
||||
|
||||
## 4. 审计日志
|
||||
|
||||
**对应页面**:`AuditLogView.vue`
|
||||
**后端接口**:`GET /system/audit-logs`(多条件筛选+分页)、`GET /system/audit-logs/export`(CSV导出)
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:时间、租户ID、项目ID、操作人ID、动作、目标类型、目标ID、详情、IP
|
||||
|
||||
**筛选条件**:租户ID、项目ID、操作人ID、动作、目标类型、开始时间、结束时间
|
||||
|
||||
**工具栏**:CSV 导出按钮(最多 10000 条)
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 分页控件(数据量大时需要翻页)
|
||||
- [ ] 筛选字段改为下拉选择(当前全是文本输入,不知道有哪些可选值)
|
||||
- [ ] 详情弹窗(点击某条记录查看完整信息)
|
||||
|
||||
---
|
||||
|
||||
## 5. 用户设置
|
||||
|
||||
**对应页面**:`UserSettingsView.vue`(列表)+ `UserCreateView.vue`(创建)+ `UserPermissionView.vue`(权限弹窗)
|
||||
**后端接口**:`GET/POST/PUT/DELETE /users`、`POST /users/:id/reset-password`
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:账号、显示名、角色、状态、页面权限、创建时间
|
||||
|
||||
**行操作**:
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 启停开关 | protected 用户和自己不可操作 |
|
||||
| 重置密码 | 弹窗确认,默认密码 `Platform@123` |
|
||||
| 页面权限 | 弹窗 checkbox 组,12 个模块可选:看板/模型训练/模型评测/模型推理/模型管理/数据集/数据处理/数据转换/算力/平台性能/查看日志/用户设置 |
|
||||
| 删除 | 确认弹窗,protected 用户和自己不可操作 |
|
||||
|
||||
### 创建页
|
||||
|
||||
**表单字段**:账号、显示名、初始密码、角色(超级管理员/操作员/观察员)、状态、页面权限
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 用户编辑页面(修改显示名、角色等,`updateUser` API 已有,UI 缺失)
|
||||
- [ ] 权限码显示为英文(如 `fine-tune`),无中文翻译
|
||||
|
||||
---
|
||||
|
||||
## 6. 平台性能
|
||||
|
||||
**对应页面**:`HardwareView.vue`
|
||||
**后端接口**:`GET /system-info`、`GET /health`(顶部栏实时指标)
|
||||
|
||||
### 页面内容
|
||||
|
||||
**概览卡片**:CPU(型号/核心数/使用率)、内存(已用/总量/使用率)、磁盘(已用/总量/使用率)、网络吞吐
|
||||
|
||||
**趋势图**:CPU/内存/磁盘/GPU 利用率折线图(最近 60 次采样,1/3/5/10秒自动刷新)
|
||||
|
||||
**GPU 资源池**:每张卡展示名称、状态、利用率、显存、温度、功耗、风扇转速;点击卡片查看详情抽屉(设备属性、趋势图、进程列表)
|
||||
|
||||
**主机信息**:OS、运行时长、进程数、GPU 驱动版本
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 历史数据持久化(刷新后采样清空)
|
||||
- [ ] 告警阈值设置
|
||||
- [ ] 网络吞吐数据显示为空
|
||||
|
||||
---
|
||||
|
||||
## 7. 查看日志
|
||||
|
||||
**对应页面**:`LogsView.vue`(系统日志 + 训练日志双 Tab)
|
||||
|
||||
### 系统日志
|
||||
|
||||
- 按日期选择日志文件
|
||||
- 关键词搜索
|
||||
- 日志级别筛选(INFO/WARN/ERROR/DEBUG)
|
||||
- 自动刷新(5/10/30/60 秒可调)
|
||||
|
||||
### 训练日志
|
||||
|
||||
- 按 PID 选择训练日志文件
|
||||
- 同样支持搜索和级别筛选
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 日志下载/导出
|
||||
- [ ] 分页或虚拟滚动(大文件加载慢)
|
||||
- [ ] 行号显示
|
||||
|
||||
---
|
||||
|
||||
## 8. 留存策略 ⚠️ 前端完全缺失
|
||||
|
||||
**后端 API 已完整实现**(CRUD 5 个端点),**前端 `retention.ts` 模块已封装**,但:
|
||||
- 侧边栏无菜单入口
|
||||
- 路由未配置
|
||||
- 无任何 Vue 页面
|
||||
|
||||
---
|
||||
|
||||
## 数据流转关系
|
||||
|
||||
```
|
||||
用户登录 → 分配角色(admin/operator/viewer) + 页面权限
|
||||
│
|
||||
├─ 创建租户(配额:GPU/存储/项目数)
|
||||
│ └─ 关联用户
|
||||
│
|
||||
├─ 创建项目(关联租户,选择编码ID)
|
||||
│ ├─ 项目成员(角色:member/admin/viewer)
|
||||
│ └─ 资源授权(ACL):谁对什么资源有什么权限
|
||||
│
|
||||
├─ 创建审批模板(定义审批流程)
|
||||
│ └─ 审批实例:敏感操作需要审批(通过/拒绝)
|
||||
│
|
||||
├─ 审计日志:所有操作自动记录(谁在什么时间做了什么)
|
||||
│
|
||||
└─ 留存策略:定义数据保留周期,自动清理过期数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成度总表
|
||||
|
||||
| 模块 | 列表 | 新建 | 编辑 | 删除 | 搜索 | 筛选 | 导出 | 完成度 |
|
||||
|------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| 租户管理 | ✅ | ✅ | 仅配额 | ✅ | ❌ | ❌ | ❌ | 75% |
|
||||
| 项目空间 | ✅ | ✅ | ❌ | ✅ | ✅ | 按租户 | ❌ | 80% |
|
||||
| 审批模板 | ✅ | ✅(JSON) | ❌ | ❌ | ❌ | ❌ | ❌ | 45% |
|
||||
| 审批中心 | ✅ | N/A | N/A | ❌ | ✅ | 按状态 | ❌ | 65% |
|
||||
| 审计日志 | ✅ | N/A | N/A | N/A | ❌ | 多字段 | CSV | 70% |
|
||||
| 用户设置 | ✅ | ✅ | 仅权限 | ✅ | ❌ | ❌ | ❌ | 75% |
|
||||
| 平台性能 | ✅ | N/A | N/A | N/A | N/A | N/A | ❌ | 80% |
|
||||
| 查看日志 | ✅ | N/A | N/A | N/A | 关键词 | 按级别 | ❌ | 70% |
|
||||
| **留存策略** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **0%** |
|
||||
Reference in New Issue
Block a user