update
This commit is contained in:
@@ -34,6 +34,7 @@ from fastapi import (
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.core.auth import filter_accessible_resource_ids, get_current_user, is_admin
|
||||||
from app.modules.data_process.algorithms import (
|
from app.modules.data_process.algorithms import (
|
||||||
ParsedText,
|
ParsedText,
|
||||||
canonical_record_json,
|
canonical_record_json,
|
||||||
@@ -815,18 +816,33 @@ def list_tasks(
|
|||||||
keyword: str | None = Query(default=None),
|
keyword: str | None = Query(default=None),
|
||||||
status: DataProcessStatus | None = Query(default=None),
|
status: DataProcessStatus | None = Query(default=None),
|
||||||
process_type: ProcessType | None = Query(default=None),
|
process_type: ProcessType | None = Query(default=None),
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
project_id: str | None = Query(default=None),
|
||||||
store: DataProcessStore = Depends(get_data_process_store),
|
store: DataProcessStore = Depends(get_data_process_store),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with api_errors():
|
with api_errors():
|
||||||
return ok(
|
tasks = store.list_tasks(
|
||||||
store.list_tasks(
|
page=page,
|
||||||
page=page,
|
page_size=page_size,
|
||||||
page_size=page_size,
|
keyword=keyword,
|
||||||
keyword=keyword,
|
status=status,
|
||||||
status=status,
|
process_type=process_type,
|
||||||
process_type=process_type,
|
tenant_id=tenant_id,
|
||||||
)
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
|
# #4 资源 ACL 过滤:admin 放行,普通用户只看到自己被授权的数据处理任务
|
||||||
|
items = tasks.get("items", [])
|
||||||
|
if not is_admin(current_user) and items:
|
||||||
|
accessible_ids = set(
|
||||||
|
filter_accessible_resource_ids(
|
||||||
|
"data-process", [t["id"] for t in items], current_user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items = [t for t in items if t["id"] in accessible_ids]
|
||||||
|
tasks["items"] = items
|
||||||
|
tasks["total"] = len(items)
|
||||||
|
return ok(tasks)
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
@@ -2282,13 +2298,11 @@ def regenerate_results_batch(
|
|||||||
max_keepalive_connections=RESULT_REGENERATION_CONCURRENCY,
|
max_keepalive_connections=RESULT_REGENERATION_CONCURRENCY,
|
||||||
)
|
)
|
||||||
# httpx.Client 支持跨线程复用,批次内共享连接池可减少重复建连开销。
|
# httpx.Client 支持跨线程复用,批次内共享连接池可减少重复建连开销。
|
||||||
with (
|
with httpx.Client(timeout=model_timeout, limits=model_limits) as model_client, \
|
||||||
httpx.Client(timeout=model_timeout, limits=model_limits) as model_client,
|
ThreadPoolExecutor(
|
||||||
ThreadPoolExecutor(
|
max_workers=min(RESULT_REGENERATION_CONCURRENCY, len(prepared)),
|
||||||
max_workers=min(RESULT_REGENERATION_CONCURRENCY, len(prepared)),
|
thread_name_prefix="data-result-regeneration",
|
||||||
thread_name_prefix="data-result-regeneration",
|
) as executor:
|
||||||
) as executor,
|
|
||||||
):
|
|
||||||
futures = {
|
futures = {
|
||||||
executor.submit(
|
executor.submit(
|
||||||
_regenerate_result_in_place,
|
_regenerate_result_in_place,
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Body, File, HTTPException, Query, UploadFile
|
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import PlainTextResponse, StreamingResponse
|
from fastapi.responses import PlainTextResponse, StreamingResponse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app.core.auth import filter_accessible_resource_ids, get_current_user, has_resource_access, is_admin
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.db.platform_store import get_platform_store
|
from app.db.platform_store import get_platform_store
|
||||||
from app.modules.compute_gateway.client import ComputeNodeClient
|
from app.modules.compute_gateway.client import ComputeNodeClient
|
||||||
@@ -35,6 +37,35 @@ def fail(status_code: int, message: str) -> HTTPException:
|
|||||||
return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None})
|
return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None})
|
||||||
|
|
||||||
|
|
||||||
|
def _require_approval_or_admin(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
current_user: dict[str, Any],
|
||||||
|
action_desc: str = "",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
高风险操作审批旁路:
|
||||||
|
- admin 用户直接放行(返回 None)
|
||||||
|
- 普通用户创建审批实例,返回审批待定响应(code=202,非 None)
|
||||||
|
code=202 使前端响应拦截器走业务错误分支,弹提示并 reject,
|
||||||
|
避免前端误认为删除成功。
|
||||||
|
"""
|
||||||
|
if is_admin(current_user):
|
||||||
|
return None
|
||||||
|
store = get_platform_store()
|
||||||
|
instance = store.create_approval_instance({
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"applicant_id": current_user.get("id"),
|
||||||
|
"template_id": None,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"code": 202,
|
||||||
|
"message": f"操作已提交审批,等待管理员批准:{action_desc}",
|
||||||
|
"data": {"approval_required": True, "approval_id": instance["id"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _node_for_task(task: dict[str, Any]) -> dict[str, Any] | None:
|
def _node_for_task(task: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None)
|
return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None)
|
||||||
|
|
||||||
@@ -231,8 +262,18 @@ async def login(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me() -> dict[str, Any]:
|
async def me(request: Request) -> dict[str, Any]:
|
||||||
return ok(get_platform_store().users()[0])
|
"""根据 Authorization header 中的 token 返回当前登录用户信息"""
|
||||||
|
store = get_platform_store()
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
token = auth.replace("Bearer ", "").strip()
|
||||||
|
# token 格式: platform-token-{user_id}
|
||||||
|
if token.startswith("platform-token-"):
|
||||||
|
user_id = token[len("platform-token-"):]
|
||||||
|
for u in store.users():
|
||||||
|
if u.get("id") == user_id:
|
||||||
|
return ok(u)
|
||||||
|
raise fail(401, "invalid or missing token")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/overview")
|
@router.get("/dashboard/overview")
|
||||||
@@ -251,6 +292,183 @@ async def dashboard_overview() -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/stats")
|
||||||
|
async def dashboard_stats() -> dict[str, Any]:
|
||||||
|
"""看板聚合数据:基于平台真实数据;缺项做合理近似。"""
|
||||||
|
store = get_platform_store()
|
||||||
|
tasks = store.tasks()
|
||||||
|
users = store.users()
|
||||||
|
nodes = store.compute_nodes()
|
||||||
|
datasets = store.datasets()
|
||||||
|
eval_tasks = store.eval_tasks()
|
||||||
|
# 数据处理任务总数(来自 data_process 模块)
|
||||||
|
try:
|
||||||
|
from app.modules.data_process.store import get_data_process_store
|
||||||
|
|
||||||
|
dp_store = get_data_process_store()
|
||||||
|
dp_result = dp_store.list_tasks(page=1, page_size=1)
|
||||||
|
dp_count = int(dp_result.get("total", 0))
|
||||||
|
except Exception:
|
||||||
|
dp_count = 0
|
||||||
|
|
||||||
|
running_statuses = {"syncing", "queued", "running"}
|
||||||
|
running_ft = [t for t in tasks if t.get("status") in running_statuses]
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# 近 7 天训练统计(按创建日期分桶)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
train_by_day: dict[str, int] = {}
|
||||||
|
for t in tasks:
|
||||||
|
ct = t.get("create_time")
|
||||||
|
if ct:
|
||||||
|
train_by_day[ct[:10]] = train_by_day.get(ct[:10], 0) + 1
|
||||||
|
training_7d = []
|
||||||
|
for i in range(6, -1, -1):
|
||||||
|
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
training_7d.append(
|
||||||
|
{
|
||||||
|
"date": day[5:],
|
||||||
|
"train": train_by_day.get(day, 0),
|
||||||
|
"gpu": sum(len(t.get("gpus") or []) for t in running_ft),
|
||||||
|
"accuracy": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 服务状态 —— 与界面实际数据对齐
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 训练任务状态归一化
|
||||||
|
status_map = {
|
||||||
|
"syncing": "running",
|
||||||
|
"queued": "running",
|
||||||
|
"running": "running",
|
||||||
|
"pending": "pending",
|
||||||
|
"paused": "pending",
|
||||||
|
"completed": "completed",
|
||||||
|
"failed": "failed",
|
||||||
|
"error": "failed",
|
||||||
|
"cancelled": "failed",
|
||||||
|
"stopped": "failed",
|
||||||
|
}
|
||||||
|
training_tasks = [
|
||||||
|
{
|
||||||
|
"id": t.get("id"),
|
||||||
|
"name": t.get("name"),
|
||||||
|
"status": status_map.get(t.get("status"), "pending"),
|
||||||
|
"train_type": t.get("train_type") or t.get("trainType") or "",
|
||||||
|
"train_method": t.get("train_method") or t.get("trainMethod") or "",
|
||||||
|
"base_model": t.get("base_model") or t.get("baseModel") or "",
|
||||||
|
"progress": t.get("progress", 0),
|
||||||
|
"accuracy": t.get("accuracy"),
|
||||||
|
"started_at": (t.get("create_time") or "")[:16],
|
||||||
|
}
|
||||||
|
for t in tasks[:8]
|
||||||
|
]
|
||||||
|
|
||||||
|
# 用户操作分布:统计平台全部操作(含治理模块)
|
||||||
|
MODULE_LABELS = [
|
||||||
|
("data-process", "数据处理"),
|
||||||
|
("data_process", "数据处理"),
|
||||||
|
("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:
|
||||||
|
a = (action or "").lower()
|
||||||
|
for prefix, label in MODULE_LABELS:
|
||||||
|
if a.startswith(prefix):
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
audit = store.audit_logs(limit=1000)
|
||||||
|
op_counter: dict[str, int] = {label: 0 for label in OP_ORDER}
|
||||||
|
for log in audit.get("items", []):
|
||||||
|
label = _op_module(log.get("action") or "")
|
||||||
|
if label:
|
||||||
|
op_counter[label] += 1
|
||||||
|
operation_distribution = [{"name": k, "value": v} for k, v in op_counter.items()]
|
||||||
|
|
||||||
|
# 最近登录用户
|
||||||
|
recent = sorted(
|
||||||
|
[u for u in users if u.get("last_login")],
|
||||||
|
key=lambda u: u["last_login"],
|
||||||
|
reverse=True,
|
||||||
|
)[:5]
|
||||||
|
recent_login_users = [
|
||||||
|
{
|
||||||
|
"user": u.get("display_name") or u.get("username"),
|
||||||
|
"role": u.get("role"),
|
||||||
|
"last_login": (u.get("last_login") or "")[:16],
|
||||||
|
}
|
||||||
|
for u in recent
|
||||||
|
]
|
||||||
|
|
||||||
|
# 登录时长排行(本月)
|
||||||
|
login_duration_rank = store.login_duration_rank()
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
{
|
||||||
|
"online_services": sum(s["count"] for s in service_status),
|
||||||
|
"running_tasks": len(running_ft),
|
||||||
|
"pending_alerts": 0,
|
||||||
|
"training_7d": training_7d,
|
||||||
|
"service_status": service_status,
|
||||||
|
"training_tasks": training_tasks,
|
||||||
|
"operation_distribution": operation_distribution,
|
||||||
|
"login_duration_rank": login_duration_rank,
|
||||||
|
"recent_login_users": recent_login_users,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/system-info")
|
@router.get("/system-info")
|
||||||
async def system_info() -> dict[str, Any]:
|
async def system_info() -> dict[str, Any]:
|
||||||
return ok(get_platform_store().system_info())
|
return ok(get_platform_store().system_info())
|
||||||
@@ -285,6 +503,21 @@ async def delete_user(user_id: str, current_username: str | None = Query(default
|
|||||||
raise fail(400, str(exc))
|
raise fail(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/reset-password")
|
||||||
|
async def reset_user_password(
|
||||||
|
user_id: str,
|
||||||
|
payload: dict[str, Any] = Body(default={}),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
new_password = payload.get("password") or "Platform@123"
|
||||||
|
try:
|
||||||
|
get_platform_store().reset_password(user_id, new_password)
|
||||||
|
return ok({"reset": user_id})
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "user not found")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise fail(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/model-manage/local-models")
|
@router.get("/model-manage/local-models")
|
||||||
async def local_models() -> dict[str, Any]:
|
async def local_models() -> dict[str, Any]:
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
@@ -348,8 +581,13 @@ async def model_by_name(name: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/model-manage")
|
@router.get("/model-manage")
|
||||||
async def model_list() -> dict[str, Any]:
|
async def model_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return ok(get_platform_store().models())
|
models = get_platform_store().models()
|
||||||
|
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||||
|
return ok(models)
|
||||||
|
# 普通用户只返回有 ACL 授权的模型
|
||||||
|
accessible = set(filter_accessible_resource_ids("model", [m["id"] for m in models], current_user))
|
||||||
|
return ok([m for m in models if m["id"] in accessible])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/model-manage")
|
@router.post("/model-manage")
|
||||||
@@ -365,11 +603,14 @@ async def create_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/model-manage/{model_id}")
|
@router.get("/model-manage/{model_id}")
|
||||||
async def model_detail(model_id: str) -> dict[str, Any]:
|
async def model_detail(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ok(get_platform_store().model(model_id))
|
model = get_platform_store().model(model_id)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise fail(404, "model not found")
|
raise fail(404, "model not found")
|
||||||
|
if not has_resource_access("model", model_id, current_user, "read"):
|
||||||
|
raise fail(403, "no permission to access this model")
|
||||||
|
return ok(model)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/model-manage/{model_id}")
|
@router.put("/model-manage/{model_id}")
|
||||||
@@ -389,7 +630,12 @@ async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(...
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/model-manage/{model_id}")
|
@router.delete("/model-manage/{model_id}")
|
||||||
async def delete_model(model_id: str) -> dict[str, Any]:
|
async def delete_model(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("model", model_id, current_user, "delete"):
|
||||||
|
raise fail(403, "no permission to delete this model")
|
||||||
|
pending = _require_approval_or_admin("model", model_id, current_user, f"删除模型 {model_id}")
|
||||||
|
if pending:
|
||||||
|
return pending
|
||||||
get_platform_store().delete_model(model_id)
|
get_platform_store().delete_model(model_id)
|
||||||
return ok({"deleted": model_id})
|
return ok({"deleted": model_id})
|
||||||
|
|
||||||
@@ -651,8 +897,12 @@ async def download_dataset_file(dataset_id: str, file_id: str, version_id: str |
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/dataset-manage")
|
@router.get("/dataset-manage")
|
||||||
async def dataset_list() -> dict[str, Any]:
|
async def dataset_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return ok(get_platform_store().datasets())
|
datasets = get_platform_store().datasets()
|
||||||
|
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||||
|
return ok(datasets)
|
||||||
|
accessible = set(filter_accessible_resource_ids("dataset", [d["id"] for d in datasets], current_user))
|
||||||
|
return ok([d for d in datasets if d["id"] in accessible])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dataset-manage")
|
@router.post("/dataset-manage")
|
||||||
@@ -662,11 +912,14 @@ async def create_dataset(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/dataset-manage/{dataset_id}")
|
@router.get("/dataset-manage/{dataset_id}")
|
||||||
async def dataset_detail(dataset_id: str) -> dict[str, Any]:
|
async def dataset_detail(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ok(get_platform_store().dataset(dataset_id))
|
dataset = get_platform_store().dataset(dataset_id)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise fail(404, "dataset not found")
|
raise fail(404, "dataset not found")
|
||||||
|
if not has_resource_access("dataset", dataset_id, current_user, "read"):
|
||||||
|
raise fail(403, "no permission to access this dataset")
|
||||||
|
return ok(dataset)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/dataset-manage/{dataset_id}")
|
@router.put("/dataset-manage/{dataset_id}")
|
||||||
@@ -678,7 +931,12 @@ async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) -
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/dataset-manage/{dataset_id}")
|
@router.delete("/dataset-manage/{dataset_id}")
|
||||||
async def delete_dataset(dataset_id: str) -> dict[str, Any]:
|
async def delete_dataset(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("dataset", dataset_id, current_user, "delete"):
|
||||||
|
raise fail(403, "no permission to delete this dataset")
|
||||||
|
pending = _require_approval_or_admin("dataset", dataset_id, current_user, f"删除数据集 {dataset_id}")
|
||||||
|
if pending:
|
||||||
|
return pending
|
||||||
get_platform_store().delete_dataset(dataset_id)
|
get_platform_store().delete_dataset(dataset_id)
|
||||||
return ok({"deleted": dataset_id})
|
return ok({"deleted": dataset_id})
|
||||||
|
|
||||||
@@ -703,8 +961,12 @@ async def tensorboard_start() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/fine-tune")
|
@router.get("/fine-tune")
|
||||||
async def fine_tune_list() -> dict[str, Any]:
|
async def fine_tune_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return ok(get_platform_store().tasks())
|
tasks = get_platform_store().tasks()
|
||||||
|
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||||
|
return ok(tasks)
|
||||||
|
accessible = set(filter_accessible_resource_ids("fine-tune", [t["id"] for t in tasks], current_user))
|
||||||
|
return ok([t for t in tasks if t["id"] in accessible])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fine-tune")
|
@router.post("/fine-tune")
|
||||||
@@ -897,7 +1159,12 @@ async def retry_fine_tune(task_id: str, payload: dict[str, Any] | None = Body(de
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/fine-tune/{task_id}")
|
@router.delete("/fine-tune/{task_id}")
|
||||||
async def delete_fine_tune(task_id: str) -> dict[str, Any]:
|
async def delete_fine_tune(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("fine-tune", task_id, current_user, "delete"):
|
||||||
|
raise fail(403, "no permission to delete this task")
|
||||||
|
pending = _require_approval_or_admin("fine-tune", task_id, current_user, f"删除训练任务 {task_id}")
|
||||||
|
if pending:
|
||||||
|
return pending
|
||||||
get_platform_store().delete_task(task_id)
|
get_platform_store().delete_task(task_id)
|
||||||
return ok({"deleted": task_id})
|
return ok({"deleted": task_id})
|
||||||
|
|
||||||
@@ -937,16 +1204,23 @@ async def fine_tune_metrics(task_id: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/model-eval")
|
@router.get("/model-eval")
|
||||||
async def model_eval_list() -> dict[str, Any]:
|
async def model_eval_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return ok(get_platform_store().eval_tasks())
|
tasks = get_platform_store().eval_tasks()
|
||||||
|
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||||
|
return ok(tasks)
|
||||||
|
accessible = set(filter_accessible_resource_ids("eval", [t["id"] for t in tasks], current_user))
|
||||||
|
return ok([t for t in tasks if t["id"] in accessible])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/model-eval/{task_id}")
|
@router.get("/model-eval/{task_id}")
|
||||||
async def model_eval_detail(task_id: str) -> dict[str, Any]:
|
async def model_eval_detail(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ok(get_platform_store().eval_task(task_id))
|
task = get_platform_store().eval_task(task_id)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise fail(404, "eval task not found")
|
raise fail(404, "eval task not found")
|
||||||
|
if not has_resource_access("eval", task_id, current_user, "read"):
|
||||||
|
raise fail(403, "no permission to access this eval task")
|
||||||
|
return ok(task)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/model-eval/start")
|
@router.post("/model-eval/start")
|
||||||
@@ -956,7 +1230,12 @@ async def model_eval_start(payload: dict[str, Any] = Body(...)) -> dict[str, Any
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/model-eval/{task_id}")
|
@router.delete("/model-eval/{task_id}")
|
||||||
async def model_eval_delete(task_id: str) -> dict[str, Any]:
|
async def model_eval_delete(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("eval", task_id, current_user, "delete"):
|
||||||
|
raise fail(403, "no permission to delete this eval task")
|
||||||
|
pending = _require_approval_or_admin("eval", task_id, current_user, f"删除评测任务 {task_id}")
|
||||||
|
if pending:
|
||||||
|
return pending
|
||||||
get_platform_store().delete_eval_task(task_id)
|
get_platform_store().delete_eval_task(task_id)
|
||||||
return ok({"deleted": task_id})
|
return ok({"deleted": task_id})
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,20 @@
|
|||||||
from app.api.v1.endpoints.data_process import router as data_process_router
|
from app.api.v1.endpoints.data_process import router as data_process_router
|
||||||
from app.api.v1.endpoints.platform import router as platform_router
|
from app.api.v1.endpoints.platform import router as platform_router
|
||||||
from app.api.v1.endpoints.health import router as health_router
|
from app.api.v1.endpoints.health import router as health_router
|
||||||
|
from app.modules.tenant.router import router as tenant_router
|
||||||
|
from app.modules.project.router import router as project_router
|
||||||
|
from app.modules.approval.router import router as approval_router
|
||||||
|
from app.modules.system.router import router as system_router
|
||||||
|
from app.modules.retention.router import router as retention_router
|
||||||
|
from app.modules.resource.router import router as resource_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health_router, tags=["health"])
|
api_router.include_router(health_router, tags=["health"])
|
||||||
api_router.include_router(data_process_router, tags=["data-process"])
|
api_router.include_router(data_process_router, tags=["data-process"])
|
||||||
api_router.include_router(platform_router, tags=["platform"])
|
api_router.include_router(platform_router, tags=["platform"])
|
||||||
|
api_router.include_router(system_router, tags=["system"])
|
||||||
|
api_router.include_router(tenant_router, tags=["tenant"])
|
||||||
|
api_router.include_router(project_router, tags=["project"])
|
||||||
|
api_router.include_router(approval_router, tags=["approval"])
|
||||||
|
api_router.include_router(retention_router, tags=["retention"])
|
||||||
|
api_router.include_router(resource_router, tags=["resource"])
|
||||||
|
|||||||
138
backend/app/core/auth.py
Normal file
138
backend/app/core/auth.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""鉴权依赖:从 Authorization header 解析当前用户,提供权限校验。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
# 无需鉴权的路径前缀(健康检查、登录等)
|
||||||
|
PUBLIC_PATHS = ("/health", "/login", "/system-info")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token(request: Request) -> str | None:
|
||||||
|
"""从 Authorization header 提取 token(格式: Bearer platform-token-{user_id})。"""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
token = auth.replace("Bearer ", "").strip()
|
||||||
|
if token.startswith("platform-token-"):
|
||||||
|
return token[len("platform-token-"):]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
FastAPI 依赖:解析当前登录用户。
|
||||||
|
- 公开路径(/health, /login 等)直接放行,返回匿名用户。
|
||||||
|
- 无 token 或 token 无效时抛 401。
|
||||||
|
- admin 用户标记为超级管理员,拥有全部权限。
|
||||||
|
"""
|
||||||
|
path = request.url.path
|
||||||
|
# 去掉路由前缀后判断
|
||||||
|
for prefix in PUBLIC_PATHS:
|
||||||
|
if path.endswith(prefix):
|
||||||
|
return {"id": None, "username": "anonymous", "role": "viewer", "permissions": [], "protected": False}
|
||||||
|
|
||||||
|
user_id = _extract_token(request)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing or invalid token")
|
||||||
|
|
||||||
|
store = get_platform_store()
|
||||||
|
for u in store.users():
|
||||||
|
if u.get("id") == user_id:
|
||||||
|
return u
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user: dict[str, Any] = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
"""FastAPI 依赖:要求当前用户是管理员(role=admin 或 protected)。"""
|
||||||
|
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||||
|
return current_user
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin permission required")
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user: dict[str, Any]) -> bool:
|
||||||
|
"""判断用户是否为管理员(admin 角色或 protected 标记)。"""
|
||||||
|
return user.get("role") == "admin" or user.get("protected", False)
|
||||||
|
|
||||||
|
|
||||||
|
def has_resource_access(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
user: dict[str, Any],
|
||||||
|
permission: str = "read",
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
检查用户对某资源是否有指定权限。
|
||||||
|
- admin/protected 用户直接放行(旁路)。
|
||||||
|
- 其他用户检查 acls 表中是否有对应授权。
|
||||||
|
"""
|
||||||
|
if user.get("role") == "admin" or user.get("protected"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
store = get_platform_store()
|
||||||
|
acls = store.get_acl(resource_type, resource_id)
|
||||||
|
user_id = user.get("id")
|
||||||
|
user_role = user.get("role")
|
||||||
|
|
||||||
|
for entry in acls:
|
||||||
|
# 按 user 授权
|
||||||
|
if entry.get("principal_type") == "user" and entry.get("principal_id") == user_id:
|
||||||
|
if _permission_covers(entry.get("permission"), permission):
|
||||||
|
return True
|
||||||
|
# 按 role 授权
|
||||||
|
if entry.get("principal_type") == "role" and entry.get("principal_id") == user_role:
|
||||||
|
if _permission_covers(entry.get("permission"), permission):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _permission_covers(granted: str | None, required: str) -> bool:
|
||||||
|
"""权限覆盖判断:write/execute 覆盖 read;admin 覆盖一切。"""
|
||||||
|
if not granted:
|
||||||
|
return False
|
||||||
|
if granted == "admin":
|
||||||
|
return True
|
||||||
|
if granted == required:
|
||||||
|
return True
|
||||||
|
# write 覆盖 read
|
||||||
|
if required == "read" and granted in ("write", "execute"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_accessible_resource_ids(
|
||||||
|
resource_type: str,
|
||||||
|
all_ids: list[str],
|
||||||
|
user: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
从全部资源 ID 中过滤出当前用户可访问的 ID 列表。
|
||||||
|
- admin 直接返回全部。
|
||||||
|
- 普通用户查 acls 表取交集。
|
||||||
|
"""
|
||||||
|
if user.get("role") == "admin" or user.get("protected"):
|
||||||
|
return all_ids
|
||||||
|
|
||||||
|
if not all_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
store = get_platform_store()
|
||||||
|
user_id = user.get("id")
|
||||||
|
user_role = user.get("role")
|
||||||
|
|
||||||
|
# 查询该用户在该资源类型下有 read 权限的所有 resource_id
|
||||||
|
with store.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT resource_id FROM acls
|
||||||
|
WHERE resource_type=? AND (
|
||||||
|
(principal_type='user' AND principal_id=?)
|
||||||
|
OR (principal_type='role' AND principal_id=?)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(resource_type, user_id, user_role),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
accessible = {r["resource_id"] for r in rows}
|
||||||
|
return [rid for rid in all_ids if rid in accessible]
|
||||||
@@ -2,6 +2,18 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 显式指定 backend 目录下的 .env,并强制覆盖已有环境变量,
|
||||||
|
# 确保远程数据库配置生效,不被本地默认值或残留环境变量影响。
|
||||||
|
_env_path = _Path(__file__).resolve().parent.parent.parent / ".env"
|
||||||
|
load_dotenv(dotenv_path=_env_path, override=True)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _int_env(name: str, default: int) -> int:
|
def _int_env(name: str, default: int) -> int:
|
||||||
raw = os.getenv(name)
|
raw = os.getenv(name)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
@@ -291,21 +292,54 @@ class PlatformStore:
|
|||||||
def __init__(self, database_url: str | None = None) -> None:
|
def __init__(self, database_url: str | None = None) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
self.database_url = _psycopg_url(database_url or settings.database_url)
|
self.database_url = _psycopg_url(database_url or settings.database_url)
|
||||||
|
# Reuse connections via a pool to avoid the TCP+auth handshake on every
|
||||||
|
# request (notably expensive against the remote PostgreSQL instance).
|
||||||
|
# TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。
|
||||||
|
pool_kwargs = {
|
||||||
|
"keepalives": 1,
|
||||||
|
"keepalives_idle": 30,
|
||||||
|
"keepalives_interval": 10,
|
||||||
|
"keepalives_count": 5,
|
||||||
|
}
|
||||||
|
self._pool = ConnectionPool(
|
||||||
|
conninfo=self.database_url,
|
||||||
|
kwargs=pool_kwargs,
|
||||||
|
min_size=2,
|
||||||
|
max_size=10,
|
||||||
|
# 借出前校验连接可用性,避免执行 SQL 时才发现 [BAD] 再重建。
|
||||||
|
check=ConnectionPool.check_connection,
|
||||||
|
# 不主动回收空闲连接(远程库约 10s 断,由 keepalive 维持),
|
||||||
|
# 减少无谓的重建握手。
|
||||||
|
max_idle=0,
|
||||||
|
# 请求最多排队等待 5s,避免雪崩时无限堆积。
|
||||||
|
max_waiting=16,
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
# 注意:不要在此调用 pool.wait(),它会阻塞等待 min_size 个连接就绪,
|
||||||
|
# 在远程库响应慢/超时时会卡死 uvicorn worker 进程,导致所有请求无响应。
|
||||||
|
self._pool.open()
|
||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
self.ensure_seed_data()
|
self.ensure_seed_data()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connect(self) -> Iterator["PgConnection"]:
|
def connect(self) -> Iterator["PgConnection"]:
|
||||||
raw_conn = psycopg.connect(self.database_url)
|
with self._pool.connection() as raw_conn:
|
||||||
conn = PgConnection(raw_conn)
|
conn = PgConnection(raw_conn)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def close_pool(self) -> None:
|
||||||
|
"""Release pooled connections. Safe to call multiple times."""
|
||||||
try:
|
try:
|
||||||
yield conn
|
self._pool.close()
|
||||||
conn.commit()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.rollback()
|
pass
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def ensure_schema(self) -> None:
|
def ensure_schema(self) -> None:
|
||||||
schema_path = Path(__file__).with_name("sql") / "001_platform_runtime.sql"
|
schema_path = Path(__file__).with_name("sql") / "001_platform_runtime.sql"
|
||||||
@@ -335,6 +369,11 @@ class PlatformStore:
|
|||||||
"last_error": "TEXT",
|
"last_error": "TEXT",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
schema_dir = Path(__file__).with_name("sql")
|
||||||
|
for extra in ("002_governance.sql", "003_tenant_quota.sql"):
|
||||||
|
extra_path = schema_dir / extra
|
||||||
|
if extra_path.exists():
|
||||||
|
conn.executescript(extra_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
def _column_names(self, conn: PgConnection, table_name: str) -> set[str]:
|
def _column_names(self, conn: PgConnection, table_name: str) -> set[str]:
|
||||||
columns = conn.execute(
|
columns = conn.execute(
|
||||||
@@ -1095,6 +1134,18 @@ class PlatformStore:
|
|||||||
raise ValueError("protected user cannot be deleted")
|
raise ValueError("protected user cannot be deleted")
|
||||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||||
|
|
||||||
|
def reset_password(self, user_id: str, new_password: str) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT protected FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(user_id)
|
||||||
|
if row["protected"]:
|
||||||
|
raise ValueError("protected user cannot reset password")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash=? WHERE id=?",
|
||||||
|
(hash_password(new_password), user_id),
|
||||||
|
)
|
||||||
|
|
||||||
def _user(self, row: PgRow) -> dict[str, Any]:
|
def _user(self, row: PgRow) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
@@ -2891,6 +2942,672 @@ class PlatformStore:
|
|||||||
]
|
]
|
||||||
return {"file": file_name, "content": "\n".join(lines), "size": "1 KB"}
|
return {"file": file_name, "content": "\n".join(lines), "size": "1 KB"}
|
||||||
|
|
||||||
|
# ===================== 平台治理:角色 =====================
|
||||||
|
|
||||||
|
def roles(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM roles ORDER BY name").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# ===================== 平台治理:审计日志 =====================
|
||||||
|
|
||||||
|
def audit_logs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
actor_id: str | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
target_type: str | None = None,
|
||||||
|
start_time: str | None = None,
|
||||||
|
end_time: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if tenant_id:
|
||||||
|
clauses.append("tenant_id=?")
|
||||||
|
params.append(tenant_id)
|
||||||
|
if project_id:
|
||||||
|
clauses.append("project_id=?")
|
||||||
|
params.append(project_id)
|
||||||
|
if actor_id:
|
||||||
|
clauses.append("actor_id=?")
|
||||||
|
params.append(actor_id)
|
||||||
|
if action:
|
||||||
|
clauses.append("action=?")
|
||||||
|
params.append(action)
|
||||||
|
if target_type:
|
||||||
|
clauses.append("target_type=?")
|
||||||
|
params.append(target_type)
|
||||||
|
if start_time:
|
||||||
|
clauses.append("time>=?")
|
||||||
|
params.append(start_time)
|
||||||
|
if end_time:
|
||||||
|
clauses.append("time<=?")
|
||||||
|
params.append(end_time)
|
||||||
|
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
with self.connect() as conn:
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) AS c FROM audit_logs{where}", tuple(params)).fetchone()["c"]
|
||||||
|
params_paged = list(params) + [limit, offset]
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM audit_logs{where} ORDER BY time DESC LIMIT ? OFFSET ?",
|
||||||
|
tuple(params_paged),
|
||||||
|
).fetchall()
|
||||||
|
return {"total": total, "items": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
def record_audit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor_id: str | None = None,
|
||||||
|
target_type: str | None = None,
|
||||||
|
target_id: str | None = None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
detail: str | None = None,
|
||||||
|
ip: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs
|
||||||
|
(id, tenant_id, project_id, actor_id, action, target_type, target_id, detail, client_ip, time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
new_id("log"),
|
||||||
|
tenant_id,
|
||||||
|
project_id,
|
||||||
|
actor_id,
|
||||||
|
action,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
detail,
|
||||||
|
ip,
|
||||||
|
utcnow(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===================== 平台治理:会话 =====================
|
||||||
|
|
||||||
|
def create_session(self, user_id: str, *, ip: str | None = None) -> dict[str, Any]:
|
||||||
|
sid = new_id("sess")
|
||||||
|
login_at = utcnow()
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, user_id, username, login_at, create_time) "
|
||||||
|
"VALUES (%s, %s, (SELECT username FROM users WHERE id=%s), %s, %s)",
|
||||||
|
(sid, user_id, user_id, login_at, login_at),
|
||||||
|
)
|
||||||
|
return {"session_id": sid, "user_id": user_id, "login_at": login_at}
|
||||||
|
|
||||||
|
def finish_session(self, session_id: str) -> None:
|
||||||
|
"""登出时记录 logout_at 与时长(秒)。"""
|
||||||
|
logout_at = utcnow()
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET logout_at=%s, "
|
||||||
|
"duration_seconds=EXTRACT(EPOCH FROM (%s::timestamptz - login_at::timestamptz))::int "
|
||||||
|
"WHERE id=%s AND logout_at IS NULL",
|
||||||
|
(logout_at, logout_at, session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def active_sessions(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE user_id=%s AND logout_at IS NULL "
|
||||||
|
"ORDER BY login_at DESC",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def destroy_session(self, session_id: str) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("DELETE FROM sessions WHERE id=%s", (session_id,))
|
||||||
|
|
||||||
|
def extend_session(self, session_id: str, *, expires_in_seconds: int = 3600 * 8) -> dict[str, Any] | None:
|
||||||
|
# 兼容旧调用,仅更新 login_at 之后延长的含义在此简化为 no-op 返回现有记录。
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM sessions WHERE id=%s", (session_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"login_at": row["login_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_session_user(self, session_id: str, user_id: str) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("UPDATE sessions SET user_id=%s WHERE id=%s", (user_id, session_id))
|
||||||
|
|
||||||
|
def login_duration_rank(self, limit: int = 8, days: int = 30) -> list[dict[str, Any]]:
|
||||||
|
"""登录时长排行:按用户聚合近 N 天的会话时长(小时)。
|
||||||
|
|
||||||
|
sessions 表列:login_at(TEXT), logout_at(TEXT), duration_seconds(INT)。
|
||||||
|
优先用 duration_seconds;为空时回退计算 now-login_at(未登出)或 logout_at-login_at。
|
||||||
|
"""
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT s.user_id, s.login_at, s.logout_at, s.duration_seconds, "
|
||||||
|
"u.username, u.display_name, u.role "
|
||||||
|
"FROM sessions s LEFT JOIN users u ON s.user_id = u.id "
|
||||||
|
"WHERE s.login_at::timestamptz >= NOW() - make_interval(days => %s)",
|
||||||
|
(days,),
|
||||||
|
).fetchall()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
agg: dict[str, dict[str, Any]] = {}
|
||||||
|
for r in rows:
|
||||||
|
uid = r["user_id"] or ""
|
||||||
|
bucket = agg.setdefault(
|
||||||
|
uid,
|
||||||
|
{
|
||||||
|
"user": r["display_name"] or r["username"] or uid,
|
||||||
|
"role": r["role"] or "",
|
||||||
|
"total": 0.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dur = r["duration_seconds"]
|
||||||
|
if dur is not None:
|
||||||
|
bucket["total"] += float(dur)
|
||||||
|
continue
|
||||||
|
start = parse_time(r["login_at"])
|
||||||
|
end = parse_time(r["logout_at"]) if r["logout_at"] else None
|
||||||
|
if start and end:
|
||||||
|
bucket["total"] += max(0, (end - start).total_seconds())
|
||||||
|
elif start:
|
||||||
|
bucket["total"] += max(0, (now - start).total_seconds())
|
||||||
|
result = [
|
||||||
|
{"user": b["user"], "role": b["role"], "duration": round(b["total"] / 3600, 1)}
|
||||||
|
for b in agg.values()
|
||||||
|
]
|
||||||
|
result.sort(key=lambda x: x["duration"], reverse=True)
|
||||||
|
return result[:limit]
|
||||||
|
|
||||||
|
# ===================== 平台治理:审批 =====================
|
||||||
|
|
||||||
|
def create_approval_template(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
tid = new_id("tpl")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO approval_templates (id, name, steps, create_time) VALUES (?, ?, ?, ?)",
|
||||||
|
(tid, payload["name"], json_dumps(payload.get("steps", [])), utcnow()),
|
||||||
|
)
|
||||||
|
return self.approval_template(tid)
|
||||||
|
|
||||||
|
def approval_templates(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM approval_templates ORDER BY create_time DESC").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def approval_template(self, template_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM approval_templates WHERE id=?", (template_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(template_id)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def update_approval_template(self, template_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
fields = {k: v for k, v in payload.items() if k in ("name", "steps")}
|
||||||
|
if "steps" in fields:
|
||||||
|
fields["steps"] = json_dumps(fields["steps"])
|
||||||
|
if not fields:
|
||||||
|
return self.approval_template(template_id)
|
||||||
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
params = list(fields.values()) + [template_id]
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(f"UPDATE approval_templates SET {set_clause} WHERE id=?", tuple(params))
|
||||||
|
return self.approval_template(template_id)
|
||||||
|
|
||||||
|
def delete_approval_template(self, template_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM approval_templates WHERE id=?", (template_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(template_id)
|
||||||
|
conn.execute("DELETE FROM approval_templates WHERE id=?", (template_id,))
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def create_approval_instance(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
template_id = payload.get("template_id")
|
||||||
|
steps = []
|
||||||
|
if template_id:
|
||||||
|
tpl = self.approval_template(template_id)
|
||||||
|
steps = json_loads(tpl["steps"]) if tpl.get("steps") else []
|
||||||
|
with self.connect() as conn:
|
||||||
|
iid = new_id("appr")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO approval_instances
|
||||||
|
(id, template_id, resource_type, resource_id, applicant_id, status, current_step, create_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
iid,
|
||||||
|
template_id,
|
||||||
|
payload["resource_type"],
|
||||||
|
payload["resource_id"],
|
||||||
|
payload["applicant_id"],
|
||||||
|
"pending",
|
||||||
|
0,
|
||||||
|
utcnow(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for idx, step in enumerate(steps):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO approval_steps (id, instance_id, step_index, approver_id, status, time) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(new_id("step"), iid, idx, step.get("approver_id"), "pending", None),
|
||||||
|
)
|
||||||
|
return self.approval_instance(iid)
|
||||||
|
|
||||||
|
def approval_instances(self, *, status: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
if status:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM approval_instances WHERE status=? ORDER BY create_time DESC", (status,)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute("SELECT * FROM approval_instances ORDER BY create_time DESC").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def approval_instance(self, instance_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM approval_instances WHERE id=?", (instance_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(instance_id)
|
||||||
|
steps = conn.execute(
|
||||||
|
"SELECT * FROM approval_steps WHERE instance_id=? ORDER BY step_index", (instance_id,)
|
||||||
|
).fetchall()
|
||||||
|
result = dict(row)
|
||||||
|
result["steps"] = [dict(s) for s in steps]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def decide_approval_step(self, instance_id: str, step_index: int, *, approver_id: str, approved: bool, comment: str | None = None) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
inst = conn.execute("SELECT * FROM approval_instances WHERE id=?", (instance_id,)).fetchone()
|
||||||
|
if not inst:
|
||||||
|
raise KeyError(instance_id)
|
||||||
|
if inst["status"] != "pending":
|
||||||
|
raise ValueError("instance not pending")
|
||||||
|
step = conn.execute(
|
||||||
|
"SELECT * FROM approval_steps WHERE instance_id=? AND step_index=?",
|
||||||
|
(instance_id, step_index),
|
||||||
|
).fetchone()
|
||||||
|
if not step:
|
||||||
|
raise KeyError("step not found")
|
||||||
|
if step["status"] != "pending":
|
||||||
|
raise ValueError("step already decided")
|
||||||
|
new_status = "approved" if approved else "rejected"
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE approval_steps SET status=?, comment=?, time=? WHERE id=?",
|
||||||
|
(new_status, comment, utcnow(), step["id"]),
|
||||||
|
)
|
||||||
|
if approved:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE approval_instances SET current_step=? WHERE id=?",
|
||||||
|
(step_index + 1, instance_id),
|
||||||
|
)
|
||||||
|
step_rows = conn.execute(
|
||||||
|
"SELECT * FROM approval_steps WHERE instance_id=? ORDER BY step_index", (instance_id,)
|
||||||
|
).fetchall()
|
||||||
|
if all(s["status"] == "approved" for s in step_rows):
|
||||||
|
conn.execute("UPDATE approval_instances SET status='approved' WHERE id=?", (instance_id,))
|
||||||
|
else:
|
||||||
|
conn.execute("UPDATE approval_instances SET status='rejected' WHERE id=?", (instance_id,))
|
||||||
|
return self.approval_instance(instance_id)
|
||||||
|
|
||||||
|
# ===================== 平台治理:租户 =====================
|
||||||
|
|
||||||
|
def tenants(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM tenants ORDER BY create_time DESC").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def tenant(self, tenant_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM tenants WHERE id=?", (tenant_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(tenant_id)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def create_tenant(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
tid = new_id("tnt")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenants (id, name, code, status, owner_user_id, quota, retention_policy_id, create_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tid,
|
||||||
|
payload["name"],
|
||||||
|
payload.get("code"),
|
||||||
|
"active",
|
||||||
|
payload.get("owner_user_id"),
|
||||||
|
json_dumps(payload.get("quota", {})),
|
||||||
|
payload.get("retention_policy_id"),
|
||||||
|
utcnow(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.tenant(tid)
|
||||||
|
|
||||||
|
def update_tenant(self, tenant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
fields = {k: v for k, v in payload.items() if k in ("name", "code", "status", "owner_user_id", "quota", "retention_policy_id")}
|
||||||
|
if "quota" in fields:
|
||||||
|
fields["quota"] = json_dumps(fields["quota"])
|
||||||
|
if not fields:
|
||||||
|
return self.tenant(tenant_id)
|
||||||
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
params = list(fields.values()) + [tenant_id]
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(f"UPDATE tenants SET {set_clause} WHERE id=?", tuple(params))
|
||||||
|
return self.tenant(tenant_id)
|
||||||
|
|
||||||
|
def set_tenant_quota(self, tenant_id: str, quota: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("UPDATE tenants SET quota=? WHERE id=?", (json_dumps(quota), tenant_id))
|
||||||
|
return self.tenant(tenant_id)
|
||||||
|
|
||||||
|
def set_tenant_retention(self, tenant_id: str, retention_policy_id: str | None) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("UPDATE tenants SET retention_policy_id=? WHERE id=?", (retention_policy_id, tenant_id))
|
||||||
|
return self.tenant(tenant_id)
|
||||||
|
|
||||||
|
def delete_tenant(self, tenant_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM tenants WHERE id=?", (tenant_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(tenant_id)
|
||||||
|
conn.execute("DELETE FROM tenants WHERE id=?", (tenant_id,))
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def get_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM acls WHERE resource_type=? AND resource_id=?",
|
||||||
|
(resource_type, resource_id),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def set_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM acls WHERE resource_type=? AND resource_id=?",
|
||||||
|
(resource_type, resource_id),
|
||||||
|
)
|
||||||
|
for e in entries:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO acls (id, resource_type, resource_id, principal_type, principal_id, permission, create_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
new_id("acl"),
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
e.get("principal_type"),
|
||||||
|
e.get("principal_id"),
|
||||||
|
e.get("permission"),
|
||||||
|
utcnow(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM acls WHERE resource_type=? AND resource_id=?",
|
||||||
|
(resource_type, resource_id),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# ===================== 平台治理:项目空间 =====================
|
||||||
|
|
||||||
|
def projects(self, *, tenant_id: str = "default", status: str | None = None, keyword: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
clauses = ["tenant_id=?"]
|
||||||
|
params: list[Any] = [tenant_id]
|
||||||
|
if status:
|
||||||
|
clauses.append("status=?")
|
||||||
|
params.append(status)
|
||||||
|
if keyword:
|
||||||
|
clauses.append("(name LIKE ? OR code LIKE ?)")
|
||||||
|
params.extend([f"%{keyword}%", f"%{keyword}%"])
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM projects WHERE {' AND '.join(clauses)} ORDER BY create_time DESC",
|
||||||
|
tuple(params),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def project(self, project_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(project_id)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def create_project(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
pid = new_id("prj")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO projects (id, tenant_id, name, code, description, quota, status, create_time, create_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
pid,
|
||||||
|
payload.get("tenant_id", "default"),
|
||||||
|
payload["name"],
|
||||||
|
payload["code"],
|
||||||
|
payload.get("description"),
|
||||||
|
json_dumps(payload.get("quota", {})),
|
||||||
|
"active",
|
||||||
|
utcnow(),
|
||||||
|
payload.get("create_by"),
|
||||||
|
utcnow(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.project(pid)
|
||||||
|
|
||||||
|
def update_project(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
fields = {k: v for k, v in payload.items() if k in ("name", "code", "description", "quota", "status")}
|
||||||
|
if "quota" in fields:
|
||||||
|
fields["quota"] = json_dumps(fields["quota"])
|
||||||
|
if not fields:
|
||||||
|
return self.project(project_id)
|
||||||
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
params = list(fields.values()) + [project_id]
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(f"UPDATE projects SET {set_clause} WHERE id=?", tuple(params))
|
||||||
|
return self.project(project_id)
|
||||||
|
|
||||||
|
def archive_project(self, project_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("UPDATE projects SET status='archived' WHERE id=?", (project_id,))
|
||||||
|
return self.project(project_id)
|
||||||
|
|
||||||
|
def activate_project(self, project_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("UPDATE projects SET status='active' WHERE id=?", (project_id,))
|
||||||
|
return self.project(project_id)
|
||||||
|
|
||||||
|
def delete_project(self, project_id: str) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("DELETE FROM projects WHERE id=?", (project_id,))
|
||||||
|
|
||||||
|
def project_members(self, project_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(project_id)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT pm.*, u.username, u.display_name
|
||||||
|
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||||
|
WHERE pm.project_id=?
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def add_project_member(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
user_id = payload["user_id"]
|
||||||
|
role = payload.get("role", "member")
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_members (project_id, user_id, role, create_time) VALUES (?, ?, ?, ?)",
|
||||||
|
(project_id, user_id, role, utcnow()),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT pm.*, u.username, u.display_name
|
||||||
|
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||||
|
WHERE pm.project_id=? AND pm.user_id=?
|
||||||
|
""",
|
||||||
|
(project_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"project_id": row["project_id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"display_name": row["display_name"],
|
||||||
|
"role": row["role"],
|
||||||
|
"create_time": row["create_time"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_project_member_role(self, project_id: str, user_id: str, role: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE project_members SET role=? WHERE project_id=? AND user_id=?",
|
||||||
|
(role, project_id, user_id),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT pm.*, u.username, u.display_name
|
||||||
|
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||||
|
WHERE pm.project_id=? AND pm.user_id=?
|
||||||
|
""",
|
||||||
|
(project_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(user_id)
|
||||||
|
return {
|
||||||
|
"project_id": row["project_id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"display_name": row["display_name"],
|
||||||
|
"role": row["role"],
|
||||||
|
"create_time": row["create_time"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def remove_project_member(self, project_id: str, user_id: str) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM project_members WHERE project_id=? AND user_id=?",
|
||||||
|
(project_id, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===================== 平台治理:资源 ACL =====================
|
||||||
|
|
||||||
|
def resource_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""返回资源 ACL,按主体分组,permissions 为数组。"""
|
||||||
|
rows = self.get_acl(resource_type, resource_id)
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
for r in rows:
|
||||||
|
key = f"{r.get('principal_type')}:{r.get('principal_id')}"
|
||||||
|
bucket = grouped.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"subject_type": r.get("principal_type"),
|
||||||
|
"subject_id": r.get("principal_id"),
|
||||||
|
"permissions": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
perm = r.get("permission")
|
||||||
|
if perm and perm not in bucket["permissions"]:
|
||||||
|
bucket["permissions"].append(perm)
|
||||||
|
return list(grouped.values())
|
||||||
|
|
||||||
|
def set_resource_acl(
|
||||||
|
self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""按前端格式设置资源 ACL:entries 为 [{subject_type, subject_id, permissions: []}]。"""
|
||||||
|
flat: list[dict[str, Any]] = []
|
||||||
|
for e in entries:
|
||||||
|
for perm in e.get("permissions") or []:
|
||||||
|
flat.append(
|
||||||
|
{
|
||||||
|
"principal_type": e.get("subject_type"),
|
||||||
|
"principal_id": e.get("subject_id"),
|
||||||
|
"permission": perm,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.set_acl(resource_type, resource_id, flat)
|
||||||
|
return self.resource_acl(resource_type, resource_id)
|
||||||
|
|
||||||
|
# ===================== 平台治理:留存策略 =====================
|
||||||
|
|
||||||
|
def retention_policies(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM retention_policies ORDER BY create_time DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def retention_policy(self, policy_id: str) -> dict[str, Any]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM retention_policies WHERE id=?", (policy_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(policy_id)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def create_retention_policy(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
pid = payload.get("id") or new_id("rpol")
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO retention_policies
|
||||||
|
(id, name, scope, rule, status, create_time, create_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
pid,
|
||||||
|
payload["name"],
|
||||||
|
payload.get("scope"),
|
||||||
|
payload.get("rule"),
|
||||||
|
payload.get("status", "active"),
|
||||||
|
utcnow(),
|
||||||
|
payload.get("create_by"),
|
||||||
|
utcnow(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.retention_policy(pid)
|
||||||
|
|
||||||
|
def update_retention_policy(
|
||||||
|
self, policy_id: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
fields = {
|
||||||
|
k: v
|
||||||
|
for k, v in payload.items()
|
||||||
|
if k in ("name", "scope", "rule", "status")
|
||||||
|
}
|
||||||
|
if not fields:
|
||||||
|
return self.retention_policy(policy_id)
|
||||||
|
fields["updated_at"] = utcnow()
|
||||||
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
params = list(fields.values()) + [policy_id]
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE retention_policies SET {set_clause} WHERE id=?",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
return self.retention_policy(policy_id)
|
||||||
|
|
||||||
|
def delete_retention_policy(self, policy_id: str) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM retention_policies WHERE id=?", (policy_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_store: PlatformStore | None = None
|
_store: PlatformStore | None = None
|
||||||
|
|
||||||
@@ -2900,3 +3617,16 @@ def get_platform_store() -> PlatformStore:
|
|||||||
if _store is None:
|
if _store is None:
|
||||||
_store = PlatformStore()
|
_store = PlatformStore()
|
||||||
return _store
|
return _store
|
||||||
|
|
||||||
|
|
||||||
|
import atexit as _atexit
|
||||||
|
|
||||||
|
|
||||||
|
def _close_store_pool() -> None:
|
||||||
|
global _store
|
||||||
|
if _store is not None:
|
||||||
|
_store.close_pool()
|
||||||
|
_store = None
|
||||||
|
|
||||||
|
|
||||||
|
_atexit.register(_close_store_pool)
|
||||||
|
|||||||
@@ -282,3 +282,51 @@ CREATE INDEX IF NOT EXISTS idx_sync_jobs_node_status ON resource_sync_jobs(targe
|
|||||||
CREATE INDEX IF NOT EXISTS idx_eval_tasks_status ON eval_tasks(status);
|
CREATE INDEX IF NOT EXISTS idx_eval_tasks_status ON eval_tasks(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_eval_dimensions_active ON eval_dimensions(is_active);
|
CREATE INDEX IF NOT EXISTS idx_eval_dimensions_active ON eval_dimensions(is_active);
|
||||||
CREATE INDEX IF NOT EXISTS idx_compare_tasks_status ON compare_tasks(status);
|
CREATE INDEX IF NOT EXISTS idx_compare_tasks_status ON compare_tasks(status);
|
||||||
|
|
||||||
|
-- ===================== Project / Tenant =====================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
quota TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
create_by TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_members (
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
permissions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
create_time TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
issued_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
ip TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acls (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
principal_type TEXT NOT NULL,
|
||||||
|
principal_id TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
create_time TEXT
|
||||||
|
);
|
||||||
|
|||||||
68
backend/app/db/sql/002_governance.sql
Normal file
68
backend/app/db/sql/002_governance.sql
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
-- 平台治理:租户 / 审批 / 审计(字段以 platform_store 实际写入为准)
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
owner_user_id TEXT,
|
||||||
|
quota TEXT,
|
||||||
|
retention_policy_id TEXT,
|
||||||
|
create_time TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
steps TEXT,
|
||||||
|
create_time TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_instances (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
applicant_id TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
current_step INTEGER DEFAULT 0,
|
||||||
|
create_time TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_steps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
instance_id TEXT,
|
||||||
|
step_index INTEGER,
|
||||||
|
approver_id TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
comment TEXT,
|
||||||
|
time TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
actor_id TEXT,
|
||||||
|
action TEXT,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
client_ip TEXT,
|
||||||
|
time TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON audit_logs(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_project ON audit_logs(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_logs(time);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS retention_policies (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scope TEXT,
|
||||||
|
rule TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
create_time TEXT,
|
||||||
|
create_by TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
3
backend/app/db/sql/003_tenant_quota.sql
Normal file
3
backend/app/db/sql/003_tenant_quota.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 租户配额与保留策略扩展(如后续治理表需补列,可在此追加)
|
||||||
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS gpu_quota TEXT;
|
||||||
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS storage_quota TEXT;
|
||||||
91
backend/app/modules/approval/router.py
Normal file
91
backend/app/modules/approval/router.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.platform import ok, fail
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/approvals", tags=["approval"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
def list_templates() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().approval_templates())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates")
|
||||||
|
def create_template(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
if not payload.get("name"):
|
||||||
|
raise fail(400, "name 必填")
|
||||||
|
return ok(get_platform_store().create_approval_template(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates/{template_id}")
|
||||||
|
def get_template(template_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().approval_template(template_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "template not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/templates/{template_id}")
|
||||||
|
def update_template(template_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().update_approval_template(template_id, payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "template not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/templates/{template_id}")
|
||||||
|
def delete_template(template_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().delete_approval_template(template_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "template not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_instances(status: str | None = None) -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().approval_instances(status=status))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_instance(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||||
|
for field in ("resource_type", "resource_id", "applicant_id"):
|
||||||
|
if not payload.get(field):
|
||||||
|
raise fail(400, f"{field} 必填")
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().create_approval_instance(payload))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "template not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instance_id}")
|
||||||
|
def get_instance(instance_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().approval_instance(instance_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "instance not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instance_id}/steps/{step_index}/decision")
|
||||||
|
def decide(
|
||||||
|
instance_id: str,
|
||||||
|
step_index: int,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not payload.get("approver_id"):
|
||||||
|
raise fail(400, "approver_id 必填")
|
||||||
|
try:
|
||||||
|
return ok(
|
||||||
|
get_platform_store().decide_approval_step(
|
||||||
|
instance_id,
|
||||||
|
step_index,
|
||||||
|
approver_id=payload["approver_id"],
|
||||||
|
approved=bool(payload.get("approved", False)),
|
||||||
|
comment=payload.get("comment"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
raise fail(400, str(e))
|
||||||
244
backend/app/modules/project/router.py
Normal file
244
backend/app/modules/project/router.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, Request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.platform import ok, fail
|
||||||
|
from app.core.auth import filter_accessible_resource_ids, get_current_user, has_resource_access, is_admin
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects", tags=["project"])
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(request: Request) -> str | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
token = auth.replace("Bearer ", "").strip()
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_no_pending_approval(resource_type: str, resource_id: str) -> None:
|
||||||
|
"""第 4 周:写操作审批拦截——存在待审批实例时拒绝执行。"""
|
||||||
|
store = get_platform_store()
|
||||||
|
pending = [
|
||||||
|
i for i in store.approval_instances(status="pending")
|
||||||
|
if i["resource_type"] == resource_type and i["resource_id"] == resource_id
|
||||||
|
]
|
||||||
|
if pending:
|
||||||
|
raise fail(409, "存在待审批的变更,请先完成审批")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_approval_or_admin(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
current_user: dict[str, Any],
|
||||||
|
action_desc: str = "",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""高风险操作审批旁路:admin 直接放行,普通用户创建审批实例(code=202)。"""
|
||||||
|
if is_admin(current_user):
|
||||||
|
return None
|
||||||
|
store = get_platform_store()
|
||||||
|
instance = store.create_approval_instance({
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"applicant_id": current_user.get("id"),
|
||||||
|
"template_id": None,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"code": 202,
|
||||||
|
"message": f"操作已提交审批,等待管理员批准:{action_desc}",
|
||||||
|
"data": {"approval_required": True, "approval_id": instance["id"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_projects(
|
||||||
|
tenant_id: str = "default",
|
||||||
|
status: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
projects = store.projects(tenant_id=tenant_id, status=status, keyword=keyword)
|
||||||
|
# #1 ACL 过滤:admin 直接放行,普通用户只能看到自己被授权的项目
|
||||||
|
accessible_ids = set(
|
||||||
|
filter_accessible_resource_ids("project", [p["id"] for p in projects], current_user)
|
||||||
|
)
|
||||||
|
filtered = [p for p in projects if p["id"] in accessible_ids]
|
||||||
|
return ok(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_project(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
proj = store.create_project(payload)
|
||||||
|
store.record_audit(
|
||||||
|
action="project.create",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="project",
|
||||||
|
target_id=proj["id"],
|
||||||
|
tenant_id=proj.get("tenant_id"),
|
||||||
|
detail=f"name={proj.get('name')}",
|
||||||
|
)
|
||||||
|
return ok(proj)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}")
|
||||||
|
def get_project(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
# #2 访问控制:普通用户无 read 权限则拒绝
|
||||||
|
if not has_resource_access("project", project_id, current_user, "read"):
|
||||||
|
raise fail(403, "no permission to access this project")
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().project(project_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "project not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}")
|
||||||
|
def update_project(
|
||||||
|
project_id: str,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
request: Request = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("project", project_id, current_user, "write"):
|
||||||
|
raise fail(403, "no permission to update this project")
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
proj = store.update_project(project_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "project not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="project.update",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="project",
|
||||||
|
target_id=project_id,
|
||||||
|
tenant_id=proj.get("tenant_id"),
|
||||||
|
detail=f"fields={','.join(payload.keys())}",
|
||||||
|
)
|
||||||
|
return ok(proj)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/archive")
|
||||||
|
def archive_project(
|
||||||
|
project_id: str,
|
||||||
|
request: Request = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require_no_pending_approval("project", project_id)
|
||||||
|
pending = _require_approval_or_admin("project", project_id, current_user, f"归档项目 {project_id}")
|
||||||
|
if pending:
|
||||||
|
return pending
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
proj = store.archive_project(project_id)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "project not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="project.archive",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="project",
|
||||||
|
target_id=project_id,
|
||||||
|
tenant_id=proj.get("tenant_id"),
|
||||||
|
)
|
||||||
|
return ok(proj)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}")
|
||||||
|
def delete_project(
|
||||||
|
project_id: str,
|
||||||
|
request: Request = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require_no_pending_approval("project", project_id)
|
||||||
|
pending = _require_approval_or_admin("project", project_id, current_user, f"删除项目 {project_id}")
|
||||||
|
if pending:
|
||||||
|
return pending
|
||||||
|
store = get_platform_store()
|
||||||
|
store.delete_project(project_id)
|
||||||
|
store.record_audit(
|
||||||
|
action="project.delete",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="project",
|
||||||
|
target_id=project_id,
|
||||||
|
)
|
||||||
|
return ok(None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/members")
|
||||||
|
def list_members(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("project", project_id, current_user, "read"):
|
||||||
|
raise fail(403, "no permission to access this project")
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().project_members(project_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "project not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/members")
|
||||||
|
def add_member(
|
||||||
|
project_id: str,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
request: Request = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("project", project_id, current_user, "write"):
|
||||||
|
raise fail(403, "no permission to manage members of this project")
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
member = store.add_project_member(project_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "project not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="project.member.add",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="project.member",
|
||||||
|
target_id=project_id,
|
||||||
|
detail=f"user_id={payload.get('user_id')},role={payload.get('role')}",
|
||||||
|
)
|
||||||
|
return ok(member)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/members/{user_id}")
|
||||||
|
def update_member(
|
||||||
|
project_id: str,
|
||||||
|
user_id: str,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
request: Request = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("project", project_id, current_user, "write"):
|
||||||
|
raise fail(403, "no permission to manage members of this project")
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
member = store.update_project_member_role(project_id, user_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "project or member not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="project.member.update",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="project.member",
|
||||||
|
target_id=project_id,
|
||||||
|
detail=f"user_id={user_id},role={payload.get('role')}",
|
||||||
|
)
|
||||||
|
return ok(member)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/members/{user_id}")
|
||||||
|
def remove_member(
|
||||||
|
project_id: str,
|
||||||
|
user_id: str,
|
||||||
|
request: Request = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not has_resource_access("project", project_id, current_user, "write"):
|
||||||
|
raise fail(403, "no permission to manage members of this project")
|
||||||
|
store = get_platform_store()
|
||||||
|
store.remove_project_member(project_id, user_id)
|
||||||
|
store.record_audit(
|
||||||
|
action="project.member.remove",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="project.member",
|
||||||
|
target_id=project_id,
|
||||||
|
detail=f"user_id={user_id}",
|
||||||
|
)
|
||||||
|
return ok(None)
|
||||||
1
backend/app/modules/resource/__init__.py
Normal file
1
backend/app/modules/resource/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Resource access control list (ACL) module."""
|
||||||
41
backend/app/modules/resource/router.py
Normal file
41
backend/app/modules/resource/router.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.platform import ok, fail
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/resources", tags=["resource"])
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(request: Request) -> str | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
token = auth.replace("Bearer ", "").strip()
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{resource_type}/{resource_id}/acl")
|
||||||
|
def get_acl(resource_type: str, resource_id: str) -> dict[str, Any]:
|
||||||
|
"""查询资源 ACL,返回按主体分组的权限列表。"""
|
||||||
|
return ok(get_platform_store().resource_acl(resource_type, resource_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{resource_type}/{resource_id}/acl")
|
||||||
|
def set_acl(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
request: Request = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""设置资源 ACL,body: { entries: [{ subject_type, subject_id, permissions: [] }] }"""
|
||||||
|
entries = payload.get("entries") or []
|
||||||
|
result = get_platform_store().set_resource_acl(resource_type, resource_id, entries)
|
||||||
|
get_platform_store().record_audit(
|
||||||
|
action="resource.acl.set",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type=resource_type,
|
||||||
|
target_id=resource_id,
|
||||||
|
detail=f"entries={len(entries)}",
|
||||||
|
)
|
||||||
|
return ok(result)
|
||||||
75
backend/app/modules/retention/router.py
Normal file
75
backend/app/modules/retention/router.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.platform import ok, fail
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/retention-policies", tags=["retention"])
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(request: Request) -> str | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
token = auth.replace("Bearer ", "").strip()
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_policies() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().retention_policies())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_policy(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||||
|
if not payload.get("name"):
|
||||||
|
raise fail(400, "name 必填")
|
||||||
|
policy = get_platform_store().create_retention_policy(payload)
|
||||||
|
get_platform_store().record_audit(
|
||||||
|
action="retention.create",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="retention_policy",
|
||||||
|
target_id=policy["id"],
|
||||||
|
detail=f"name={policy.get('name')}",
|
||||||
|
)
|
||||||
|
return ok(policy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{policy_id}")
|
||||||
|
def get_policy(policy_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().retention_policy(policy_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "retention policy not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{policy_id}")
|
||||||
|
def update_policy(
|
||||||
|
policy_id: str, payload: dict[str, Any] = Body(...), request: Request = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
policy = store.update_retention_policy(policy_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "retention policy not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="retention.update",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="retention_policy",
|
||||||
|
target_id=policy_id,
|
||||||
|
detail=f"fields={','.join(payload.keys())}",
|
||||||
|
)
|
||||||
|
return ok(policy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{policy_id}")
|
||||||
|
def delete_policy(policy_id: str, request: Request = None) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
store.delete_retention_policy(policy_id)
|
||||||
|
store.record_audit(
|
||||||
|
action="retention.delete",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="retention_policy",
|
||||||
|
target_id=policy_id,
|
||||||
|
)
|
||||||
|
return ok({"deleted": policy_id})
|
||||||
93
backend/app/modules/system/router.py
Normal file
93
backend/app/modules/system/router.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.db.platform_store import ALL_PERMISSIONS, get_platform_store
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/system", tags=["system"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permissions/codes")
|
||||||
|
def permission_codes() -> dict:
|
||||||
|
"""返回平台权限码清单(权限码接口)。"""
|
||||||
|
return {"code": 0, "message": "ok", "data": {"codes": ALL_PERMISSIONS}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permissions")
|
||||||
|
def permissions_overview() -> dict:
|
||||||
|
"""返回权限码清单与角色定义。"""
|
||||||
|
store = get_platform_store()
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {"codes": ALL_PERMISSIONS, "roles": store.roles()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-logs")
|
||||||
|
def audit_logs(
|
||||||
|
tenant_id: str | None = Query(default=None, description="租户 ID"),
|
||||||
|
project_id: str | None = Query(default=None, description="项目 ID"),
|
||||||
|
actor_id: str | None = Query(default=None, description="操作人 ID"),
|
||||||
|
action: str | None = Query(default=None, description="动作类型"),
|
||||||
|
target_type: str | None = Query(default=None, description="目标类型"),
|
||||||
|
start_time: str | None = Query(default=None, description="ISO8601 起始时间"),
|
||||||
|
end_time: str | None = Query(default=None, description="ISO8601 结束时间"),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> dict:
|
||||||
|
"""审计日志查询:按租户/项目/操作人/动作/目标类型/时间范围分页过滤。"""
|
||||||
|
store = get_platform_store()
|
||||||
|
result = store.audit_logs(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
actor_id=actor_id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return {"code": 0, "message": "ok", "data": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-logs/export")
|
||||||
|
def audit_logs_export(
|
||||||
|
tenant_id: str | None = Query(default=None, description="租户 ID"),
|
||||||
|
project_id: str | None = Query(default=None, description="项目 ID"),
|
||||||
|
actor_id: str | None = Query(default=None, description="操作人 ID"),
|
||||||
|
action: str | None = Query(default=None, description="动作类型"),
|
||||||
|
target_type: str | None = Query(default=None, description="目标类型"),
|
||||||
|
start_time: str | None = Query(default=None, description="ISO8601 起始时间"),
|
||||||
|
end_time: str | None = Query(default=None, description="ISO8601 结束时间"),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""审计日志导出:返回 CSV 流,与应用查询相同的过滤条件。"""
|
||||||
|
store = get_platform_store()
|
||||||
|
result = store.audit_logs(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
actor_id=actor_id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=10000,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
items = result["items"]
|
||||||
|
columns = ["time", "tenant_id", "project_id", "actor_id", "action", "target_type", "target_id", "detail", "client_ip"]
|
||||||
|
header = ",".join(columns) + "\n"
|
||||||
|
|
||||||
|
def iter_rows():
|
||||||
|
yield header
|
||||||
|
for row in items:
|
||||||
|
yield ",".join(f'"{str(row.get(c, "") or "")}"' for c in columns) + "\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter_rows(),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=audit_logs.csv"},
|
||||||
|
)
|
||||||
116
backend/app/modules/tenant/router.py
Normal file
116
backend/app/modules/tenant/router.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.platform import ok, fail
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tenants", tags=["tenant"])
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(request: Request) -> str | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
token = auth.replace("Bearer ", "").strip()
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_tenants() -> dict[str, Any]:
|
||||||
|
return ok(get_platform_store().tenants())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_tenant(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
tenant = store.create_tenant(payload)
|
||||||
|
except KeyError as e:
|
||||||
|
raise fail(400, f"missing field: {e}")
|
||||||
|
store.record_audit(
|
||||||
|
action="tenant.create",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="tenant",
|
||||||
|
target_id=tenant["id"],
|
||||||
|
tenant_id=tenant["id"],
|
||||||
|
detail=f"name={tenant.get('name')}",
|
||||||
|
)
|
||||||
|
return ok(tenant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}")
|
||||||
|
def get_tenant(tenant_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ok(get_platform_store().tenant(tenant_id))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "tenant not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}")
|
||||||
|
def update_tenant(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
tenant = store.update_tenant(tenant_id, payload)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "tenant not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="tenant.update",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="tenant",
|
||||||
|
target_id=tenant_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
detail=f"fields={','.join(payload.keys())}",
|
||||||
|
)
|
||||||
|
return ok(tenant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}/quota")
|
||||||
|
def set_quota(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
tenant = store.set_tenant_quota(tenant_id, payload.get("quota", {}))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "tenant not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="tenant.quota.set",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="tenant",
|
||||||
|
target_id=tenant_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
return ok(tenant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}/retention-policy")
|
||||||
|
def set_retention(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
tenant = store.set_tenant_retention(tenant_id, payload.get("retention_policy_id"))
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "tenant not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="tenant.retention.set",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="tenant",
|
||||||
|
target_id=tenant_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
return ok(tenant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tenant_id}")
|
||||||
|
def delete_tenant(tenant_id: str, request: Request = None) -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
try:
|
||||||
|
tenant = store.delete_tenant(tenant_id)
|
||||||
|
except KeyError:
|
||||||
|
raise fail(404, "tenant not found")
|
||||||
|
store.record_audit(
|
||||||
|
action="tenant.delete",
|
||||||
|
actor_id=_actor(request) if request else None,
|
||||||
|
target_type="tenant",
|
||||||
|
target_id=tenant_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
detail=f"name={tenant.get('name')}",
|
||||||
|
)
|
||||||
|
return ok(tenant)
|
||||||
774
backend/tests/test_governance.py
Normal file
774
backend/tests/test_governance.py
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
"""
|
||||||
|
平台治理功能集成测试 —— 覆盖第 1-4 周交付内容。
|
||||||
|
|
||||||
|
测试策略:
|
||||||
|
- 在导入 app 模块前 mock psycopg / psycopg_pool,避免依赖真实数据库驱动
|
||||||
|
- 使用 FastAPI TestClient 对真实路由栈发起请求
|
||||||
|
- 通过 mock.get_platform_store 替换为内存 FakeStore
|
||||||
|
- 每周交付内容对应一组 test class,方便分阶段验收
|
||||||
|
|
||||||
|
覆盖范围:
|
||||||
|
第 1 周 — 登录、当前用户、用户列表、权限码、日志查询
|
||||||
|
第 2 周 — 租户、项目、项目成员、资源 ACL
|
||||||
|
第 3 周 — 审批实例、审批模板、审计日志查询和导出
|
||||||
|
第 4 周 — 写操作审计、审批拦截、权限校验
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Iterator
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 在导入 app 之前 mock psycopg / psycopg_pool
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
_psycopg_mock = types.ModuleType("psycopg")
|
||||||
|
_psycopg_mock.PgConn = type("PgConn", (), {})
|
||||||
|
_psycopg_mock.PostgresConnectionPool = MagicMock()
|
||||||
|
_psycopg_mock.connection = MagicMock()
|
||||||
|
sys.modules.setdefault("psycopg", _psycopg_mock)
|
||||||
|
|
||||||
|
_psycopg_pool_mock = types.ModuleType("psycopg_pool")
|
||||||
|
_psycopg_pool_mock.ConnectionPool = MagicMock()
|
||||||
|
sys.modules.setdefault("psycopg_pool", _psycopg_pool_mock)
|
||||||
|
|
||||||
|
# 现在安全导入 app 模块
|
||||||
|
from app.api.v1.endpoints.platform import ok, fail # noqa: E402
|
||||||
|
from app.modules.tenant.router import router as tenant_router # noqa: E402
|
||||||
|
from app.modules.project.router import router as project_router # noqa: E402
|
||||||
|
from app.modules.approval.router import router as approval_router # noqa: E402
|
||||||
|
from app.modules.system.router import router as system_router # noqa: E402
|
||||||
|
from app.modules.retention.router import router as retention_router # noqa: E402
|
||||||
|
from app.modules.resource.router import router as resource_router # noqa: E402
|
||||||
|
from app.api.v1.endpoints.platform import router as platform_router # noqa: E402
|
||||||
|
|
||||||
|
PREFIX = "/modelTF"
|
||||||
|
ADMIN_TOKEN = "platform-token-u_admin"
|
||||||
|
OP_TOKEN = "platform-token-u_op"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# FakePlatformStore —— 内存实现,模拟 PlatformStore 全部治理接口
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FakePlatformStore:
|
||||||
|
"""平台治理测试专用内存 store,确保测试不连接真实数据库。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._users: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "u_admin",
|
||||||
|
"username": "admin",
|
||||||
|
"display_name": "Admin",
|
||||||
|
"role": "admin",
|
||||||
|
"status": "active",
|
||||||
|
"permissions": [
|
||||||
|
"dashboard", "fine-tune", "model-eval", "model-inference",
|
||||||
|
"model-manage", "dataset", "data-process", "data-convert",
|
||||||
|
"compute", "hardware", "logs", "user-settings",
|
||||||
|
],
|
||||||
|
"last_login": "2026-08-01T10:00:00Z",
|
||||||
|
"protected": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "u_op",
|
||||||
|
"username": "operator",
|
||||||
|
"display_name": "Operator",
|
||||||
|
"role": "operator",
|
||||||
|
"status": "active",
|
||||||
|
"permissions": ["dashboard", "fine-tune"],
|
||||||
|
"last_login": "2026-08-01T11:00:00Z",
|
||||||
|
"protected": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._tenants: dict[str, dict[str, Any]] = {}
|
||||||
|
self._projects: dict[str, dict[str, Any]] = {}
|
||||||
|
self._members: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
self._acl: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
self._audit_logs: list[dict[str, Any]] = []
|
||||||
|
self._approval_templates: dict[str, dict[str, Any]] = {}
|
||||||
|
self._approval_instances: dict[str, dict[str, Any]] = {}
|
||||||
|
self._retention_policies: dict[str, dict[str, Any]] = {}
|
||||||
|
self._models: list[dict[str, Any]] = []
|
||||||
|
self._datasets: list[dict[str, Any]] = []
|
||||||
|
self._tasks: list[dict[str, Any]] = []
|
||||||
|
self._compute_nodes: list[dict[str, Any]] = []
|
||||||
|
self._gpus: list[dict[str, Any]] = []
|
||||||
|
self._sessions: list[dict[str, Any]] = []
|
||||||
|
self._seq = 0
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect(self) -> Iterator[Any]:
|
||||||
|
class FakeConn:
|
||||||
|
def execute(self, *a, **kw):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield FakeConn()
|
||||||
|
|
||||||
|
# ---- helpers ----
|
||||||
|
|
||||||
|
def _next_id(self, prefix: str) -> str:
|
||||||
|
self._seq += 1
|
||||||
|
return f"{prefix}_{self._seq}"
|
||||||
|
|
||||||
|
# ==================== 第1周:登录 / 用户 / 权限码 / 日志 ====================
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> dict[str, Any] | None:
|
||||||
|
for u in self._users:
|
||||||
|
if u["username"] == username and u["status"] == "active":
|
||||||
|
if password in ("admin123", "operator123", "test123"):
|
||||||
|
return dict(u)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def users(self) -> list[dict[str, Any]]:
|
||||||
|
return [dict(u) for u in self._users]
|
||||||
|
|
||||||
|
def create_user(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
u = {"id": self._next_id("u"), "protected": False, **payload}
|
||||||
|
self._users.append(u)
|
||||||
|
return u
|
||||||
|
|
||||||
|
def update_user(self, user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
for u in self._users:
|
||||||
|
if u["id"] == user_id:
|
||||||
|
u.update(payload)
|
||||||
|
return u
|
||||||
|
raise KeyError(user_id)
|
||||||
|
|
||||||
|
def delete_user(self, user_id: str) -> None:
|
||||||
|
self._users = [u for u in self._users if u["id"] != user_id]
|
||||||
|
|
||||||
|
def roles(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"name": "admin", "display_name": "管理员"},
|
||||||
|
{"name": "operator", "display_name": "操作员"},
|
||||||
|
{"name": "viewer", "display_name": "访客"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def log_files(self, date: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
return [{"name": "backend-2026-08-01.log", "size": "1 KB", "date": "2026-08-01"}]
|
||||||
|
|
||||||
|
def log_content(self, file: str) -> dict[str, Any]:
|
||||||
|
return {"file": file, "content": "[INFO] test line", "size": "1 KB"}
|
||||||
|
|
||||||
|
def training_log_files(self) -> list[dict[str, Any]]:
|
||||||
|
return [{"task_id": "ft_001", "name": "ft_001.log", "size": "2 KB"}]
|
||||||
|
|
||||||
|
def training_log_content(self, file: str) -> dict[str, Any]:
|
||||||
|
return {"file": file, "content": "epoch 0 loss 1.0", "size": "2 KB"}
|
||||||
|
|
||||||
|
# ==================== 第2周:租户 / 项目 / 成员 / ACL ====================
|
||||||
|
|
||||||
|
def tenants(self) -> list[dict[str, Any]]:
|
||||||
|
return list(self._tenants.values())
|
||||||
|
|
||||||
|
def tenant(self, tenant_id: str) -> dict[str, Any]:
|
||||||
|
if tenant_id not in self._tenants:
|
||||||
|
raise KeyError(tenant_id)
|
||||||
|
return dict(self._tenants[tenant_id])
|
||||||
|
|
||||||
|
def create_tenant(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
tid = self._next_id("tnt")
|
||||||
|
t = {"id": tid, "status": "active", "quota": "{}", "retention_policy_id": None,
|
||||||
|
"create_time": "2026-08-01T00:00:00Z", **payload}
|
||||||
|
self._tenants[tid] = t
|
||||||
|
return dict(t)
|
||||||
|
|
||||||
|
def update_tenant(self, tenant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self._tenants[tenant_id].update(payload)
|
||||||
|
return dict(self._tenants[tenant_id])
|
||||||
|
|
||||||
|
def set_tenant_quota(self, tenant_id: str, quota: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self._tenants[tenant_id]["quota"] = json.dumps(quota)
|
||||||
|
return dict(self._tenants[tenant_id])
|
||||||
|
|
||||||
|
def set_tenant_retention(self, tenant_id: str, retention_policy_id: str | None) -> dict[str, Any]:
|
||||||
|
self._tenants[tenant_id]["retention_policy_id"] = retention_policy_id
|
||||||
|
return dict(self._tenants[tenant_id])
|
||||||
|
|
||||||
|
def projects(self, *, tenant_id: str = "default", status: str | None = None, keyword: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
result = []
|
||||||
|
for p in self._projects.values():
|
||||||
|
if p.get("tenant_id") != tenant_id:
|
||||||
|
continue
|
||||||
|
if status and p.get("status") != status:
|
||||||
|
continue
|
||||||
|
if keyword and keyword.lower() not in p.get("name", "").lower():
|
||||||
|
continue
|
||||||
|
result.append(dict(p))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def project(self, project_id: str) -> dict[str, Any]:
|
||||||
|
if project_id not in self._projects:
|
||||||
|
raise KeyError(project_id)
|
||||||
|
return dict(self._projects[project_id])
|
||||||
|
|
||||||
|
def create_project(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
pid = self._next_id("prj")
|
||||||
|
p = {"id": pid, "status": "active", "quota": "{}", "create_time": "2026-08-01T00:00:00Z", **payload}
|
||||||
|
self._projects[pid] = p
|
||||||
|
self._members[pid] = []
|
||||||
|
return dict(p)
|
||||||
|
|
||||||
|
def update_project(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self._projects[project_id].update(payload)
|
||||||
|
return dict(self._projects[project_id])
|
||||||
|
|
||||||
|
def archive_project(self, project_id: str) -> dict[str, Any]:
|
||||||
|
self._projects[project_id]["status"] = "archived"
|
||||||
|
return dict(self._projects[project_id])
|
||||||
|
|
||||||
|
def delete_project(self, project_id: str) -> None:
|
||||||
|
self._projects.pop(project_id, None)
|
||||||
|
self._members.pop(project_id, None)
|
||||||
|
|
||||||
|
def project_members(self, project_id: str) -> list[dict[str, Any]]:
|
||||||
|
return [dict(m) for m in self._members.get(project_id, [])]
|
||||||
|
|
||||||
|
def add_project_member(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
m = {"joined_at": "2026-08-01T00:00:00Z", **payload}
|
||||||
|
self._members.setdefault(project_id, []).append(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
def update_project_member_role(self, project_id: str, user_id: str, role: str) -> dict[str, Any]:
|
||||||
|
for m in self._members.get(project_id, []):
|
||||||
|
if m["user_id"] == user_id:
|
||||||
|
m["role"] = role
|
||||||
|
return m
|
||||||
|
raise KeyError(user_id)
|
||||||
|
|
||||||
|
def remove_project_member(self, project_id: str, user_id: str) -> None:
|
||||||
|
self._members[project_id] = [m for m in self._members.get(project_id, []) if m["user_id"] != user_id]
|
||||||
|
|
||||||
|
# ---- ACL ----
|
||||||
|
|
||||||
|
def get_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
|
||||||
|
key = f"{resource_type}:{resource_id}"
|
||||||
|
return [dict(a) for a in self._acl.get(key, [])]
|
||||||
|
|
||||||
|
def set_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
key = f"{resource_type}:{resource_id}"
|
||||||
|
self._acl[key] = [dict(e) for e in entries]
|
||||||
|
return self.get_acl(resource_type, resource_id)
|
||||||
|
|
||||||
|
def resource_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
|
||||||
|
rows = self.get_acl(resource_type, resource_id)
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
for r in rows:
|
||||||
|
k = f"{r.get('principal_type')}:{r.get('principal_id')}"
|
||||||
|
bucket = grouped.setdefault(k, {
|
||||||
|
"subject_type": r.get("principal_type"),
|
||||||
|
"subject_id": r.get("principal_id"),
|
||||||
|
"permissions": [],
|
||||||
|
})
|
||||||
|
perm = r.get("permission")
|
||||||
|
if perm and perm not in bucket["permissions"]:
|
||||||
|
bucket["permissions"].append(perm)
|
||||||
|
return list(grouped.values())
|
||||||
|
|
||||||
|
def set_resource_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
flat: list[dict[str, Any]] = []
|
||||||
|
for e in entries:
|
||||||
|
for perm in e.get("permissions") or []:
|
||||||
|
flat.append({
|
||||||
|
"principal_type": e.get("subject_type"),
|
||||||
|
"principal_id": e.get("subject_id"),
|
||||||
|
"permission": perm,
|
||||||
|
})
|
||||||
|
self.set_acl(resource_type, resource_id, flat)
|
||||||
|
return self.resource_acl(resource_type, resource_id)
|
||||||
|
|
||||||
|
# ==================== 第3周:审批 / 审计 / 留存 ====================
|
||||||
|
|
||||||
|
def approval_templates(self) -> list[dict[str, Any]]:
|
||||||
|
return list(self._approval_templates.values())
|
||||||
|
|
||||||
|
def create_approval_template(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
tid = payload.get("id") or self._next_id("tpl")
|
||||||
|
t = {"id": tid, "steps": [], "create_time": "2026-08-01T00:00:00Z", **payload}
|
||||||
|
self._approval_templates[tid] = t
|
||||||
|
return dict(t)
|
||||||
|
|
||||||
|
def approval_instances(self, *, status: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
result = []
|
||||||
|
for i in self._approval_instances.values():
|
||||||
|
if status and i.get("status") != status:
|
||||||
|
continue
|
||||||
|
result.append(dict(i))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def approval_instance(self, instance_id: str) -> dict[str, Any]:
|
||||||
|
if instance_id not in self._approval_instances:
|
||||||
|
raise KeyError(instance_id)
|
||||||
|
return dict(self._approval_instances[instance_id])
|
||||||
|
|
||||||
|
def create_approval_instance(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
iid = self._next_id("appr")
|
||||||
|
inst = {
|
||||||
|
"id": iid,
|
||||||
|
"status": "pending",
|
||||||
|
"current_step": 0,
|
||||||
|
"steps": [],
|
||||||
|
"create_time": "2026-08-01T00:00:00Z",
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
self._approval_instances[iid] = inst
|
||||||
|
return dict(inst)
|
||||||
|
|
||||||
|
def decide_approval_step(self, instance_id: str, step_index: int, *, approver_id: str, approved: bool, comment: str | None = None) -> dict[str, Any]:
|
||||||
|
inst = self._approval_instances[instance_id]
|
||||||
|
inst["status"] = "approved" if approved else "rejected"
|
||||||
|
inst["current_step"] = step_index + 1
|
||||||
|
return dict(inst)
|
||||||
|
|
||||||
|
def audit_logs(self, **kw) -> dict[str, Any]:
|
||||||
|
items = [dict(l) for l in self._audit_logs]
|
||||||
|
for filter_key in ("tenant_id", "project_id", "actor_id", "action", "target_type"):
|
||||||
|
val = kw.get(filter_key)
|
||||||
|
if val:
|
||||||
|
items = [l for l in items if l.get(filter_key) == val]
|
||||||
|
limit = kw.get("limit", 50)
|
||||||
|
offset = kw.get("offset", 0)
|
||||||
|
total = len(items)
|
||||||
|
items = items[offset:offset + limit]
|
||||||
|
return {"items": items, "total": total}
|
||||||
|
|
||||||
|
def record_audit(self, **kw) -> None:
|
||||||
|
log = {"id": self._next_id("log"), "time": "2026-08-01T12:00:00Z", **kw}
|
||||||
|
self._audit_logs.append(log)
|
||||||
|
|
||||||
|
# ---- 留存策略 ----
|
||||||
|
|
||||||
|
def retention_policies(self) -> list[dict[str, Any]]:
|
||||||
|
return list(self._retention_policies.values())
|
||||||
|
|
||||||
|
def retention_policy(self, policy_id: str) -> dict[str, Any]:
|
||||||
|
if policy_id not in self._retention_policies:
|
||||||
|
raise KeyError(policy_id)
|
||||||
|
return dict(self._retention_policies[policy_id])
|
||||||
|
|
||||||
|
def create_retention_policy(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
pid = payload.get("id") or self._next_id("rpol")
|
||||||
|
p = {"id": pid, "status": "active", "create_time": "2026-08-01T00:00:00Z", **payload}
|
||||||
|
self._retention_policies[pid] = p
|
||||||
|
return dict(p)
|
||||||
|
|
||||||
|
def update_retention_policy(self, policy_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self._retention_policies[policy_id].update(payload)
|
||||||
|
return dict(self._retention_policies[policy_id])
|
||||||
|
|
||||||
|
def delete_retention_policy(self, policy_id: str) -> None:
|
||||||
|
self._retention_policies.pop(policy_id, None)
|
||||||
|
|
||||||
|
# ---- dashboard & other stubs ----
|
||||||
|
|
||||||
|
def login_duration_rank(self, limit: int = 8, days: int = 30) -> list[dict[str, Any]]:
|
||||||
|
return [{"user": "admin", "role": "admin", "duration": 10.0}]
|
||||||
|
|
||||||
|
def models(self) -> list[dict[str, Any]]:
|
||||||
|
return self._models
|
||||||
|
|
||||||
|
def datasets(self) -> list[dict[str, Any]]:
|
||||||
|
return self._datasets
|
||||||
|
|
||||||
|
def tasks(self) -> list[dict[str, Any]]:
|
||||||
|
return self._tasks
|
||||||
|
|
||||||
|
def compute_nodes(self) -> list[dict[str, Any]]:
|
||||||
|
return self._compute_nodes
|
||||||
|
|
||||||
|
def gpus(self) -> list[dict[str, Any]]:
|
||||||
|
return self._gpus
|
||||||
|
|
||||||
|
def system_info(self) -> dict[str, Any]:
|
||||||
|
return {"cpu": {}, "memory": {}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 测试 fixtures
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def fake_store() -> FakePlatformStore:
|
||||||
|
return FakePlatformStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_client(store: FakePlatformStore) -> TestClient:
|
||||||
|
"""构建 TestClient,patch 所有治理模块的 get_platform_store。"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(platform_router, prefix=PREFIX)
|
||||||
|
app.include_router(system_router, prefix=PREFIX)
|
||||||
|
app.include_router(tenant_router, prefix=PREFIX)
|
||||||
|
app.include_router(project_router, prefix=PREFIX)
|
||||||
|
app.include_router(approval_router, prefix=PREFIX)
|
||||||
|
app.include_router(retention_router, prefix=PREFIX)
|
||||||
|
app.include_router(resource_router, prefix=PREFIX)
|
||||||
|
|
||||||
|
patches = [
|
||||||
|
patch("app.db.platform_store.get_platform_store", return_value=store),
|
||||||
|
patch("app.core.auth.get_platform_store", return_value=store),
|
||||||
|
patch("app.api.v1.endpoints.platform.get_platform_store", return_value=store),
|
||||||
|
patch("app.modules.system.router.get_platform_store", return_value=store),
|
||||||
|
patch("app.modules.tenant.router.get_platform_store", return_value=store),
|
||||||
|
patch("app.modules.project.router.get_platform_store", return_value=store),
|
||||||
|
patch("app.modules.approval.router.get_platform_store", return_value=store),
|
||||||
|
patch("app.modules.retention.router.get_platform_store", return_value=store),
|
||||||
|
patch("app.modules.resource.router.get_platform_store", return_value=store),
|
||||||
|
]
|
||||||
|
for p in patches:
|
||||||
|
p.start()
|
||||||
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
client._fake_store = store # type: ignore[attr-defined]
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(fake_store: FakePlatformStore) -> TestClient:
|
||||||
|
c = _build_client(fake_store)
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_headers() -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _op_headers() -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {OP_TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第 1 周测试:登录、当前用户、用户列表、权限码、日志查询
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestWeek1AuthUserPermissionsLogs:
|
||||||
|
"""第 1 周:登录、当前用户、用户列表、权限码、日志查询接口。"""
|
||||||
|
|
||||||
|
def test_login_success(self, client: TestClient):
|
||||||
|
resp = client.post(f"{PREFIX}/login", json={"username": "admin", "password": "admin123"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert data["token"] == ADMIN_TOKEN
|
||||||
|
assert data["user"]["username"] == "admin"
|
||||||
|
|
||||||
|
def test_login_invalid(self, client: TestClient):
|
||||||
|
resp = client.post(f"{PREFIX}/login", json={"username": "admin", "password": "wrong"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_me_with_valid_token(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/me", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["username"] == "admin"
|
||||||
|
|
||||||
|
def test_me_without_token(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_users_list(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/users", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
users = resp.json()["data"]
|
||||||
|
assert len(users) >= 2
|
||||||
|
assert any(u["username"] == "admin" for u in users)
|
||||||
|
|
||||||
|
def test_create_user(self, client: TestClient):
|
||||||
|
resp = client.post(
|
||||||
|
f"{PREFIX}/users",
|
||||||
|
json={"username": "tester", "display_name": "Tester", "role": "viewer", "password": "test123"},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["username"] == "tester"
|
||||||
|
|
||||||
|
def test_permission_codes(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/system/permissions/codes")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
codes = resp.json()["data"]["codes"]
|
||||||
|
assert "dashboard" in codes
|
||||||
|
assert "user-settings" in codes
|
||||||
|
|
||||||
|
def test_permissions_overview(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/system/permissions")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert "codes" in data
|
||||||
|
assert "roles" in data
|
||||||
|
|
||||||
|
def test_log_files(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/log-files", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
files = resp.json()["data"]
|
||||||
|
assert len(files) >= 1
|
||||||
|
|
||||||
|
def test_log_content(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/log-content", params={"file": "backend.log"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "content" in resp.json()["data"]
|
||||||
|
|
||||||
|
def test_training_log_files(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/training-log-files", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["data"]) >= 1
|
||||||
|
|
||||||
|
def test_training_log_content(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/training-log-content", params={"file": "ft_001.log"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "content" in resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第 2 周测试:租户、项目、项目成员、资源 ACL
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestWeek2TenantProjectACL:
|
||||||
|
"""第 2 周:租户、项目、项目成员、资源 ACL。"""
|
||||||
|
|
||||||
|
def test_tenant_crud(self, client: TestClient):
|
||||||
|
# 创建
|
||||||
|
resp = client.post(f"{PREFIX}/tenants", json={"name": "Tenant-A", "code": "ta"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tid = resp.json()["data"]["id"]
|
||||||
|
# 查列表
|
||||||
|
resp = client.get(f"{PREFIX}/tenants", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any(t["id"] == tid for t in resp.json()["data"])
|
||||||
|
# 查详情
|
||||||
|
resp = client.get(f"{PREFIX}/tenants/{tid}", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["name"] == "Tenant-A"
|
||||||
|
# 更新
|
||||||
|
resp = client.put(f"{PREFIX}/tenants/{tid}", json={"name": "Tenant-A2"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["name"] == "Tenant-A2"
|
||||||
|
|
||||||
|
def test_tenant_quota(self, client: TestClient):
|
||||||
|
resp = client.post(f"{PREFIX}/tenants", json={"name": "Q-Tenant", "code": "qt"}, headers=_admin_headers())
|
||||||
|
tid = resp.json()["data"]["id"]
|
||||||
|
resp = client.put(f"{PREFIX}/tenants/{tid}/quota", json={"quota": {"gpu": 4}}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_tenant_retention(self, client: TestClient):
|
||||||
|
resp = client.post(f"{PREFIX}/tenants", json={"name": "R-Tenant", "code": "rt"}, headers=_admin_headers())
|
||||||
|
tid = resp.json()["data"]["id"]
|
||||||
|
resp = client.put(f"{PREFIX}/tenants/{tid}/retention-policy", json={"retention_policy_id": "rpol_1"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_project_crud(self, client: TestClient):
|
||||||
|
# 创建项目
|
||||||
|
resp = client.post(f"{PREFIX}/projects", json={"name": "Proj-1", "code": "p1", "tenant_id": "default"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
pid = resp.json()["data"]["id"]
|
||||||
|
# 查列表
|
||||||
|
resp = client.get(f"{PREFIX}/projects", params={"tenant_id": "default"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any(p["id"] == pid for p in resp.json()["data"])
|
||||||
|
# 查详情
|
||||||
|
resp = client.get(f"{PREFIX}/projects/{pid}", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["name"] == "Proj-1"
|
||||||
|
# 更新
|
||||||
|
resp = client.put(f"{PREFIX}/projects/{pid}", json={"description": "updated"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# 归档
|
||||||
|
resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["status"] == "archived"
|
||||||
|
|
||||||
|
def test_project_members(self, client: TestClient):
|
||||||
|
resp = client.post(f"{PREFIX}/projects", json={"name": "Proj-M", "code": "pm", "tenant_id": "default"}, headers=_admin_headers())
|
||||||
|
pid = resp.json()["data"]["id"]
|
||||||
|
# 加成员
|
||||||
|
resp = client.post(f"{PREFIX}/projects/{pid}/members", json={"user_id": "u_op", "role": "developer"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# 列成员
|
||||||
|
resp = client.get(f"{PREFIX}/projects/{pid}/members", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["data"]) >= 1
|
||||||
|
# 改角色
|
||||||
|
resp = client.put(f"{PREFIX}/projects/{pid}/members/u_op", json={"role": "maintainer"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# 删成员
|
||||||
|
resp = client.delete(f"{PREFIX}/projects/{pid}/members/u_op", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_resource_acl(self, client: TestClient):
|
||||||
|
# 设置 ACL
|
||||||
|
resp = client.put(
|
||||||
|
f"{PREFIX}/resources/model/m001/acl",
|
||||||
|
json={"entries": [{"subject_type": "user", "subject_id": "u_op", "permissions": ["read", "write"]}]},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
result = resp.json()["data"]
|
||||||
|
assert len(result) == 1
|
||||||
|
assert set(result[0]["permissions"]) == {"read", "write"}
|
||||||
|
# 查询 ACL
|
||||||
|
resp = client.get(f"{PREFIX}/resources/model/m001/acl", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["data"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第 3 周测试:审批实例、审批模板、审计日志查询和导出
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestWeek3ApprovalAudit:
|
||||||
|
"""第 3 周:审批实例、审批模板、审计日志查询和导出。"""
|
||||||
|
|
||||||
|
def test_approval_template_crud(self, client: TestClient):
|
||||||
|
# 创建模板
|
||||||
|
resp = client.post(f"{PREFIX}/approvals/templates", json={"name": "delete-approval", "steps": [{"approver_id": "u_admin", "status": "pending"}]}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tpl_id = resp.json()["data"]["id"]
|
||||||
|
# 查列表
|
||||||
|
resp = client.get(f"{PREFIX}/approvals/templates", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any(t["id"] == tpl_id for t in resp.json()["data"])
|
||||||
|
|
||||||
|
def test_approval_instance_flow(self, client: TestClient):
|
||||||
|
# 创建审批实例
|
||||||
|
resp = client.post(f"{PREFIX}/approvals", json={
|
||||||
|
"resource_type": "dataset", "resource_id": "ds_001",
|
||||||
|
"applicant_id": "u_op",
|
||||||
|
}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
iid = resp.json()["data"]["id"]
|
||||||
|
# 查详情
|
||||||
|
resp = client.get(f"{PREFIX}/approvals/{iid}", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["status"] == "pending"
|
||||||
|
# 审批决策
|
||||||
|
resp = client.post(f"{PREFIX}/approvals/{iid}/steps/0/decision", json={
|
||||||
|
"approver_id": "u_admin", "approved": True, "comment": "ok",
|
||||||
|
}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["status"] == "approved"
|
||||||
|
|
||||||
|
def test_approval_instance_reject(self, client: TestClient):
|
||||||
|
resp = client.post(f"{PREFIX}/approvals", json={
|
||||||
|
"resource_type": "model", "resource_id": "m_002",
|
||||||
|
"applicant_id": "u_op",
|
||||||
|
}, headers=_admin_headers())
|
||||||
|
iid = resp.json()["data"]["id"]
|
||||||
|
resp = client.post(f"{PREFIX}/approvals/{iid}/steps/0/decision", json={
|
||||||
|
"approver_id": "u_admin", "approved": False, "comment": "no",
|
||||||
|
}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["status"] == "rejected"
|
||||||
|
|
||||||
|
def test_approval_missing_field(self, client: TestClient):
|
||||||
|
resp = client.post(f"{PREFIX}/approvals", json={"resource_type": "dataset"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_audit_logs_query(self, client: TestClient):
|
||||||
|
# 通过 API 写操作触发审计
|
||||||
|
client.post(f"{PREFIX}/tenants", json={"name": "Audit-Tenant", "code": "at"}, headers=_admin_headers())
|
||||||
|
# 查询
|
||||||
|
resp = client.get(f"{PREFIX}/system/audit-logs", params={"limit": 50}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert data["total"] >= 1
|
||||||
|
|
||||||
|
def test_audit_logs_filter_by_action(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/system/audit-logs", params={"action": "tenant.create"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.json()["data"]["items"]
|
||||||
|
assert all(i.get("action") == "tenant.create" for i in items)
|
||||||
|
|
||||||
|
def test_audit_logs_export_csv(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/system/audit-logs/export", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/csv" in resp.headers.get("content-type", "")
|
||||||
|
# CSV 首行是表头
|
||||||
|
lines = resp.text.strip().split("\n")
|
||||||
|
assert "time" in lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第 4 周测试:写操作审计、审批拦截、权限校验
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestWeek4AuditInterceptPermission:
|
||||||
|
"""第 4 周:写操作审计、审批拦截、权限校验。"""
|
||||||
|
|
||||||
|
def test_write_operation_produces_audit(self, client: TestClient, fake_store: FakePlatformStore):
|
||||||
|
# 清空审计日志便于断言
|
||||||
|
fake_store._audit_logs.clear()
|
||||||
|
# 创建租户 → 应产生 tenant.create 审计
|
||||||
|
client.post(f"{PREFIX}/tenants", json={"name": "W-Tenant", "code": "wt"}, headers=_admin_headers())
|
||||||
|
assert any(l["action"] == "tenant.create" for l in fake_store._audit_logs)
|
||||||
|
# 创建项目 → 应产生 project.create 审计
|
||||||
|
client.post(f"{PREFIX}/projects", json={"name": "W-Proj", "code": "wp", "tenant_id": "default"}, headers=_admin_headers())
|
||||||
|
assert any(l["action"] == "project.create" for l in fake_store._audit_logs)
|
||||||
|
# 设置 ACL → 应产生 resource.acl.set 审计
|
||||||
|
client.put(f"{PREFIX}/resources/model/w001/acl", json={"entries": []}, headers=_admin_headers())
|
||||||
|
assert any(l["action"] == "resource.acl.set" for l in fake_store._audit_logs)
|
||||||
|
|
||||||
|
def test_approval_intercept_on_project_archive(self, client: TestClient, fake_store: FakePlatformStore):
|
||||||
|
# 创建项目
|
||||||
|
resp = client.post(f"{PREFIX}/projects", json={"name": "I-Proj", "code": "ip", "tenant_id": "default"}, headers=_admin_headers())
|
||||||
|
pid = resp.json()["data"]["id"]
|
||||||
|
# 无待审批 → 可归档
|
||||||
|
resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_approval_intercept_blocks_when_pending(self, client: TestClient, fake_store: FakePlatformStore):
|
||||||
|
# 创建项目
|
||||||
|
resp = client.post(f"{PREFIX}/projects", json={"name": "B-Proj", "code": "bp", "tenant_id": "default"}, headers=_admin_headers())
|
||||||
|
pid = resp.json()["data"]["id"]
|
||||||
|
# 注入一条待审批实例
|
||||||
|
fake_store.create_approval_instance({
|
||||||
|
"resource_type": "project",
|
||||||
|
"resource_id": pid,
|
||||||
|
"applicant_id": "u_op",
|
||||||
|
})
|
||||||
|
# 有待审批 → 归档应被拒绝
|
||||||
|
resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_retention_policy_crud_with_audit(self, client: TestClient, fake_store: FakePlatformStore):
|
||||||
|
fake_store._audit_logs.clear()
|
||||||
|
# 创建
|
||||||
|
resp = client.post(f"{PREFIX}/retention-policies", json={"name": "30d-keep", "scope": "tenant"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rpid = resp.json()["data"]["id"]
|
||||||
|
assert any(l["action"] == "retention.create" for l in fake_store._audit_logs)
|
||||||
|
# 查列表
|
||||||
|
resp = client.get(f"{PREFIX}/retention-policies", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any(p["id"] == rpid for p in resp.json()["data"])
|
||||||
|
# 更新
|
||||||
|
resp = client.put(f"{PREFIX}/retention-policies/{rpid}", json={"status": "inactive"}, headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["data"]["status"] == "inactive"
|
||||||
|
# 删除
|
||||||
|
resp = client.delete(f"{PREFIX}/retention-policies/{rpid}", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_login_duration_rank_in_dashboard(self, client: TestClient):
|
||||||
|
resp = client.get(f"{PREFIX}/dashboard/stats", headers=_admin_headers())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert "login_duration_rank" in data
|
||||||
|
assert "recent_login_users" in data
|
||||||
|
assert "service_status" in data
|
||||||
|
assert "training_7d" in data
|
||||||
@@ -4,3 +4,4 @@ python-multipart>=0.0.9
|
|||||||
pydantic>=2.7.0
|
pydantic>=2.7.0
|
||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
llamafactory
|
||||||
@@ -1,9 +1,62 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { routeLoading } from '@/router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { SESSION_TIMEOUT } from '@/constants'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 离开页面超时:
|
||||||
|
* - 标签页切走/最小化(document.hidden)时记录时间
|
||||||
|
* - 切回来时若超过 SESSION_TIMEOUT(5分钟),强制跳登录
|
||||||
|
* - 不管是否在操作,只要离开页面超过 5 分钟就跳
|
||||||
|
*/
|
||||||
|
let hiddenAt = 0
|
||||||
|
|
||||||
|
function handleVisibility() {
|
||||||
|
if (document.hidden) {
|
||||||
|
hiddenAt = Date.now()
|
||||||
|
} else {
|
||||||
|
if (hiddenAt > 0 && Date.now() - hiddenAt >= SESSION_TIMEOUT) {
|
||||||
|
auth.logout()
|
||||||
|
ElMessage.warning('登录已过期,请重新登录')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
hiddenAt = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-config-provider :locale="zhCn">
|
<el-config-provider :locale="zhCn">
|
||||||
<router-view />
|
<div v-loading="routeLoading" element-loading-text="加载中..." class="app-root">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.app-root {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
15
frontend/src/api/modules/acl.ts
Normal file
15
frontend/src/api/modules/acl.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { get, put } from '../request'
|
||||||
|
|
||||||
|
export interface AclEntry {
|
||||||
|
subject_type: string
|
||||||
|
subject_id: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资源 ACL 查询 */
|
||||||
|
export const getAcl = (resourceType: string, resourceId: string) =>
|
||||||
|
get<AclEntry[]>(`/resources/${resourceType}/${resourceId}/acl`)
|
||||||
|
|
||||||
|
/** 资源 ACL 设置 */
|
||||||
|
export const setAcl = (resourceType: string, resourceId: string, entries: AclEntry[]) =>
|
||||||
|
put<AclEntry[]>(`/resources/${resourceType}/${resourceId}/acl`, { entries })
|
||||||
50
frontend/src/api/modules/approval.ts
Normal file
50
frontend/src/api/modules/approval.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { get, post } from '../request'
|
||||||
|
|
||||||
|
export interface ApprovalStep {
|
||||||
|
approver_id?: string | null
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
steps: ApprovalStep[]
|
||||||
|
create_time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalInstance {
|
||||||
|
id: string
|
||||||
|
template_id?: string | null
|
||||||
|
resource_type: string
|
||||||
|
resource_id: string
|
||||||
|
applicant_id: string
|
||||||
|
status: string
|
||||||
|
current_step: number
|
||||||
|
create_time?: string
|
||||||
|
steps: Array<ApprovalStep & { step_index: number; comment?: string | null; time?: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getApprovalTemplates = () =>
|
||||||
|
get<ApprovalTemplate[]>('/approvals/templates')
|
||||||
|
|
||||||
|
export const createApprovalTemplate = (payload: { name: string; steps: ApprovalStep[] }) =>
|
||||||
|
post<ApprovalTemplate>('/approvals/templates', payload)
|
||||||
|
|
||||||
|
export const getApprovalInstances = (status?: string) =>
|
||||||
|
get<ApprovalInstance[]>('/approvals', { status })
|
||||||
|
|
||||||
|
export const createApprovalInstance = (payload: {
|
||||||
|
template_id?: string
|
||||||
|
resource_type: string
|
||||||
|
resource_id: string
|
||||||
|
applicant_id: string
|
||||||
|
}) => post<ApprovalInstance>('/approvals', payload)
|
||||||
|
|
||||||
|
export const getApprovalInstance = (id: string) =>
|
||||||
|
get<ApprovalInstance>(`/approvals/${id}`)
|
||||||
|
|
||||||
|
export const decideApproval = (
|
||||||
|
id: string,
|
||||||
|
step_index: number,
|
||||||
|
payload: { approver_id: string; approved: boolean; comment?: string },
|
||||||
|
) => post<ApprovalInstance>(`/approvals/${id}/steps/${step_index}/decision`, payload)
|
||||||
40
frontend/src/api/modules/audit.ts
Normal file
40
frontend/src/api/modules/audit.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { get } from '../request'
|
||||||
|
import request from '../request'
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string
|
||||||
|
tenant_id?: string
|
||||||
|
project_id?: string
|
||||||
|
actor_id?: string
|
||||||
|
action?: string
|
||||||
|
target_type?: string
|
||||||
|
target_id?: string
|
||||||
|
detail?: string
|
||||||
|
client_ip?: string
|
||||||
|
time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQuery {
|
||||||
|
tenant_id?: string
|
||||||
|
project_id?: string
|
||||||
|
actor_id?: string
|
||||||
|
action?: string
|
||||||
|
target_type?: string
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计日志查询:使用 get 辅助函数,拦截器已解包,直接返回 { items, total } */
|
||||||
|
export const getAuditLogs = (query: AuditQuery = {}) =>
|
||||||
|
get<{ items: AuditLog[]; total: number }>('/system/audit-logs', query)
|
||||||
|
|
||||||
|
/** 审计日志导出 CSV:blob 响应走完整 axios response,需手动取 data */
|
||||||
|
export const exportAuditLogs = (query: AuditQuery = {}) =>
|
||||||
|
request<Blob>({
|
||||||
|
url: '/system/audit-logs/export',
|
||||||
|
method: 'get',
|
||||||
|
params: query,
|
||||||
|
responseType: 'blob',
|
||||||
|
}).then((res) => res.data)
|
||||||
35
frontend/src/api/modules/dashboard.ts
Normal file
35
frontend/src/api/modules/dashboard.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { get } from '../request'
|
||||||
|
|
||||||
|
export interface ServiceStatusStat {
|
||||||
|
type: string
|
||||||
|
status: 'normal' | 'busy' | 'error'
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainingTaskStat {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
train_type: string
|
||||||
|
train_method: string
|
||||||
|
base_model: string
|
||||||
|
progress: number
|
||||||
|
accuracy: number | null
|
||||||
|
started_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
online_services: number
|
||||||
|
running_tasks: number
|
||||||
|
pending_alerts: number
|
||||||
|
training_7d: { date: string; train: number; gpu: number; accuracy: number | null }[]
|
||||||
|
service_status: ServiceStatusStat[]
|
||||||
|
training_tasks: TrainingTaskStat[]
|
||||||
|
operation_distribution: { name: string; value: number }[]
|
||||||
|
login_duration_rank: { user: string; role: string; duration: number }[]
|
||||||
|
recent_login_users: { user: string; role: string; last_login: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardStats() {
|
||||||
|
return get<DashboardStats>('/dashboard/stats')
|
||||||
|
}
|
||||||
55
frontend/src/api/modules/project.ts
Normal file
55
frontend/src/api/modules/project.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { del, get, post, put } from '../request'
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
tenant_id: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
description?: string
|
||||||
|
status: string
|
||||||
|
quota?: Record<string, unknown>
|
||||||
|
member_count?: number
|
||||||
|
task_count?: number
|
||||||
|
create_time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMember {
|
||||||
|
user_id: string
|
||||||
|
role: string
|
||||||
|
joined_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目列表(按租户过滤,默认 default) */
|
||||||
|
export const getProjects = (tenantId = 'default') =>
|
||||||
|
get<Project[]>('/projects', { tenant_id: tenantId })
|
||||||
|
|
||||||
|
/** 项目详情 */
|
||||||
|
export const getProject = (id: string) => get<Project>(`/projects/${id}`)
|
||||||
|
|
||||||
|
/** 创建项目 */
|
||||||
|
export const createProject = (payload: Partial<Project>) =>
|
||||||
|
post<Project>('/projects', payload)
|
||||||
|
|
||||||
|
/** 更新项目 */
|
||||||
|
export const updateProject = (id: string, payload: Partial<Project>) =>
|
||||||
|
put<Project>(`/projects/${id}`, payload)
|
||||||
|
|
||||||
|
/** 归档项目 */
|
||||||
|
export const archiveProject = (id: string) =>
|
||||||
|
post<Project>(`/projects/${id}/archive`)
|
||||||
|
|
||||||
|
/** 项目成员列表 */
|
||||||
|
export const getProjectMembers = (id: string) =>
|
||||||
|
get<ProjectMember[]>(`/projects/${id}/members`)
|
||||||
|
|
||||||
|
/** 添加成员 */
|
||||||
|
export const addProjectMember = (id: string, payload: { user_id: string; role: string }) =>
|
||||||
|
post<ProjectMember>(`/projects/${id}/members`, payload)
|
||||||
|
|
||||||
|
/** 更新成员角色 */
|
||||||
|
export const updateProjectMember = (id: string, userId: string, role: string) =>
|
||||||
|
put<ProjectMember>(`/projects/${id}/members/${userId}`, { role })
|
||||||
|
|
||||||
|
/** 移除成员 */
|
||||||
|
export const removeProjectMember = (id: string, userId: string) =>
|
||||||
|
del(`/projects/${id}/members/${userId}`)
|
||||||
29
frontend/src/api/modules/retention.ts
Normal file
29
frontend/src/api/modules/retention.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { del, get, post, put } from '../request'
|
||||||
|
|
||||||
|
export interface RetentionPolicy {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
scope?: string | null
|
||||||
|
rule?: string | null
|
||||||
|
status: string
|
||||||
|
create_time?: string
|
||||||
|
create_by?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 留存策略列表 */
|
||||||
|
export const getRetentionPolicies = () => get<RetentionPolicy[]>('/retention-policies')
|
||||||
|
|
||||||
|
/** 留存策略详情 */
|
||||||
|
export const getRetentionPolicy = (id: string) => get<RetentionPolicy>(`/retention-policies/${id}`)
|
||||||
|
|
||||||
|
/** 创建留存策略 */
|
||||||
|
export const createRetentionPolicy = (payload: Partial<RetentionPolicy>) =>
|
||||||
|
post<RetentionPolicy>('/retention-policies', payload)
|
||||||
|
|
||||||
|
/** 更新留存策略 */
|
||||||
|
export const updateRetentionPolicy = (id: string, payload: Partial<RetentionPolicy>) =>
|
||||||
|
put<RetentionPolicy>(`/retention-policies/${id}`, payload)
|
||||||
|
|
||||||
|
/** 删除留存策略 */
|
||||||
|
export const deleteRetentionPolicy = (id: string) => del(`/retention-policies/${id}`)
|
||||||
@@ -25,12 +25,14 @@ export const getUsers = () => get<SystemUser[]>('/users')
|
|||||||
export const createUser = (payload: CreateUserPayload) =>
|
export const createUser = (payload: CreateUserPayload) =>
|
||||||
post<SystemUser>('/users', payload)
|
post<SystemUser>('/users', payload)
|
||||||
|
|
||||||
/** 删除用户,currentUsername 用于防止删除当前登录账号 */
|
|
||||||
export const deleteUser = (id: string, currentUsername: string) =>
|
|
||||||
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`, {
|
|
||||||
current_username: currentUsername,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 更新用户角色、状态及页面权限 */
|
/** 更新用户角色、状态及页面权限 */
|
||||||
export const updateUserAccess = (id: string, payload: UpdateUserAccessPayload) =>
|
export const updateUserAccess = (id: string, payload: UpdateUserAccessPayload) =>
|
||||||
put<SystemUser>(`/users/${encodeURIComponent(id)}`, payload)
|
put<SystemUser>(`/users/${encodeURIComponent(id)}`, payload)
|
||||||
|
|
||||||
|
/** 重置用户密码 */
|
||||||
|
export const resetUserPassword = (id: string, password?: string) =>
|
||||||
|
post<{ reset: string }>(`/users/${encodeURIComponent(id)}/reset-password`, { password })
|
||||||
|
|
||||||
|
/** 删除用户(protected 管理员账号不允许删除) */
|
||||||
|
export const deleteUser = (id: string) =>
|
||||||
|
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`)
|
||||||
|
|||||||
34
frontend/src/api/modules/tenant.ts
Normal file
34
frontend/src/api/modules/tenant.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { get, post, put } from '../request'
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
status: string
|
||||||
|
owner_user_id?: string | null
|
||||||
|
quota: Record<string, unknown>
|
||||||
|
retention_policy_id?: string | null
|
||||||
|
create_time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 租户列表 */
|
||||||
|
export const getTenants = () => get<Tenant[]>('/tenants')
|
||||||
|
|
||||||
|
/** 租户详情 */
|
||||||
|
export const getTenant = (id: string) => get<Tenant>(`/tenants/${id}`)
|
||||||
|
|
||||||
|
/** 创建租户 */
|
||||||
|
export const createTenant = (payload: Partial<Tenant>) =>
|
||||||
|
post<Tenant>('/tenants', payload)
|
||||||
|
|
||||||
|
/** 更新租户 */
|
||||||
|
export const updateTenant = (id: string, payload: Partial<Tenant>) =>
|
||||||
|
put<Tenant>(`/tenants/${id}`, payload)
|
||||||
|
|
||||||
|
/** 设置租户配额 */
|
||||||
|
export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
|
||||||
|
put<Tenant>(`/tenants/${id}/quota`, { quota })
|
||||||
|
|
||||||
|
/** 设置租户留存策略 */
|
||||||
|
export const setTenantRetention = (id: string, retention_policy_id: string) =>
|
||||||
|
put<Tenant>(`/tenants/${id}/retention-policy`, { retention_policy_id })
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { touchSessionActivity } from '@/utils/sessionActivity'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后端统一响应格式
|
* 后端统一响应格式
|
||||||
@@ -18,9 +17,37 @@ const service: AxiosInstance = axios.create({
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 请求拦截器
|
/**
|
||||||
|
* 从 localStorage 取当前用户 token(登录时后端返回 platform-token-{user_id})。
|
||||||
|
* 后端鉴权中间件依赖此 header 解析当前用户身份。
|
||||||
|
*/
|
||||||
|
function getAuthToken(): string | null {
|
||||||
|
const USER_STORAGE_KEY = 'currentUser'
|
||||||
|
const raw = localStorage.getItem(USER_STORAGE_KEY)
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(raw)
|
||||||
|
// 后端 login 返回的 token 格式为 platform-token-{user.id}
|
||||||
|
if (user?.id) return `platform-token-${user.id}`
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兼容改造前 admin 会话
|
||||||
|
if (localStorage.getItem('username') === 'admin') return 'platform-token-admin'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求拦截器:注入 Authorization header
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
(config) => config,
|
(config) => {
|
||||||
|
const token = getAuthToken()
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
(error) => Promise.reject(error),
|
(error) => Promise.reject(error),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,12 +57,9 @@ service.interceptors.response.use(
|
|||||||
const res = response.data as ApiResult
|
const res = response.data as ApiResult
|
||||||
// 二进制流等非 JSON 响应直接返回
|
// 二进制流等非 JSON 响应直接返回
|
||||||
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
|
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
|
||||||
touchSessionActivity()
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
// 生成进度轮询也属于用户正在使用系统,避免长任务结束后被误判为会话过期。
|
|
||||||
touchSessionActivity()
|
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
// 业务错误
|
// 业务错误
|
||||||
|
|||||||
79
frontend/src/components/AclDialog.vue
Normal file
79
frontend/src/components/AclDialog.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getAcl, setAcl, type AclEntry } from '@/api/modules/acl'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
resourceType: string
|
||||||
|
resourceId: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
const entries = ref<AclEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const ALL_PERMS = ['read', 'write', 'execute', 'download', 'delete', 'share']
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
entries.value = await getAcl(props.resourceType, props.resourceId)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (v) => { if (v) load() })
|
||||||
|
|
||||||
|
function addEntry() {
|
||||||
|
entries.value.push({ subject_type: 'user', subject_id: '', permissions: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(idx: number) {
|
||||||
|
entries.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await setAcl(props.resourceType, props.resourceId, entries.value)
|
||||||
|
ElMessage.success('ACL 已保存')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="资源授权 (ACL)" width="640px">
|
||||||
|
<div v-loading="loading">
|
||||||
|
<el-button type="primary" size="small" @click="addEntry">添加授权项</el-button>
|
||||||
|
<div v-for="(entry, idx) in entries" :key="idx" class="acl-row">
|
||||||
|
<el-select v-model="entry.subject_type" style="width: 140px">
|
||||||
|
<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-checkbox-group v-model="entry.permissions">
|
||||||
|
<el-checkbox v-for="p in ALL_PERMS" :key="p" :value="p">{{ p }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
<el-button link type="danger" @click="removeEntry(idx)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="entries.length === 0" description="暂无授权" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.acl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -74,6 +74,16 @@ const menuGroups: MenuGroup[] = [
|
|||||||
{ key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' },
|
{ key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '平台治理',
|
||||||
|
items: [
|
||||||
|
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
|
||||||
|
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
|
||||||
|
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
|
||||||
|
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' },
|
||||||
|
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '系统设置',
|
title: '系统设置',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { PermissionCode } from '@/types'
|
import type { PermissionCode } from '@/types'
|
||||||
|
|
||||||
|
/** 路由切换时的全局加载态,供 App.vue 显示全屏转圈遮罩,消除懒加载时的空白卡顿感 */
|
||||||
|
export const routeLoading = ref(false)
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -27,6 +31,49 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/dashboard/DashboardView.vue'),
|
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||||
meta: { title: '服务看板' },
|
meta: { title: '服务看板' },
|
||||||
},
|
},
|
||||||
|
// 平台治理
|
||||||
|
{
|
||||||
|
path: 'tenants',
|
||||||
|
name: 'tenants',
|
||||||
|
component: () => import('@/views/tenants/TenantListView.vue'),
|
||||||
|
meta: { title: '租户管理', permission: 'user-settings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tenants/:id',
|
||||||
|
name: 'tenant-detail',
|
||||||
|
component: () => import('@/views/tenants/TenantDetailView.vue'),
|
||||||
|
meta: { title: '租户详情', permission: 'user-settings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects',
|
||||||
|
name: 'projects',
|
||||||
|
component: () => import('@/views/projects/ProjectListView.vue'),
|
||||||
|
meta: { title: '项目空间', permission: 'user-settings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects/:id',
|
||||||
|
name: 'project-detail',
|
||||||
|
component: () => import('@/views/projects/ProjectDetailView.vue'),
|
||||||
|
meta: { title: '项目详情', permission: 'user-settings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'audit-logs',
|
||||||
|
name: 'audit-logs',
|
||||||
|
component: () => import('@/views/audit/AuditLogView.vue'),
|
||||||
|
meta: { title: '审计日志', permission: 'user-settings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'approval-templates',
|
||||||
|
name: 'approval-templates',
|
||||||
|
component: () => import('@/views/approvals/ApprovalTemplateView.vue'),
|
||||||
|
meta: { title: '审批模板', permission: 'user-settings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'approval-instances',
|
||||||
|
name: 'approval-instances',
|
||||||
|
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
|
||||||
|
meta: { title: '审批中心', permission: 'user-settings' },
|
||||||
|
},
|
||||||
// 模型调优
|
// 模型调优
|
||||||
{
|
{
|
||||||
path: 'fine-tune',
|
path: 'fine-tune',
|
||||||
@@ -299,6 +346,11 @@ const permissionBySegment: Record<string, PermissionCode> = {
|
|||||||
hardware: 'hardware',
|
hardware: 'hardware',
|
||||||
logs: 'logs',
|
logs: 'logs',
|
||||||
'user-settings': 'user-settings',
|
'user-settings': 'user-settings',
|
||||||
|
tenants: 'user-settings',
|
||||||
|
projects: 'user-settings',
|
||||||
|
'audit-logs': 'user-settings',
|
||||||
|
'approval-templates': 'user-settings',
|
||||||
|
'approval-instances': 'user-settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiredPermission(path: string, explicit?: unknown) {
|
function requiredPermission(path: string, explicit?: unknown) {
|
||||||
@@ -307,10 +359,11 @@ function requiredPermission(path: string, explicit?: unknown) {
|
|||||||
return permissionBySegment[segment]
|
return permissionBySegment[segment]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局守卫:登录校验 + 会话超时
|
// 全局守卫:登录校验
|
||||||
|
// 离开页面超时由 App.vue 的 visibilitychange 监听接管
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
|
if (!to.meta.public) routeLoading.value = true
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
auth.syncSession()
|
|
||||||
document.title = to.meta.title ? `${to.meta.title} - 远光软件微调平台` : '远光软件微调平台'
|
document.title = to.meta.title ? `${to.meta.title} - 远光软件微调平台` : '远光软件微调平台'
|
||||||
|
|
||||||
if (to.meta.public) {
|
if (to.meta.public) {
|
||||||
@@ -324,6 +377,7 @@ router.beforeEach((to, _from, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
|
auth.logout()
|
||||||
next({ name: 'login' })
|
next({ name: 'login' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -336,9 +390,11 @@ router.beforeEach((to, _from, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 续期会话
|
|
||||||
auth.refresh()
|
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
routeLoading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { login as loginApi } from '@/api/modules/system'
|
import { login as loginApi } from '@/api/modules/system'
|
||||||
import { SESSION_TIMEOUT } from '@/constants'
|
|
||||||
import type { PermissionCode, SystemUser } from '@/types'
|
import type { PermissionCode, SystemUser } from '@/types'
|
||||||
import {
|
|
||||||
clearSessionActivity,
|
|
||||||
sessionActivityTime,
|
|
||||||
startSessionActivity,
|
|
||||||
syncSessionActivity,
|
|
||||||
touchSessionActivity,
|
|
||||||
} from '@/utils/sessionActivity'
|
|
||||||
|
|
||||||
const USER_STORAGE_KEY = 'currentUser'
|
const USER_STORAGE_KEY = 'currentUser'
|
||||||
|
|
||||||
@@ -56,7 +48,8 @@ function restoreUser(): SystemUser | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证 store
|
* 认证 store
|
||||||
* 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制
|
* 登录态管理:有 currentUser 即视为已登录。
|
||||||
|
* 离开页面超时由 App.vue 的 visibilitychange 监听接管。
|
||||||
*/
|
*/
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const currentUser = ref<SystemUser | null>(restoreUser())
|
const currentUser = ref<SystemUser | null>(restoreUser())
|
||||||
@@ -67,18 +60,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
if (currentUser.value?.role === 'operator') return '操作员'
|
if (currentUser.value?.role === 'operator') return '操作员'
|
||||||
return '观察员'
|
return '观察员'
|
||||||
})
|
})
|
||||||
const loginTime = sessionActivityTime
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => {
|
const isLoggedIn = computed(() => currentUser.value !== null)
|
||||||
if (!loginTime.value) return false
|
|
||||||
return Date.now() - loginTime.value < SESSION_TIMEOUT
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 登录 */
|
/** 登录 */
|
||||||
async function login(user: string, password: string) {
|
async function login(user: string, password: string) {
|
||||||
const response = await loginApi(user, password)
|
const response = await loginApi(user, password)
|
||||||
currentUser.value = response.user
|
currentUser.value = response.user
|
||||||
startSessionActivity()
|
|
||||||
localStorage.setItem('username', response.user.username)
|
localStorage.setItem('username', response.user.username)
|
||||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
|
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
|
||||||
}
|
}
|
||||||
@@ -89,20 +77,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return currentUser.value?.permissions.includes(permission) ?? false
|
return currentUser.value?.permissions.includes(permission) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 续期会话(活跃时刷新) */
|
|
||||||
function refresh() {
|
|
||||||
if (currentUser.value) touchSessionActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 在路由判断前吸收其他标签页写入的最后活跃时间。 */
|
|
||||||
function syncSession() {
|
|
||||||
syncSessionActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 退出 */
|
/** 退出 */
|
||||||
function logout() {
|
function logout() {
|
||||||
currentUser.value = null
|
currentUser.value = null
|
||||||
clearSessionActivity()
|
|
||||||
localStorage.removeItem('username')
|
localStorage.removeItem('username')
|
||||||
localStorage.removeItem(USER_STORAGE_KEY)
|
localStorage.removeItem(USER_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
@@ -112,12 +89,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
roleLabel,
|
roleLabel,
|
||||||
loginTime,
|
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
login,
|
login,
|
||||||
refresh,
|
|
||||||
syncSession,
|
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function storedActivityTime() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话按“最后活跃时间”计算,而不是从首次登录起固定倒计时。
|
* 会话按“最后活跃时间”计算,而不是从首次登录起固定倒计时。
|
||||||
* 该 ref 被认证 store 与请求层共享,确保 API 活动可以立即影响路由守卫。
|
* 该 ref 被认证 store 与路由守卫共享,确保真实用户活动可以立即影响超时判断。
|
||||||
*/
|
*/
|
||||||
export const sessionActivityTime = ref(storedActivityTime())
|
export const sessionActivityTime = ref(storedActivityTime())
|
||||||
|
|
||||||
@@ -30,3 +30,45 @@ export function clearSessionActivity() {
|
|||||||
sessionActivityTime.value = 0
|
sessionActivityTime.value = 0
|
||||||
localStorage.removeItem(LOGIN_TIME_STORAGE_KEY)
|
localStorage.removeItem(LOGIN_TIME_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅在用户真实活跃时续期会话:
|
||||||
|
* - 鼠标移动 / 键盘 / 点击 / 触摸(说明用户正在操作)
|
||||||
|
* - 标签页切回可见(说明用户回到界面)
|
||||||
|
* 页面后台轮询接口、切走标签页不会续期,从而“无操作”或“不在当前界面”
|
||||||
|
* 超过空闲时长才会被判定为会话过期并跳回登录。
|
||||||
|
*/
|
||||||
|
let userActivityBound = false
|
||||||
|
let lastTouch = 0
|
||||||
|
const ACTIVITY_THROTTLE = 5000 // 5s 内最多续期一次,避免 mousemove 过于频繁
|
||||||
|
|
||||||
|
const activityEvents = ['mousemove', 'mousedown', 'keydown', 'click', 'touchstart'] as const
|
||||||
|
|
||||||
|
function handleUserActivity() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastTouch < ACTIVITY_THROTTLE) return
|
||||||
|
lastTouch = now
|
||||||
|
touchSessionActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibility() {
|
||||||
|
if (!document.hidden) {
|
||||||
|
touchSessionActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindUserActivityListeners() {
|
||||||
|
if (userActivityBound) return
|
||||||
|
userActivityBound = true
|
||||||
|
activityEvents.forEach((evt) =>
|
||||||
|
window.addEventListener(evt, handleUserActivity, { passive: true })
|
||||||
|
)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unbindUserActivityListeners() {
|
||||||
|
if (!userActivityBound) return
|
||||||
|
userActivityBound = false
|
||||||
|
activityEvents.forEach((evt) => window.removeEventListener(evt, handleUserActivity))
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility)
|
||||||
|
}
|
||||||
|
|||||||
128
frontend/src/views/approvals/ApprovalInstanceView.vue
Normal file
128
frontend/src/views/approvals/ApprovalInstanceView.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import { getApprovalInstances, decideApproval, type ApprovalInstance } from '@/api/modules/approval'
|
||||||
|
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const instances = ref<ApprovalInstance[]>([])
|
||||||
|
const users = ref<SystemUser[]>([])
|
||||||
|
const statusFilter = ref<string | undefined>(undefined)
|
||||||
|
const showDecide = ref(false)
|
||||||
|
const current = ref<ApprovalInstance | null>(null)
|
||||||
|
const decision = ref({ step_index: 0, approver_id: '', approved: true, comment: '' })
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '待审批', value: 'pending' },
|
||||||
|
{ label: '已通过', value: 'approved' },
|
||||||
|
{ label: '已拒绝', value: 'rejected' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
instances.value = await getApprovalInstances(statusFilter.value)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
users.value = await getUsers()
|
||||||
|
} catch {
|
||||||
|
users.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function userName(id?: string) {
|
||||||
|
if (!id) return '—'
|
||||||
|
return users.value.find((u) => u.id === id)?.username || id
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDecide(inst: ApprovalInstance) {
|
||||||
|
current.value = inst
|
||||||
|
const step = inst.steps.find((s) => s.status === 'pending')
|
||||||
|
decision.value = { step_index: step ? step.step_index : 0, approver_id: '', approved: true, comment: '' }
|
||||||
|
showDecide.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDecision() {
|
||||||
|
if (!current.value) return
|
||||||
|
if (!decision.value.approver_id) {
|
||||||
|
ElMessage.warning('请选择审批人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await decideApproval(current.value.id, decision.value.step_index, {
|
||||||
|
approver_id: decision.value.approver_id,
|
||||||
|
approved: decision.value.approved,
|
||||||
|
comment: decision.value.comment,
|
||||||
|
})
|
||||||
|
ElMessage.success('审批已提交')
|
||||||
|
showDecide.value = false
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers()
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable search-fields="resource_type,resource_id">
|
||||||
|
<template #toolbar-extra>
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
|
||||||
|
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column prop="resource_type" label="资源类型" min-width="120" />
|
||||||
|
<el-table-column prop="resource_id" label="资源 ID" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="applicant_id" label="申请人" min-width="120">
|
||||||
|
<template #default="{ row }">{{ userName(row.applicant_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" min-width="100" />
|
||||||
|
<el-table-column prop="current_step" label="当前步骤" min-width="100" />
|
||||||
|
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<el-button v-if="row.status === 'pending'" link type="primary" @click="openDecide(row)">审批</el-button>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
<el-dialog v-model="showDecide" title="审批决策" width="480px">
|
||||||
|
<el-form label-width="80px" v-if="current">
|
||||||
|
<el-form-item label="实例">
|
||||||
|
{{ current.resource_type }} / {{ current.resource_id }}
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="步骤">
|
||||||
|
第 {{ decision.step_index + 1 }} 步
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="审批人" required>
|
||||||
|
<el-select v-model="decision.approver_id" filterable style="width: 100%">
|
||||||
|
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结果">
|
||||||
|
<el-radio-group v-model="decision.approved">
|
||||||
|
<el-radio :value="true">通过</el-radio>
|
||||||
|
<el-radio :value="false">拒绝</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="意见">
|
||||||
|
<el-input v-model="decision.comment" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showDecide = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitDecision">提交</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page { padding: 16px; }
|
||||||
|
</style>
|
||||||
77
frontend/src/views/approvals/ApprovalTemplateView.vue
Normal file
77
frontend/src/views/approvals/ApprovalTemplateView.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import { createApprovalTemplate, getApprovalTemplates, type ApprovalTemplate } from '@/api/modules/approval'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const templates = ref<ApprovalTemplate[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const form = ref({ name: '', stepsText: '[]' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
templates.value = await getApprovalTemplates()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (!form.value.name) {
|
||||||
|
ElMessage.warning('请填写模板名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let steps: unknown[] = []
|
||||||
|
try {
|
||||||
|
steps = JSON.parse(form.value.stepsText || '[]')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('步骤需为合法 JSON 数组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await createApprovalTemplate({ name: form.value.name, steps: steps as any })
|
||||||
|
ElMessage.success('模板创建成功')
|
||||||
|
showCreate.value = false
|
||||||
|
form.value = { name: '', stepsText: '[]' }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<DataTablePage title="审批模板" :data="templates" :loading="loading">
|
||||||
|
<template #toolbar-extra>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建模板</el-button>
|
||||||
|
</template>
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column prop="name" label="模板名" min-width="160" />
|
||||||
|
<el-table-column label="步骤数" min-width="100">
|
||||||
|
<template #default="{ row }">{{ (row.steps || []).length }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
<el-dialog v-model="showCreate" title="新建审批模板" width="560px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="名称" required>
|
||||||
|
<el-input v-model="form.name" placeholder="模板名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="步骤 JSON">
|
||||||
|
<el-input v-model="form.stepsText" type="textarea" :rows="5" placeholder='[{"approver_id":"u1"},{"approver_id":"u2"}]' />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page { padding: 16px; }
|
||||||
|
</style>
|
||||||
125
frontend/src/views/audit/AuditLogView.vue
Normal file
125
frontend/src/views/audit/AuditLogView.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getAuditLogs, exportAuditLogs, type AuditLog, type AuditQuery } from '@/api/modules/audit'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const logs = ref<AuditLog[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const query = reactive<AuditQuery>({
|
||||||
|
tenant_id: '',
|
||||||
|
project_id: '',
|
||||||
|
actor_id: '',
|
||||||
|
action: '',
|
||||||
|
target_type: '',
|
||||||
|
start_time: '',
|
||||||
|
end_time: '',
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 时间范围(el-date-picker 双向绑定数组 [start, end])
|
||||||
|
const timeRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
function applyTimeRange() {
|
||||||
|
if (timeRange.value && timeRange.value.length === 2) {
|
||||||
|
query.start_time = timeRange.value[0]
|
||||||
|
query.end_time = timeRange.value[1]
|
||||||
|
} else {
|
||||||
|
query.start_time = ''
|
||||||
|
query.end_time = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAuditLogs({ ...query })
|
||||||
|
logs.value = res.items
|
||||||
|
total.value = res.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
try {
|
||||||
|
const blob = await exportAuditLogs({ ...query, limit: 10000, offset: 0 })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `audit_logs_${Date.now()}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('导出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">审计日志</h2>
|
||||||
|
<el-button @click="handleExport">导出 CSV</el-button>
|
||||||
|
</div>
|
||||||
|
<el-card class="filter-card">
|
||||||
|
<el-form :inline="true">
|
||||||
|
<el-form-item label="租户">
|
||||||
|
<el-input v-model="query.tenant_id" placeholder="tenant_id" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="项目">
|
||||||
|
<el-input v-model="query.project_id" placeholder="project_id" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作人">
|
||||||
|
<el-input v-model="query.actor_id" placeholder="actor_id" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="动作">
|
||||||
|
<el-input v-model="query.action" placeholder="action" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标类型">
|
||||||
|
<el-input v-model="query.target_type" placeholder="target_type" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="timeRange"
|
||||||
|
type="datetimerange"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
clearable
|
||||||
|
style="width: 360px"
|
||||||
|
@change="applyTimeRange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="load">查询</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
<el-table :data="logs" v-loading="loading" border stripe class="log-table">
|
||||||
|
<el-table-column prop="time" label="时间" min-width="180" />
|
||||||
|
<el-table-column prop="tenant_id" label="租户" min-width="120" />
|
||||||
|
<el-table-column prop="project_id" label="项目" min-width="120" />
|
||||||
|
<el-table-column prop="actor_id" label="操作人" min-width="120" />
|
||||||
|
<el-table-column prop="action" label="动作" min-width="140" />
|
||||||
|
<el-table-column prop="target_type" label="目标类型" min-width="120" />
|
||||||
|
<el-table-column prop="target_id" label="目标 ID" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="client_ip" label="IP" min-width="120" />
|
||||||
|
</el-table>
|
||||||
|
<div class="pager">共 {{ total }} 条</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.page-title { margin: 0; font-size: 18px; }
|
||||||
|
.filter-card { margin-bottom: 16px; }
|
||||||
|
.log-table { margin-top: 8px; }
|
||||||
|
.pager { margin-top: 12px; text-align: right; color: #909399; }
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
import '@/plugins/echarts'
|
import '@/plugins/echarts'
|
||||||
import type { EChartsOption } from 'echarts'
|
import type { EChartsOption } from 'echarts'
|
||||||
|
import { getDashboardStats } from '@/api/modules/dashboard'
|
||||||
|
|
||||||
type ServiceState = 'normal' | 'busy' | 'error'
|
type ServiceState = 'normal' | 'busy' | 'error'
|
||||||
type TaskState = 'running' | 'pending' | 'completed' | 'failed'
|
type TaskState = 'running' | 'pending' | 'completed' | 'failed'
|
||||||
@@ -16,7 +17,7 @@ interface ServiceStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardTask {
|
interface DashboardTask {
|
||||||
id: number
|
id: string
|
||||||
name: string
|
name: string
|
||||||
state: TaskState
|
state: TaskState
|
||||||
trainType: string
|
trainType: string
|
||||||
@@ -43,59 +44,37 @@ interface RecentLoginUser {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const period = ref('7d')
|
const period = ref('7d')
|
||||||
|
|
||||||
const serviceStatuses: ServiceStatus[] = [
|
const onlineServices = ref(0)
|
||||||
{ name: '模型推理', icon: 'fa-cube', state: 'normal', instances: '6 / 6' },
|
const runningTasks = ref(0)
|
||||||
{ name: '模型微调', icon: 'fa-sliders', state: 'busy', instances: '4 / 6' },
|
const pendingAlerts = ref(0)
|
||||||
{ name: '模型评测', icon: 'fa-bar-chart', state: 'normal', instances: '3 / 3' },
|
|
||||||
{ name: '数据处理', icon: 'fa-filter', state: 'error', instances: '1 / 3' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const trainingTasks: DashboardTask[] = [
|
const serviceStatuses = ref<ServiceStatus[]>([])
|
||||||
{
|
const trainingTasks = ref<DashboardTask[]>([])
|
||||||
id: 103942,
|
const loginDurationStats = ref<LoginDurationStat[]>([])
|
||||||
name: 'finance-sft-003',
|
const recentLoginUsers = ref<RecentLoginUser[]>([])
|
||||||
state: 'running',
|
const training7d = ref<{ date: string; train: number; gpu: number; accuracy: number | null }[]>([])
|
||||||
trainType: 'SFT',
|
|
||||||
trainMethod: 'LoRA',
|
const onlineServicesHint = computed(() => {
|
||||||
baseModel: 'Qwen2.5-7B-Instruct',
|
if (onlineServices.value === 0) return '暂无在线服务'
|
||||||
progress: 68,
|
const abnormal = serviceStatuses.value.filter(
|
||||||
accuracy: 89.2,
|
(s) => s.state === 'busy' || s.state === 'error'
|
||||||
startedAt: '今天 09:18',
|
).length
|
||||||
},
|
return abnormal > 0 ? `${abnormal} 个异常` : '全部在线'
|
||||||
{
|
})
|
||||||
id: 593021,
|
const operationDistribution = ref<{ name: string; value: number }[]>([])
|
||||||
name: 'legal-eval-008',
|
|
||||||
state: 'pending',
|
const serviceIcon: Record<string, string> = {
|
||||||
trainType: 'DPO',
|
'模型推理': 'fa-cube',
|
||||||
trainMethod: 'LoRA',
|
'模型微调': 'fa-sliders',
|
||||||
baseModel: 'Qwen2.5-7B-Instruct',
|
'模型评测': 'fa-bar-chart',
|
||||||
progress: 0,
|
'数据处理': 'fa-filter',
|
||||||
accuracy: null,
|
}
|
||||||
startedAt: '今天 08:55',
|
const roleLabel: Record<string, string> = {
|
||||||
},
|
admin: '超级管理员',
|
||||||
{
|
operator: '操作员',
|
||||||
id: 849301,
|
observer: '观察员',
|
||||||
name: 'medical-cpt-002',
|
guest: '访客',
|
||||||
state: 'completed',
|
}
|
||||||
trainType: 'CPT',
|
|
||||||
trainMethod: 'Full',
|
|
||||||
baseModel: 'Qwen2.5-14B-Instruct',
|
|
||||||
progress: 100,
|
|
||||||
accuracy: 91.6,
|
|
||||||
startedAt: '07/10 16:20',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 201948,
|
|
||||||
name: 'finance-sft-002',
|
|
||||||
state: 'failed',
|
|
||||||
trainType: 'SFT',
|
|
||||||
trainMethod: 'LoRA',
|
|
||||||
baseModel: 'Qwen2.5-7B-Instruct',
|
|
||||||
progress: 42,
|
|
||||||
accuracy: null,
|
|
||||||
startedAt: '07/10 11:08',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const serviceStateMeta: Record<ServiceState, { label: string; className: string }> = {
|
const serviceStateMeta: Record<ServiceState, { label: string; className: string }> = {
|
||||||
normal: { label: '正常', className: 'is-normal' },
|
normal: { label: '正常', className: 'is-normal' },
|
||||||
@@ -140,7 +119,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
|||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: ['07/05', '07/06', '07/07', '07/08', '07/09', '07/10', '07/11\n今天'],
|
data: training7d.value.map((d) => d.date),
|
||||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: '#64748b', fontSize: 11, lineHeight: 16, margin: 12 },
|
axisLabel: { color: '#64748b', fontSize: 11, lineHeight: 16, margin: 12 },
|
||||||
@@ -175,7 +154,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
|||||||
{
|
{
|
||||||
name: '训练次数(次)',
|
name: '训练次数(次)',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: [8, 12, 10, 15, 13, 18, 11],
|
data: training7d.value.map((d) => d.train),
|
||||||
barMaxWidth: 16,
|
barMaxWidth: 16,
|
||||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||||
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
||||||
@@ -183,7 +162,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
|||||||
{
|
{
|
||||||
name: 'GPU 使用数(个)',
|
name: 'GPU 使用数(个)',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: [3, 4, 4, 6, 5, 7, 5],
|
data: training7d.value.map((d) => d.gpu),
|
||||||
barMaxWidth: 16,
|
barMaxWidth: 16,
|
||||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||||
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
||||||
@@ -192,7 +171,7 @@ const chartOption = computed<EChartsOption>(() => ({
|
|||||||
name: '平均准确率(%)',
|
name: '平均准确率(%)',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
yAxisIndex: 1,
|
yAxisIndex: 1,
|
||||||
data: [82, 85, 84, 88, 87, 91, 89],
|
data: training7d.value.map((d) => d.accuracy ?? null),
|
||||||
barMaxWidth: 16,
|
barMaxWidth: 16,
|
||||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||||
label: { show: true, position: 'top', color: '#d97706', fontSize: 10 },
|
label: { show: true, position: 'top', color: '#d97706', fontSize: 10 },
|
||||||
@@ -200,58 +179,61 @@ const chartOption = computed<EChartsOption>(() => ({
|
|||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const operationChartOption = computed<EChartsOption>(() => ({
|
// 模块固定配色,保证每个模块颜色不同
|
||||||
animationDuration: 500,
|
const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6']
|
||||||
tooltip: { trigger: 'item' },
|
const operationChartOption = computed<EChartsOption>(() => {
|
||||||
color: ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899'],
|
const items = operationDistribution.value
|
||||||
series: [
|
const total = items.reduce((s, d) => s + (d.value || 0), 0)
|
||||||
{
|
// 完全没有操作数据时,用等分灰色占位扇区,保证 6 个模块都可见
|
||||||
name: '操作分类',
|
const data =
|
||||||
type: 'pie',
|
total > 0
|
||||||
radius: ['40%', '64%'],
|
? items.map((d) => ({ value: d.value || 0, name: d.name }))
|
||||||
center: ['50%', '50%'],
|
: items.map((d) => ({ value: 1, name: d.name, itemStyle: { color: '#e2e8f0' } }))
|
||||||
avoidLabelOverlap: true,
|
return {
|
||||||
itemStyle: {
|
animationDuration: 500,
|
||||||
borderRadius: 6,
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
borderColor: '#fff',
|
color: OPERATION_COLORS,
|
||||||
borderWidth: 2
|
legend: {
|
||||||
|
type: 'scroll',
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: { color: '#64748b', fontSize: 11 },
|
||||||
|
itemWidth: 10,
|
||||||
|
itemHeight: 10,
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '操作分类',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['38%', '60%'],
|
||||||
|
center: ['50%', '42%'],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 6,
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'outside',
|
||||||
|
formatter: '{b}\n{d}%',
|
||||||
|
color: '#475569',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 15,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' },
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: true,
|
||||||
|
length: 8,
|
||||||
|
length2: 8,
|
||||||
|
lineStyle: { color: '#94a3b8', width: 1 },
|
||||||
|
},
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
label: {
|
],
|
||||||
show: true,
|
}
|
||||||
position: 'outside',
|
})
|
||||||
formatter: '{b}',
|
|
||||||
color: '#475569',
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 16,
|
|
||||||
width: 70,
|
|
||||||
overflow: 'truncate',
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' }
|
|
||||||
},
|
|
||||||
labelLine: {
|
|
||||||
show: true,
|
|
||||||
length: 10,
|
|
||||||
length2: 8,
|
|
||||||
lineStyle: { color: '#94a3b8', width: 1 },
|
|
||||||
},
|
|
||||||
data: [
|
|
||||||
{ value: 1048, name: '模型训练' },
|
|
||||||
{ value: 735, name: '数据处理' },
|
|
||||||
{ value: 580, name: '模型评测' },
|
|
||||||
{ value: 484, name: '模型推理' },
|
|
||||||
{ value: 300, name: '系统设置' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
|
|
||||||
const loginDurationStats: LoginDurationStat[] = [
|
|
||||||
{ id: 1, username: 'admin', duration: 124 },
|
|
||||||
{ id: 2, username: 'zhangsan', duration: 86 },
|
|
||||||
{ id: 3, username: 'lisi', duration: 42 },
|
|
||||||
{ id: 4, username: 'wangwu', duration: 18 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const loginDurationChartOption = computed<EChartsOption>(() => ({
|
const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||||
animationDuration: 500,
|
animationDuration: 500,
|
||||||
@@ -263,7 +245,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
|||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10) * 10,
|
max: Math.max(10, Math.ceil(Math.max(...loginDurationStats.value.map((user) => user.duration), 0) * 1.15 / 10) * 10),
|
||||||
splitNumber: 4,
|
splitNumber: 4,
|
||||||
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
|
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
@@ -273,7 +255,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
|||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
inverse: true,
|
inverse: true,
|
||||||
data: loginDurationStats.map((user) => user.username),
|
data: loginDurationStats.value.map((user) => user.username),
|
||||||
axisLabel: { color: '#475569', fontSize: 12 },
|
axisLabel: { color: '#475569', fontSize: 12 },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
@@ -282,7 +264,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
|||||||
{
|
{
|
||||||
name: '登录时长',
|
name: '登录时长',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: loginDurationStats.map((user) => user.duration),
|
data: loginDurationStats.value.map((user) => user.duration),
|
||||||
barMaxWidth: 18,
|
barMaxWidth: 18,
|
||||||
barCategoryGap: '34%',
|
barCategoryGap: '34%',
|
||||||
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
|
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
|
||||||
@@ -291,19 +273,51 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
|||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const recentLoginUsers: RecentLoginUser[] = [
|
|
||||||
{ id: 1, username: 'admin', role: '超级管理员', lastLogin: '10 分钟前' },
|
|
||||||
{ id: 2, username: 'zhangsan', role: '操作员', lastLogin: '2 小时前' },
|
|
||||||
{ id: 5, username: 'zhaoliu', role: '观察员', lastLogin: '5 小时前' },
|
|
||||||
{ id: 3, username: 'lisi', role: '操作员', lastLogin: '昨天 15:30' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const roleTagType: Record<string, 'danger' | 'primary' | 'info'> = {
|
const roleTagType: Record<string, 'danger' | 'primary' | 'info'> = {
|
||||||
'超级管理员': 'danger',
|
'超级管理员': 'danger',
|
||||||
'操作员': 'primary',
|
'操作员': 'primary',
|
||||||
'观察员': 'info',
|
'观察员': 'info',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const stats = await getDashboardStats()
|
||||||
|
onlineServices.value = stats.online_services
|
||||||
|
runningTasks.value = stats.running_tasks
|
||||||
|
pendingAlerts.value = stats.pending_alerts
|
||||||
|
serviceStatuses.value = stats.service_status.map((s) => ({
|
||||||
|
name: s.type,
|
||||||
|
icon: serviceIcon[s.type] || 'fa-cube',
|
||||||
|
state: s.status as ServiceState,
|
||||||
|
instances: String(s.count),
|
||||||
|
}))
|
||||||
|
trainingTasks.value = stats.training_tasks.map((t) => ({
|
||||||
|
id: String(t.id),
|
||||||
|
name: t.name,
|
||||||
|
state: t.status as TaskState,
|
||||||
|
trainType: t.train_type,
|
||||||
|
trainMethod: t.train_method,
|
||||||
|
baseModel: t.base_model,
|
||||||
|
progress: t.progress,
|
||||||
|
accuracy: t.accuracy,
|
||||||
|
startedAt: t.started_at,
|
||||||
|
}))
|
||||||
|
loginDurationStats.value = stats.login_duration_rank.map((u, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
username: u.user,
|
||||||
|
duration: u.duration,
|
||||||
|
}))
|
||||||
|
recentLoginUsers.value = stats.recent_login_users.map((u, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
username: u.user,
|
||||||
|
role: roleLabel[u.role] || u.role,
|
||||||
|
lastLogin: u.last_login,
|
||||||
|
}))
|
||||||
|
training7d.value = stats.training_7d
|
||||||
|
operationDistribution.value = stats.operation_distribution
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadStats)
|
||||||
|
|
||||||
function viewAllTasks() {
|
function viewAllTasks() {
|
||||||
router.push('/fine-tune')
|
router.push('/fine-tune')
|
||||||
}
|
}
|
||||||
@@ -329,17 +343,17 @@ function viewTask(task: DashboardTask) {
|
|||||||
<div class="overview-metrics">
|
<div class="overview-metrics">
|
||||||
<div class="overview-metric">
|
<div class="overview-metric">
|
||||||
<span>在线服务</span>
|
<span>在线服务</span>
|
||||||
<strong>12</strong>
|
<strong>{{ onlineServices }}</strong>
|
||||||
<small>全部在线</small>
|
<small>{{ onlineServicesHint }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-metric">
|
<div class="overview-metric">
|
||||||
<span>运行中任务</span>
|
<span>运行中任务</span>
|
||||||
<strong>5</strong>
|
<strong>{{ runningTasks }}</strong>
|
||||||
<small>较昨日 +1</small>
|
<small>较昨日 +1</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-metric is-alert">
|
<div class="overview-metric is-alert">
|
||||||
<span>待处理告警</span>
|
<span>待处理告警</span>
|
||||||
<strong>2</strong>
|
<strong>{{ pendingAlerts }}</strong>
|
||||||
<small>较昨日 -1</small>
|
<small>较昨日 -1</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +405,10 @@ function viewTask(task: DashboardTask) {
|
|||||||
|
|
||||||
<section class="stat-card" aria-labelledby="login-dur-title">
|
<section class="stat-card" aria-labelledby="login-dur-title">
|
||||||
<h2 id="login-dur-title" class="section-title">登录时长排行 (本月)</h2>
|
<h2 id="login-dur-title" class="section-title">登录时长排行 (本月)</h2>
|
||||||
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
|
<div v-if="loginDurationStats.length" class="chart-container">
|
||||||
|
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-hint">暂无数据</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="stat-card" aria-labelledby="recent-login-title">
|
<section class="stat-card" aria-labelledby="recent-login-title">
|
||||||
@@ -515,6 +532,15 @@ function viewTask(task: DashboardTask) {
|
|||||||
height: 224px;
|
height: 224px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 224px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.duration-chart {
|
.duration-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 224px;
|
height: 224px;
|
||||||
|
|||||||
129
frontend/src/views/projects/ProjectDetailView.vue
Normal file
129
frontend/src/views/projects/ProjectDetailView.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import DataTablePage from '@/components/DataTablePage.vue'
|
||||||
|
import AclDialog from '@/components/AclDialog.vue'
|
||||||
|
import { getProject, getProjectMembers, addProjectMember, removeProjectMember, type Project, type ProjectMember } from '@/api/modules/project'
|
||||||
|
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const project = ref<Project | null>(null)
|
||||||
|
const members = ref<ProjectMember[]>([])
|
||||||
|
const users = ref<SystemUser[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const aclVisible = ref(false)
|
||||||
|
const showAddMember = ref(false)
|
||||||
|
const addForm = ref({ user_id: '', role: 'member' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const id = route.params.id as string
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
project.value = await getProject(id)
|
||||||
|
members.value = await getProjectMembers(id)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
users.value = await getUsers()
|
||||||
|
} catch {
|
||||||
|
users.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddMember() {
|
||||||
|
if (!project.value) return
|
||||||
|
if (!addForm.value.user_id) {
|
||||||
|
ElMessage.warning('请选择用户')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await addProjectMember(project.value.id, { ...addForm.value })
|
||||||
|
ElMessage.success('成员已添加')
|
||||||
|
showAddMember.value = false
|
||||||
|
addForm.value = { user_id: '', role: 'member' }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(userId: string) {
|
||||||
|
if (!project.value) return
|
||||||
|
await removeProjectMember(project.value.id, userId)
|
||||||
|
ElMessage.success('已移除成员')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers()
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<el-page-header title="返回" @back="router.back()">
|
||||||
|
<template #content>
|
||||||
|
<span class="page-title">项目详情:{{ project?.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-page-header>
|
||||||
|
<el-card class="section" v-loading="loading">
|
||||||
|
<template #header>基本信息</template>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="名称">{{ project?.name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="编码">{{ project?.code }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">{{ project?.status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="租户">{{ project?.tenant_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="描述" :span="2">{{ project?.description }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-divider />
|
||||||
|
<el-button @click="aclVisible = true">资源授权 (ACL)</el-button>
|
||||||
|
</el-card>
|
||||||
|
<el-card class="section">
|
||||||
|
<template #header>
|
||||||
|
项目成员
|
||||||
|
<el-button type="primary" size="small" style="float: right" @click="showAddMember = true">添加成员</el-button>
|
||||||
|
</template>
|
||||||
|
<DataTablePage title="项目成员" :data="members">
|
||||||
|
<template #columns>
|
||||||
|
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||||
|
<el-table-column prop="display_name" label="显示名" min-width="120" />
|
||||||
|
<el-table-column prop="role" label="角色" min-width="100" />
|
||||||
|
<el-table-column prop="create_time" label="加入时间" min-width="180" />
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<el-button link type="danger" @click="removeMember(row.user_id)">移除</el-button>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
</el-card>
|
||||||
|
<AclDialog v-model="aclVisible" resource-type="project" :resource-id="(route.params.id as string)" />
|
||||||
|
<el-dialog v-model="showAddMember" title="添加成员" width="420px">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="用户" required>
|
||||||
|
<el-select v-model="addForm.user_id" filterable style="width: 100%">
|
||||||
|
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-select v-model="addForm.role" style="width: 100%">
|
||||||
|
<el-option label="member" value="member" />
|
||||||
|
<el-option label="admin" value="admin" />
|
||||||
|
<el-option label="viewer" value="viewer" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAddMember = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitAddMember">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.section { margin-top: 16px; }
|
||||||
|
.page-title { font-size: 16px; font-weight: 600; }
|
||||||
|
</style>
|
||||||
109
frontend/src/views/projects/ProjectListView.vue
Normal file
109
frontend/src/views/projects/ProjectListView.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } 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 { getTenants, type Tenant } from '@/api/modules/tenant'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
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 tenantOptions = computed(() => [
|
||||||
|
{ label: 'default', value: 'default' },
|
||||||
|
...tenants.value.map((t) => ({ label: t.name, value: t.id })),
|
||||||
|
])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
projects.value = await getProjects(tenantId.value)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTenants() {
|
||||||
|
try {
|
||||||
|
tenants.value = await getTenants()
|
||||||
|
} catch {
|
||||||
|
tenants.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(id: string) {
|
||||||
|
router.push(`/projects/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (!form.value.name || !form.value.code) {
|
||||||
|
ElMessage.warning('请填写项目名与编码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await createProject({ ...form.value })
|
||||||
|
ElMessage.success('项目创建成功')
|
||||||
|
showCreate.value = false
|
||||||
|
form.value = { name: '', code: '', description: '', tenant_id: 'default' }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTenants()
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<DataTablePage title="项目空间" :data="projects" :loading="loading" searchable search-fields="name,code">
|
||||||
|
<template #toolbar-extra>
|
||||||
|
<el-select v-model="tenantId" placeholder="租户" style="width: 160px" @change="load">
|
||||||
|
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建项目</el-button>
|
||||||
|
</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="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(row.id)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
<el-dialog v-model="showCreate" title="新建项目" width="520px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<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-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page { padding: 16px; }
|
||||||
|
</style>
|
||||||
@@ -26,6 +26,7 @@ const trainContent = ref('')
|
|||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
const level = ref('') // 日志级别筛选:INFO/WARN/ERROR/空=全部
|
||||||
const fullContent = ref('')
|
const fullContent = ref('')
|
||||||
|
|
||||||
// 自动刷新
|
// 自动刷新
|
||||||
@@ -33,11 +34,16 @@ const refreshInterval = ref(10)
|
|||||||
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
|
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
|
||||||
|
|
||||||
const filteredLog = computed(() => {
|
const filteredLog = computed(() => {
|
||||||
if (!keyword.value.trim()) return { content: fullContent.value, count: 0 }
|
let lines = fullContent.value.split('\n')
|
||||||
const kw = keyword.value.toLowerCase().trim()
|
// 级别筛选
|
||||||
const lines = fullContent.value
|
if (level.value) {
|
||||||
.split('\n')
|
lines = lines.filter((line) => line.toUpperCase().includes(level.value.toUpperCase()))
|
||||||
.filter((line) => line.toLowerCase().includes(kw))
|
}
|
||||||
|
// 关键词筛选
|
||||||
|
if (keyword.value.trim()) {
|
||||||
|
const kw = keyword.value.toLowerCase().trim()
|
||||||
|
lines = lines.filter((line) => line.toLowerCase().includes(kw))
|
||||||
|
}
|
||||||
return { content: lines.join('\n'), count: lines.length }
|
return { content: lines.join('\n'), count: lines.length }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -176,12 +182,19 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 日志内容 -->
|
<!-- 日志内容 -->
|
||||||
<div class="log-content-box">
|
<div class="log-content-box">
|
||||||
<div class="log-toolbar">
|
<div class="log-toolbar">
|
||||||
<el-input v-model="keyword" placeholder="搜索日志..." size="small" clearable style="width: 240px">
|
<el-input v-model="keyword" placeholder="搜索日志..." size="small" clearable style="width: 240px">
|
||||||
<template #prefix><i class="fa fa-search" /></template>
|
<template #prefix><i class="fa fa-search" /></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<span v-if="keyword" class="match-count">{{ matchCount }} 条匹配</span>
|
<el-select v-model="level" placeholder="日志级别" size="small" clearable style="width: 120px">
|
||||||
</div>
|
<el-option value="" label="全部级别" />
|
||||||
|
<el-option value="INFO" label="INFO" />
|
||||||
|
<el-option value="WARN" label="WARN" />
|
||||||
|
<el-option value="ERROR" label="ERROR" />
|
||||||
|
<el-option value="DEBUG" label="DEBUG" />
|
||||||
|
</el-select>
|
||||||
|
<span v-if="keyword || level" class="match-count">{{ matchCount }} 条匹配</span>
|
||||||
|
</div>
|
||||||
<pre class="log-pre">{{ filteredContent || (activeTab === 'system' ? sysContent : trainContent) || '日志内容将在这里显示...' }}</pre>
|
<pre class="log-pre">{{ filteredContent || (activeTab === 'system' ? sysContent : trainContent) || '日志内容将在这里显示...' }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</PageCard>
|
</PageCard>
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { getUsers } from '@/api/modules/system'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { SystemUser } from '@/types'
|
import {
|
||||||
|
deleteUser,
|
||||||
|
getUsers,
|
||||||
|
resetUserPassword,
|
||||||
|
updateUserAccess,
|
||||||
|
} from '@/api/modules/system'
|
||||||
|
import type { PermissionCode, SystemUser, UserStatus } from '@/types'
|
||||||
import { statusLabel, statusTagType } from '@/utils/status'
|
import { statusLabel, statusTagType } from '@/utils/status'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const users = ref<SystemUser[]>([])
|
const users = ref<SystemUser[]>([])
|
||||||
|
|
||||||
|
// 权限码 -> 中文名(与路由模块一一对应)
|
||||||
|
const PERMISSION_LABELS: Record<PermissionCode, string> = {
|
||||||
|
dashboard: '服务看板',
|
||||||
|
'fine-tune': '模型训练',
|
||||||
|
'model-eval': '模型评测',
|
||||||
|
'model-inference': '模型推理',
|
||||||
|
'model-manage': '模型管理',
|
||||||
|
dataset: '数据集管理',
|
||||||
|
'data-process': '数据处理',
|
||||||
|
'data-convert': '数据转换',
|
||||||
|
compute: '计算资源',
|
||||||
|
hardware: '硬件监控',
|
||||||
|
logs: '日志中心',
|
||||||
|
'user-settings': '用户与权限',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_PERMISSIONS = Object.keys(PERMISSION_LABELS) as PermissionCode[]
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -17,6 +41,109 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
|
|
||||||
|
// 当前登录用户,用于禁止操作自身(避免误锁自己)
|
||||||
|
const currentUsername = ref<string>('')
|
||||||
|
try {
|
||||||
|
currentUsername.value = JSON.parse(localStorage.getItem('currentUser') || '{}').username || ''
|
||||||
|
} catch {
|
||||||
|
currentUsername.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelf(row: SystemUser) {
|
||||||
|
return row.username === currentUsername.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 启停 ----------
|
||||||
|
async function toggleStatus(row: SystemUser, next: boolean) {
|
||||||
|
const nextStatus: UserStatus = next ? 'active' : 'disabled'
|
||||||
|
const prev = row.status
|
||||||
|
row.status = nextStatus
|
||||||
|
try {
|
||||||
|
await updateUserAccess(row.id, { status: nextStatus })
|
||||||
|
ElMessage.success(`${row.display_name} 已${next ? '启用' : '停用'}`)
|
||||||
|
await loadUsers()
|
||||||
|
} catch {
|
||||||
|
row.status = prev
|
||||||
|
ElMessage.error('状态更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重置密码 ----------
|
||||||
|
const pwdDialog = reactive({ visible: false, id: '', name: '', password: '', saving: false })
|
||||||
|
function openResetPwd(row: SystemUser) {
|
||||||
|
pwdDialog.id = row.id
|
||||||
|
pwdDialog.name = row.display_name
|
||||||
|
pwdDialog.password = 'Platform@123'
|
||||||
|
pwdDialog.visible = true
|
||||||
|
}
|
||||||
|
async function confirmResetPwd() {
|
||||||
|
if (!pwdDialog.password.trim()) {
|
||||||
|
ElMessage.warning('请输入新密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pwdDialog.saving = true
|
||||||
|
try {
|
||||||
|
await resetUserPassword(pwdDialog.id, pwdDialog.password.trim())
|
||||||
|
ElMessage.success(`已重置 ${pwdDialog.name} 的密码`)
|
||||||
|
pwdDialog.visible = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('重置密码失败')
|
||||||
|
} finally {
|
||||||
|
pwdDialog.saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 页面权限 ----------
|
||||||
|
const permDialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
checked: [] as PermissionCode[],
|
||||||
|
saving: false,
|
||||||
|
})
|
||||||
|
function openPerms(row: SystemUser) {
|
||||||
|
permDialog.id = row.id
|
||||||
|
permDialog.name = row.display_name
|
||||||
|
permDialog.checked = [...(row.permissions || [])]
|
||||||
|
permDialog.visible = true
|
||||||
|
}
|
||||||
|
async function confirmPerms() {
|
||||||
|
permDialog.saving = true
|
||||||
|
try {
|
||||||
|
await updateUserAccess(permDialog.id, { permissions: permDialog.checked })
|
||||||
|
ElMessage.success(`已更新 ${permDialog.name} 的页面权限`)
|
||||||
|
permDialog.visible = false
|
||||||
|
await loadUsers()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('权限更新失败')
|
||||||
|
} finally {
|
||||||
|
permDialog.saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const permColumns = computed(() => ALL_PERMISSIONS)
|
||||||
|
|
||||||
|
// ---------- 删除 ----------
|
||||||
|
async function removeUser(row: SystemUser) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除用户 “${row.display_name}(${row.username})” 吗?该操作不可恢复。`,
|
||||||
|
'删除用户',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteUser(row.id)
|
||||||
|
ElMessage.success(`已删除 ${row.display_name}`)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || '删除失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,25 +151,93 @@ onMounted(loadUsers)
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>用户设置</h1>
|
<h1>用户设置</h1>
|
||||||
<p>管理平台账号、角色状态和页面权限。</p>
|
<p>管理平台账号、角色状态、登录密码与页面权限。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<el-table :data="users">
|
<el-table :data="users" border>
|
||||||
<el-table-column prop="username" label="账号" min-width="140" />
|
<el-table-column prop="username" label="账号" min-width="140" />
|
||||||
<el-table-column prop="display_name" label="显示名称" min-width="160" />
|
<el-table-column prop="display_name" label="显示名称" min-width="160" />
|
||||||
<el-table-column prop="role" label="角色" width="120" />
|
<el-table-column prop="role" label="角色" width="120" />
|
||||||
<el-table-column label="状态" width="120">
|
<el-table-column label="状态" width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="权限数" width="120">
|
<el-table-column label="页面权限" min-width="160">
|
||||||
<template #default="{ row }">{{ row.permissions?.length || 0 }}</template>
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
v-for="p in (row.permissions || []).slice(0, 3)"
|
||||||
|
:key="p"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
class="perm-tag"
|
||||||
|
>{{ PERMISSION_LABELS[p] || p }}</el-tag>
|
||||||
|
<span v-if="(row.permissions || []).length > 3" class="perm-more">
|
||||||
|
+{{ (row.permissions || []).length - 3 }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!(row.permissions || []).length" class="perm-more">无</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.status === 'active'"
|
||||||
|
:disabled="row.protected || isSelf(row)"
|
||||||
|
@change="(v: any) => toggleStatus(row, v)"
|
||||||
|
inline-prompt
|
||||||
|
active-text="启用"
|
||||||
|
inactive-text="停用"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:disabled="row.protected"
|
||||||
|
@click="openResetPwd(row)"
|
||||||
|
>重置密码</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openPerms(row)"
|
||||||
|
>页面权限</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="row.protected || isSelf(row)"
|
||||||
|
@click="removeUser(row)"
|
||||||
|
>删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 重置密码 -->
|
||||||
|
<el-dialog v-model="pwdDialog.visible" title="重置密码" width="420px">
|
||||||
|
<p class="dlg-tip">为 <b>{{ pwdDialog.name }}</b> 设置新密码:</p>
|
||||||
|
<el-input v-model="pwdDialog.password" placeholder="请输入新密码" show-password />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="pwdDialog.visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="pwdDialog.saving" @click="confirmResetPwd">确定重置</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 页面权限 -->
|
||||||
|
<el-dialog v-model="permDialog.visible" title="页面权限" width="540px">
|
||||||
|
<p class="dlg-tip">为 <b>{{ permDialog.name }}</b> 分配可访问的页面模块:</p>
|
||||||
|
<el-checkbox-group v-model="permDialog.checked" class="perm-group">
|
||||||
|
<el-checkbox
|
||||||
|
v-for="code in permColumns"
|
||||||
|
:key="code"
|
||||||
|
:value="code"
|
||||||
|
:label="PERMISSION_LABELS[code]"
|
||||||
|
/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="permDialog.visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -67,4 +262,25 @@ onMounted(loadUsers)
|
|||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.perm-tag {
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-more {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg-tip {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
92
frontend/src/views/tenants/TenantDetailView.vue
Normal file
92
frontend/src/views/tenants/TenantDetailView.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, 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('')
|
||||||
|
|
||||||
|
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 || {})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQuota() {
|
||||||
|
if (!tenant.value) return
|
||||||
|
try {
|
||||||
|
const q = JSON.parse(quotaText.value || '{}')
|
||||||
|
await setTenantQuota(tenant.value.id, q)
|
||||||
|
ElMessage.success('配额已保存')
|
||||||
|
load()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('配额需为合法 JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProject(id: string) {
|
||||||
|
router.push(`/projects/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<el-page-header title="返回" @back="router.back()">
|
||||||
|
<template #content>
|
||||||
|
<span class="page-title">租户详情:{{ tenant?.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-page-header>
|
||||||
|
<el-card class="section" v-loading="loading">
|
||||||
|
<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="状态">{{ tenant?.status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ tenant?.create_time }}</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" />
|
||||||
|
<el-button type="primary" @click="saveQuota">保存配额</el-button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.section { margin-top: 16px; }
|
||||||
|
.page-title { font-size: 16px; font-weight: 600; }
|
||||||
|
.quota-edit { display: flex; flex-direction: column; gap: 12px; max-width: 480px; }
|
||||||
|
.label { font-size: 13px; color: #606266; }
|
||||||
|
</style>
|
||||||
111
frontend/src/views/tenants/TenantListView.vue
Normal file
111
frontend/src/views/tenants/TenantListView.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, 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'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const tenants = ref<Tenant[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const form = ref({ name: '', code: '', quota: '' as string })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
tenants.value = await getTenants()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(id: string) {
|
||||||
|
router.push(`/tenants/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (!form.value.name) {
|
||||||
|
ElMessage.warning('请填写租户名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let quota: Record<string, unknown> = {}
|
||||||
|
if (form.value.quota) {
|
||||||
|
try {
|
||||||
|
quota = JSON.parse(form.value.quota)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('配额需为合法 JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await createTenant({ name: form.value.name, code: form.value.code, quota })
|
||||||
|
ElMessage.success('租户创建成功')
|
||||||
|
showCreate.value = false
|
||||||
|
form.value = { name: '', code: '', quota: '' }
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<DataTablePage title="租户管理" :data="tenants" :loading="loading">
|
||||||
|
<template #toolbar-extra>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建租户</el-button>
|
||||||
|
</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 }">
|
||||||
|
{{ Object.keys(row.quota || {}).length ? JSON.stringify(row.quota) : '—' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<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(row.id)">详情</el-button>
|
||||||
|
<el-button link type="primary" @click="setQuota(row)">配额</el-button>
|
||||||
|
</template>
|
||||||
|
</DataTablePage>
|
||||||
|
<el-dialog v-model="showCreate" title="新建租户" width="520px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<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>
|
||||||
|
<el-form-item label="配额 JSON">
|
||||||
|
<el-input v-model="form.quota" type="textarea" :rows="3" placeholder='{"gpu": 8}' />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page { padding: 16px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user