更新平台治理
This commit is contained in:
@@ -178,8 +178,8 @@ def filter_accessible_resource_ids(
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
accessible = {r["resource_id"] for r in rows}
|
accessible = {r["resource_id"] for r in rows}
|
||||||
if resource_type in owner_tables:
|
if resource_type in OWNER_TABLES:
|
||||||
table, column = owner_tables[resource_type]
|
table, column = OWNER_TABLES[resource_type]
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
owned = conn.execute(f"SELECT id FROM {table} WHERE {column}=?", (user_id,)).fetchall()
|
owned = conn.execute(f"SELECT id FROM {table} WHERE {column}=?", (user_id,)).fetchall()
|
||||||
accessible.update(row["id"] for row in owned)
|
accessible.update(row["id"] for row in owned)
|
||||||
|
|||||||
@@ -1343,6 +1343,8 @@ class PlatformStore:
|
|||||||
raise ValueError("protected user cannot be deleted")
|
raise ValueError("protected user cannot be deleted")
|
||||||
# 级联删除该用户关联的数据
|
# 级联删除该用户关联的数据
|
||||||
tables_to_clean = [
|
tables_to_clean = [
|
||||||
|
# 登录会话(避免残留 session 导致统计显示 user_id)
|
||||||
|
("sessions", "user_id=?", [user_id]),
|
||||||
# ACL 授权
|
# ACL 授权
|
||||||
("acls", "principal_type='user' AND principal_id=?", [user_id]),
|
("acls", "principal_type='user' AND principal_id=?", [user_id]),
|
||||||
# 审批实例(申请人)
|
# 审批实例(申请人)
|
||||||
@@ -3755,12 +3757,16 @@ class PlatformStore:
|
|||||||
|
|
||||||
sessions 表列:login_at(TEXT), logout_at(TEXT), duration_seconds(INT)。
|
sessions 表列:login_at(TEXT), logout_at(TEXT), duration_seconds(INT)。
|
||||||
优先用 duration_seconds;为空时回退计算 now-login_at(未登出)或 logout_at-login_at。
|
优先用 duration_seconds;为空时回退计算 now-login_at(未登出)或 logout_at-login_at。
|
||||||
|
|
||||||
|
注意:使用 INNER JOIN 只统计仍存在于 users 表中的用户,
|
||||||
|
避免已删除用户的残留 session 记录导致显示 user_id(如 u_xxxx)。
|
||||||
"""
|
"""
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
# 使用 INNER JOIN 而非 LEFT JOIN,确保只统计仍然存在的用户
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT s.user_id, s.login_at, s.logout_at, s.duration_seconds, "
|
"SELECT s.user_id, s.login_at, s.logout_at, s.duration_seconds, "
|
||||||
"u.username, u.display_name, u.role "
|
"u.username, u.display_name, u.role "
|
||||||
"FROM sessions s LEFT JOIN users u ON s.user_id = u.id "
|
"FROM sessions s INNER JOIN users u ON s.user_id = u.id "
|
||||||
"WHERE s.login_at::timestamptz >= NOW() - make_interval(days => %s)",
|
"WHERE s.login_at::timestamptz >= NOW() - make_interval(days => %s)",
|
||||||
(days,),
|
(days,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -3768,10 +3774,15 @@ class PlatformStore:
|
|||||||
agg: dict[str, dict[str, Any]] = {}
|
agg: dict[str, dict[str, Any]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
uid = r["user_id"] or ""
|
uid = r["user_id"] or ""
|
||||||
|
# 优先使用 display_name,其次 username,最后才回退到 user_id
|
||||||
|
display = r["display_name"] or r["username"] or uid
|
||||||
|
# 如果回退到了 user_id 格式(说明用户信息不完整),标记为"未知用户"
|
||||||
|
if display == uid and display.startswith("u_") and len(display) > 10:
|
||||||
|
display = "(已删除用户)"
|
||||||
bucket = agg.setdefault(
|
bucket = agg.setdefault(
|
||||||
uid,
|
uid,
|
||||||
{
|
{
|
||||||
"user": r["display_name"] or r["username"] or uid,
|
"user": display,
|
||||||
"role": r["role"] or "",
|
"role": r["role"] or "",
|
||||||
"total": 0.0,
|
"total": 0.0,
|
||||||
},
|
},
|
||||||
@@ -4182,8 +4193,8 @@ class PlatformStore:
|
|||||||
bucket = grouped.setdefault(
|
bucket = grouped.setdefault(
|
||||||
key,
|
key,
|
||||||
{
|
{
|
||||||
"subject_type": r.get("principal_type"),
|
"principal_type": r.get("principal_type"),
|
||||||
"subject_id": r.get("principal_id"),
|
"principal_id": r.get("principal_id"),
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -4201,8 +4212,8 @@ class PlatformStore:
|
|||||||
for perm in e.get("permissions") or []:
|
for perm in e.get("permissions") or []:
|
||||||
flat.append(
|
flat.append(
|
||||||
{
|
{
|
||||||
"principal_type": e.get("subject_type"),
|
"principal_type": e.get("principal_type") or e.get("subject_type"),
|
||||||
"principal_id": e.get("subject_id"),
|
"principal_id": e.get("principal_id") or e.get("subject_id"),
|
||||||
"permission": perm,
|
"permission": perm,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def update_tenant(tenant_id: str, payload: dict[str, Any] = Body(...), request:
|
|||||||
def set_quota(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
def set_quota(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||||
store = get_platform_store()
|
store = get_platform_store()
|
||||||
try:
|
try:
|
||||||
tenant = store.set_tenant_quota(tenant_id, payload.get("quota", {}))
|
tenant = store.set_tenant_quota(tenant_id, payload)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise fail(404, "tenant not found")
|
raise fail(404, "tenant not found")
|
||||||
store.record_audit(
|
store.record_audit(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# 平台治理功能使用指南
|
# 平台治理功能使用指南
|
||||||
|
|
||||||
> 版本:v1.0
|
> 版本:v1.1
|
||||||
> 日期:2026-08-10
|
> 日期:2026-08-13
|
||||||
> 适用版本:YG Fine-Tune Platform v1.0+
|
> 适用版本:YG Fine-Tune Platform v1.0+
|
||||||
|
> 更新说明:移除页面权限码设计,改为基于角色的简化权限模型
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,25 +40,52 @@
|
|||||||
|
|
||||||
### 1.3 入口在哪里?
|
### 1.3 入口在哪里?
|
||||||
|
|
||||||
所有治理功能集中在左侧导航栏的 **「系统设置」** 分组下:
|
所有治理功能集中在左侧导航栏的 **「系统设置」** 和 **「平台治理」** 分组下:
|
||||||
|
|
||||||
```
|
```
|
||||||
系统设置
|
系统设置
|
||||||
├── 用户设置 ← 用户 CRUD + 角色权限 + 密码管理
|
├── 用户设置 ← 用户 CRUD + 角色权限 + 密码管理(仅 admin)
|
||||||
├── 租户管理 ← 组织/团队(可选)
|
├── 平台性能 ← 系统监控
|
||||||
├── 项目空间 ← 项目级资源隔离(可选)
|
└── 查看日志 ← 日志查看
|
||||||
├── 审批模板 ← 定义哪些操作需要审批
|
|
||||||
├── 审批中心 ← 处理待审批请求
|
平台治理
|
||||||
└── 审计日志 ← 查看所有操作记录
|
├── 租户管理 ← 组织/团队(仅 admin)
|
||||||
|
├── 项目空间 ← 项目级资源隔离(仅 admin)
|
||||||
|
├── 审批模板 ← 定义哪些操作需要审批(仅 admin)
|
||||||
|
├── 审批中心 ← 处理待审批请求(仅 admin)
|
||||||
|
└── 审计日志 ← 查看所有操作记录(仅 admin)
|
||||||
|
|
||||||
|
算力资源
|
||||||
|
└── 算力节点 ← GPU 分配与管理(仅 admin)
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ 以上菜单**只有 admin 用户能看到**。普通用户登录后不会出现这些入口。
|
> ⚠️ 以上菜单**只有 admin 用户能看到**。普通用户登录后不会出现这些入口。
|
||||||
|
>
|
||||||
|
> **重要变更(v1.1)**:非 admin 用户**默认可以访问所有业务功能菜单**(模型训练、评测、推理、数据集、数据处理等),无需管理员单独分配权限。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 用户与权限管理
|
## 2. 用户与权限管理
|
||||||
|
|
||||||
### 2.1 创建用户
|
### 2.1 权限模型概述(v1.1 更新)
|
||||||
|
|
||||||
|
平台采用**基于角色的简化权限模型**:
|
||||||
|
|
||||||
|
| 用户类型 | 可见菜单 | 说明 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| **admin(管理员)** | **全部菜单** | 包括用户设置、平台治理、算力节点等管理功能 |
|
||||||
|
| **非 admin 用户** | **除管理功能外的所有业务菜单** | 模型训练/评测/推理、数据集、数据处理、日志等 |
|
||||||
|
|
||||||
|
> **核心原则**:
|
||||||
|
> - 非 admin 用户**默认拥有所有业务功能的访问权限**,无需单独分配
|
||||||
|
> - 仅以下功能**仅管理员可见**:
|
||||||
|
> - `用户设置`(用户 CRUD、角色管理)
|
||||||
|
> - `平台治理`(租户管理、项目空间、审批模板/中心、审计日志)
|
||||||
|
> - `算力节点`(GPU 分配)
|
||||||
|
>
|
||||||
|
> 资源级别的访问控制通过 **ACL(访问控制列表)** 实现,详见第 4 章。
|
||||||
|
|
||||||
|
### 2.2 创建用户
|
||||||
|
|
||||||
**路径**:`用户设置` → `创建用户`
|
**路径**:`用户设置` → `创建用户`
|
||||||
|
|
||||||
@@ -71,42 +99,23 @@
|
|||||||
- **角色**:选择 `admin` / `operator` / `viewer`
|
- **角色**:选择 `admin` / `operator` / `viewer`
|
||||||
5. 点击保存
|
5. 点击保存
|
||||||
|
|
||||||
创建后用户可以立即用该账号登录。
|
创建后用户可以立即用该账号登录,**无需额外分配页面权限**。
|
||||||
|
|
||||||
### 2.2 管理用户权限
|
### 2.3 管理员专属功能
|
||||||
|
|
||||||
**路径**:`用户设置` → 用户列表 → 操作列「页面权限」
|
以下功能**仅 admin 角色可见**,对其他用户隐藏:
|
||||||
|
|
||||||
#### 给普通用户分配业务模块权限
|
| 功能分组 | 包含菜单 | 路由前缀 |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| 系统设置 - 用户设置 | 用户列表、创建用户、重置密码 | `/user-settings` |
|
||||||
|
| 平台治理 - 租户管理 | 租户列表、配额设置 | `/tenants` |
|
||||||
|
| 平台治理 - 项目空间 | 项目列表、成员管理、ACL | `/projects` |
|
||||||
|
| 平台治理 - 审批模板 | 审批流程定义 | `/approval-templates` |
|
||||||
|
| 平台治理 - 审批中心 | 待审批请求处理 | `/approval-instances` |
|
||||||
|
| 平台治理 - 审计日志 | 操作记录查询与导出 | `/audit-logs` |
|
||||||
|
| 算力资源 - 算力节点 | GPU 分配与管理 | `/compute` |
|
||||||
|
|
||||||
点击某用户的「页面权限」按钮,弹出对话框:
|
### 2.4 重置用户密码
|
||||||
|
|
||||||
```
|
|
||||||
为 张三 分配可访问的页面模块:
|
|
||||||
|
|
||||||
☑ 服务看板 ☑ 模型训练 ☑ 模型评测
|
|
||||||
☑ 模型推理 ☑ 模型管理 ☑ 数据集管理
|
|
||||||
☐ 数据处理 ☐ 数据类型转换 ☐ 算力节点 ← 不勾选则不可见
|
|
||||||
☑ 平台性能 ☑ 查看日志
|
|
||||||
|
|
||||||
[取消] [保存]
|
|
||||||
```
|
|
||||||
|
|
||||||
勾选需要的模块,点「保存」即可。
|
|
||||||
|
|
||||||
> **注意**:
|
|
||||||
> - 「用户与权限」这个选项**只有 admin 能看到**,其他用户即使被赋权也不会显示
|
|
||||||
> - admin 用户的权限**不可更改**,始终是全选状态且只读
|
|
||||||
|
|
||||||
#### 限制说明
|
|
||||||
|
|
||||||
| 权限码 | 说明 | 谁能拥有 |
|
|
||||||
|---|---|---|
|
|
||||||
| `user-settings` | 用户设置、租户管理、项目空间、审批、审计日志 | **仅 admin** |
|
|
||||||
| `compute` | 算力节点、GPU 分配 | **仅 admin** |
|
|
||||||
| 其他业务权限 | 训练、评测、推理、模型、数据集等 | admin 可分配给任何人 |
|
|
||||||
|
|
||||||
### 2.3 重置用户密码
|
|
||||||
|
|
||||||
**两种方式**:
|
**两种方式**:
|
||||||
|
|
||||||
@@ -120,14 +129,14 @@
|
|||||||
2. 输入旧密码 + 新密码(至少 6 位)
|
2. 输入旧密码 + 新密码(至少 6 位)
|
||||||
3. 确认修改
|
3. 确认修改
|
||||||
|
|
||||||
### 2.4 删除用户
|
### 2.5 删除用户
|
||||||
|
|
||||||
**路径**:`用户设置` → 用户列表 → 操作列「删除」
|
**路径**:`用户设置` → 用户列表 → 操作列「删除」
|
||||||
|
|
||||||
> ⚠️ 删除用户时会**级联清理**其所有关联数据:
|
> ⚠️ 删除用户时会**级联清理**其所有关联数据:
|
||||||
> - 该用户创建的数据集、基座模型、微调产物、评测任务
|
> - 该用户创建的数据集、基座模型、微调产物、评测任务
|
||||||
> - 该用户的 ACL 授权记录、GPU 分配记录
|
> - 该用户的 ACL 授权记录、GPU 分配记录
|
||||||
> - 该用户的审批实例、审计日志、项目成员关系
|
> - 该用户的审批实例、审计日志、项目成员关系、登录会话
|
||||||
> - **训练任务保留不删**(避免算力节点上的物理任务数据不一致)
|
> - **训练任务保留不删**(避免算力节点上的物理任务数据不一致)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -197,7 +206,7 @@
|
|||||||
|
|
||||||
| 资源类型 | admin 看到 | 普通用户看到 |
|
| 资源类型 | admin 看到 | 普通用户看到 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **基座模型**(容器内注册的本地模型) | 全部 | **全部**(共享资源,有 model-manage 权限即可见) |
|
| **基座模型**(容器内注册的本地模型) | 全部 | **全部**(共享资源) |
|
||||||
| **数据集** | 全部 | **自己创建的** + 被 ACL 授权的 |
|
| **数据集** | 全部 | **自己创建的** + 被 ACL 授权的 |
|
||||||
| **微调产物**(训练输出的模型) | 全部 | **自己训练的** + 被 ACL 授权的 |
|
| **微调产物**(训练输出的模型) | 全部 | **自己训练的** + 被 ACL 授权的 |
|
||||||
| **评测任务** | 全部 | **自己创建的** + 被 ACL 授权的 |
|
| **评测任务** | 全部 | **自己创建的** + 被 ACL 授权的 |
|
||||||
@@ -289,14 +298,14 @@ curl -X PUT /modelTF/resources/dataset/ds_alpaca_id/acl \
|
|||||||
|
|
||||||
### 5.3 管理审批
|
### 5.3 管理审批
|
||||||
|
|
||||||
**路径**:`系统设置` → `审批中心`
|
**路径**:`平台治理` → `审批中心`
|
||||||
|
|
||||||
1. 查看待审批列表(status=pending)
|
1. 查看待审批列表(status=pending)
|
||||||
2. 点击某条记录查看详情
|
2. 点击某条记录查看详情
|
||||||
3. 决策:「通过」或「拒绝」
|
3. 决策:「通过」或「拒绝」
|
||||||
4. 决策结果自动执行对应操作并记录审计日志
|
4. 决策结果自动执行对应操作并记录审计日志
|
||||||
|
|
||||||
**审批模板**(`系统设置` → `审批模板`):定义每种操作需要几步审批、每步谁来审。默认模板都是单步(admin 审批即可)。
|
**审批模板**(`平台治理` → `审批模板`):定义每种操作需要几步审批、每步谁来审。默认模板都是单步(admin 审批即可)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -315,7 +324,7 @@ curl -X PUT /modelTF/resources/dataset/ds_alpaca_id/acl \
|
|||||||
|
|
||||||
### 6.2 查询审计日志
|
### 6.2 查询审计日志
|
||||||
|
|
||||||
**路径**:`系统设置` → `审计日志`
|
**路径**:`平台治理` → `审计日志`
|
||||||
|
|
||||||
支持筛选条件:
|
支持筛选条件:
|
||||||
|
|
||||||
@@ -332,7 +341,7 @@ curl -X PUT /modelTF/resources/dataset/ds_alpaca_id/acl \
|
|||||||
|
|
||||||
### 6.4 日志保留策略
|
### 6.4 日志保留策略
|
||||||
|
|
||||||
审计日志受**留存策略**控制(`系统设置` → 租户管理 → 绑定留存策略)。默认保留 30 天,超期自动清理。
|
审计日志受**留存策略**控制(`平台治理` → 租户管理 → 绑定留存策略)。默认保留 30 天,超期自动清理。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -340,9 +349,13 @@ curl -X PUT /modelTF/resources/dataset/ds_alpaca_id/acl \
|
|||||||
|
|
||||||
### Q1: 普通用户看不到某个菜单?
|
### Q1: 普通用户看不到某个菜单?
|
||||||
|
|
||||||
检查两件事:
|
根据 v1.1 权限模型:
|
||||||
1. 该用户是否有对应的**权限码**(admin 在「用户设置」→「页面权限」中分配)
|
1. **业务菜单**(训练、评测、推理、数据集等):普通用户**默认全部可见**,无需分配
|
||||||
2. 该菜单是否属于 **admin 专属**(如「用户设置」「算力节点」——这些对非 admin 永远不可见)
|
2. **管理菜单**(用户设置、租户管理、算力节点等):**仅 admin 可见**,这是设计如此
|
||||||
|
|
||||||
|
如果普通用户看不到业务菜单,请检查:
|
||||||
|
- 用户是否正常登录(token 是否有效)
|
||||||
|
- 用户状态是否为 `active`(未被停用)
|
||||||
|
|
||||||
### Q2: 用户创建训练任务时报错"无权使用所选 GPU"
|
### Q2: 用户创建训练任务时报错"无权使用所选 GPU"
|
||||||
|
|
||||||
@@ -355,7 +368,7 @@ curl -X PUT /modelTF/resources/dataset/ds_alpaca_id/acl \
|
|||||||
|
|
||||||
正常情况下 `delete_user` 会级联清理关联数据。如果仍有残留:
|
正常情况下 `delete_user` 会级联清理关联数据。如果仍有残留:
|
||||||
- **训练任务**:设计上保留不删(避免算力节点物理数据不一致),这是预期行为
|
- **训练任务**:设计上保留不删(避免算力节点物理数据不一致),这是预期行为
|
||||||
- **登录时长排行**:可能来自旧的 session 记录,不影响功能,新登录后会更新
|
- **登录时长排行**:可能来自旧的 session 记录(已修复:改用 INNER JOIN 过滤已删除用户)
|
||||||
|
|
||||||
### Q4: 审批实例一直 pending 没人处理?
|
### Q4: 审批实例一直 pending 没人处理?
|
||||||
|
|
||||||
@@ -394,6 +407,18 @@ curl -H "Authorization: Bearer platform-token-admin" \
|
|||||||
|
|
||||||
如果是完全忘记且不是 admin,只能由 admin 重置。
|
如果是完全忘记且不是 admin,只能由 admin 重置。
|
||||||
|
|
||||||
|
### Q7: 为什么移除了「页面权限」功能?(v1.1 变更说明)
|
||||||
|
|
||||||
|
旧版本要求管理员为每个用户单独分配页面权限码,这导致:
|
||||||
|
- 新用户创建后需要额外操作才能使用系统
|
||||||
|
- 权限配置复杂,容易出错
|
||||||
|
- 与实际使用场景不匹配(大多数用户需要访问大部分功能)
|
||||||
|
|
||||||
|
**新模型(v1.1)**简化为:
|
||||||
|
- 非 admin 用户**默认拥有所有业务功能的访问权限**
|
||||||
|
- 仅管理员专属功能(用户管理、平台治理、算力节点)受角色限制
|
||||||
|
- 资源级别控制通过 ACL 实现,更灵活
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 附录:API 快速参考
|
## 附录:API 快速参考
|
||||||
|
|||||||
121
docs/模型评测功能总结.md
121
docs/模型评测功能总结.md
@@ -1,121 +0,0 @@
|
|||||||
# 模型评测功能总结
|
|
||||||
|
|
||||||
本项目(基于 LLaMA-Factory 的微调训练平台)包含 **4 套相对独立** 的模型评测能力,分别面向不同的使用场景:
|
|
||||||
|
|
||||||
| 能力 | 入口/目录 | 评测类型 | 打分方式 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 1. 学术 Benchmark 评测 | `llamafactory/eval/` | 选择题式基准(类 MMLU/C-Eval) | 选项匹配 + few-shot |
|
|
||||||
| 2. 评估工作台 | `backend/app/api/v1/eval/` | 生成式问答(指令跟随) | BLEU / ROUGE / ExactMatch + 可选 LLM 评审 |
|
|
||||||
| 3. 平台评估系统 | `backend/app/api/v1/evaluation/` | 基于评估数据集的问答 | 判卷模型(judge model)打分(0–5 分) |
|
|
||||||
| 4. 训练时验证评估 | `backend/app/services/task_runner.py` | 训练验证集 | loss 指标 |
|
|
||||||
|
|
||||||
下面分别说明。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 学术 Benchmark 评测(LLaMA-Factory 原生)
|
|
||||||
|
|
||||||
面向标准学术选择题基准(如 MMLU、C-Eval 等),复用 LLaMA-Factory 原生的评测框架。
|
|
||||||
|
|
||||||
**核心文件**
|
|
||||||
- `llamafactory/eval/evaluator.py`:`Evaluator` 类 + `run_eval()` 入口
|
|
||||||
- `llamafactory/eval/template.py`:评测 prompt 模板(中/英,含 few-shot 示例构建)
|
|
||||||
- `llamafactory/hparams/evaluation_args.py`:`EvaluationArguments` 配置类
|
|
||||||
|
|
||||||
**工作流程**
|
|
||||||
1. 按 `task`(benchmark 名称)加载数据集,按科目(subject)拆分。
|
|
||||||
2. 每个样本构造 few-shot 提示词(`n_shot` 控制示例数,由 `lang` 决定中/英模板),将题干与候选选项拼入 prompt。
|
|
||||||
3. 调用模型推理得到预测,与标准答案比对,统计每个科目及整体的 `accuracy`。
|
|
||||||
4. 结果写入 `save_dir`,打印各科目与平均准确率。
|
|
||||||
|
|
||||||
**关键参数(`EvaluationArguments`)**
|
|
||||||
- `task`:基准数据集名
|
|
||||||
- `batch_size` / `n_shot` / `lang` / `save_dir` / `seed`
|
|
||||||
- `model_name_or_path`、`template`、`trust_remote_code` 等模型相关参数
|
|
||||||
|
|
||||||
> 该能力属于框架底层,本平台前端未直接提供操作入口,主要通过配置文件/脚本调用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 评估工作台(生成式评测 + 指标计算)
|
|
||||||
|
|
||||||
后端路由位于 `backend/app/api/v1/eval/__init__.py`,前端称为「评估工作台」。**适用于评测模型的指令跟随与生成质量**,并支持 LLM 作为裁判(LLM-as-a-Judge)。
|
|
||||||
|
|
||||||
**API 端点**
|
|
||||||
- `GET /evaluation/tasks`:列出评测任务(`frontend/src/api/evaluation.ts:listTasks`)
|
|
||||||
- `POST /evaluation/run`:提交一次评测(`runEval`)
|
|
||||||
- `GET /evaluation/report/{task_id}`:拉取评测报告(`getReport`)
|
|
||||||
- `DELETE /evaluation/tasks/{task_id}`:删除任务(`deleteTask`)
|
|
||||||
|
|
||||||
**评测流程(`run_eval`)**
|
|
||||||
1. 通过 **LLaMA-Factory 数据管道**(`get_dataset`) 加载数据集,支持 `subset` 与抽样(`eval_sample`)。
|
|
||||||
2. 用 **原生 transformers** 加载模型在本地做生成推理(单进程顺序生成,便于展示样本)。
|
|
||||||
3. 计算客观指标(`compute_score`):
|
|
||||||
- `BLEU`(sacrebleu)
|
|
||||||
- `ROUGE-1 / ROUGE-2 / ROUGE-L`(rouge-score)
|
|
||||||
- `Exact Match`
|
|
||||||
4. **可选 LLM 评审**(judge):当配置了 `judge_model` / `judge_api_base` / `judge_api_key` 时,调用 OpenAI 兼容接口对每条样本打分(10 分制),并输出 4 个维度与理由:
|
|
||||||
- 核心事实正确性 `factual`
|
|
||||||
- 信息完整性 `completeness`
|
|
||||||
- 无幻觉 `no_hallucination`
|
|
||||||
- 格式合规性 `format`
|
|
||||||
- 综合分 `score` + `reason`
|
|
||||||
5. 任务状态持久化在后端 `eval_tasks.json`(支持 running/completed/failed/stopped),前端轮询进度。
|
|
||||||
|
|
||||||
**前端页面**
|
|
||||||
- `frontend/src/views/evaluation/EvaluateTask.vue`:任务列表、创建评测对话框(选模型、数据集、指标、可选 judge 配置)
|
|
||||||
- `frontend/src/views/evaluation/EvaluateReport.vue`:报告页,展示综合得分、BLEU、ROUGE-L、各维度指标及「参考答案 vs 模型预测 vs LLM 评审」对比样例
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 平台评估系统(基于评估数据集 + 判卷模型)
|
|
||||||
|
|
||||||
后端路由位于 `backend/app/api/v1/evaluation/__init__.py`,是平台业务层自研的评测体系。通过「评估数据集」组织题目,可一次性对 **多个被测模型 + 指定判卷模型** 进行批量评分。
|
|
||||||
|
|
||||||
**核心概念(数据模型 `backend/app/models/models.py`)**
|
|
||||||
- `EvalDataset`(`models.py:131`):评估数据集,从项目问答对(`Question`/`Chunk`)中按 `question_type`(mixed/fact/reasoning)选题构建,状态 `pending/running/completed/failed`。
|
|
||||||
- `EvalResult`(`models.py:147`):单条评测结果,含 `judge_score`(0–5 分)、`is_correct`(true/false/partial)、`feedback`、`expected_answer` 等。
|
|
||||||
- `Task`(`models.py:184`):后台任务,`task_type="model-evaluation"`,记录进度与 `model_info`(存放平均分等汇总)。
|
|
||||||
|
|
||||||
**评测流程(`process_evaluation_task`,`backend/app/services/task_processor.py:336` 起)**
|
|
||||||
1. 加载评估数据集关联的题目,可选带入 `chunk` 上下文(RAG 场景)。
|
|
||||||
2. 对每道题,先用 `build_eval_prompt` 组合「上下文 + 题目 + 参考答案」,调用 **判卷模型**(`call_model`,temperature=0.3)生成评分。
|
|
||||||
3. `parse_eval_result` 解析出 `score`(0–5)、`is_correct`、`feedback`,写入 `EvalResult`。
|
|
||||||
4. 逐题提交进度(`completed_count` / `progress`),支持中途 `stopped`。
|
|
||||||
5. 汇总:`avg_score = 总分/有效数 × 20`(换算百分制),`avg_score_5 = 总分/有效数`(5 分制),存入 `task.model_info`。判定规则:得分 **≥3 视为正确**。
|
|
||||||
|
|
||||||
**特点**
|
|
||||||
- 判卷与被测模型解耦:被测模型给出答案,判卷模型(judge)独立评分,降低自评偏差。
|
|
||||||
- 支持失败隔离:单题异常写入 `evaluation_status: failed` 记录而不中断整体任务。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 训练时验证评估
|
|
||||||
|
|
||||||
在微调训练任务执行期间,由 `backend/app/services/task_runner.py` 的 `do_eval` 触发:
|
|
||||||
|
|
||||||
- 在训练过程中对验证集(validation set)计算 `eval_loss`,用于监控过拟合。
|
|
||||||
- 结果回填到 `Task` 的 `loss_info` / `detail`,前端绘制 loss 曲线。
|
|
||||||
- 属于训练配套的轻量评估,不参与上述 1–3 的业务评测。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附属:前端评测相关页面
|
|
||||||
|
|
||||||
| 文件 | 作用 |
|
|
||||||
| --- | --- |
|
|
||||||
| `frontend/src/views/evaluation/EvaluateTask.vue` | 评估工作台:任务列表 + 创建评测 |
|
|
||||||
| `frontend/src/views/evaluation/EvaluateReport.vue` | 评估报告:指标卡 + 维度标签 + 对比样例 |
|
|
||||||
| `frontend/src/api/evaluation.ts` | 评估工作台接口封装 |
|
|
||||||
| 平台评估系统入口 | 评估数据集管理 + 评估任务(model-evaluation)创建与结果查看 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 小结
|
|
||||||
|
|
||||||
- **想要学术榜单式准确率** → 用能力 1(LLaMA-Factory `eval/`)。
|
|
||||||
- **想要开放式生成质量(BLEU/ROUGE + LLM 评审)** → 用能力 2(评估工作台 `/evaluation/run`)。
|
|
||||||
- **想要基于自有问答数据、用判卷模型批量打分** → 用能力 3(平台评估系统 `model-evaluation` 任务)。
|
|
||||||
- **训练过程监控** → 能力 4(`do_eval` 验证集 loss)。
|
|
||||||
|
|
||||||
三种业务评测(1/2/3)相互独立,可并存于同一平台;数据模型(`EvalDataset`/`EvalResult`/`Task`)主要服务于能力 3,而能力 2 使用独立的 `eval_tasks.json` 文件持久化。
|
|
||||||
23
frontend/src/api/modules/resource-acl.ts
Normal file
23
frontend/src/api/modules/resource-acl.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { get, put } from '../request'
|
||||||
|
|
||||||
|
/** ACL 条目 */
|
||||||
|
export interface AclEntry {
|
||||||
|
principal_type: 'user' | 'role'
|
||||||
|
principal_id: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资源 ACL(含已有条目) */
|
||||||
|
export interface ResourceAcl {
|
||||||
|
resource_type: string
|
||||||
|
resource_id: string
|
||||||
|
entries: AclEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询资源 ACL */
|
||||||
|
export const getResourceAcl = (resourceType: string, resourceId: string) =>
|
||||||
|
get<ResourceAcl>(`/resources/${resourceType}/${resourceId}/acl`)
|
||||||
|
|
||||||
|
/** 设置资源 ACL */
|
||||||
|
export const setResourceAcl = (resourceType: string, resourceId: string, entries: AclEntry[]) =>
|
||||||
|
put<ResourceAcl>(`/resources/${resourceType}/${resourceId}/acl`, { entries })
|
||||||
@@ -30,7 +30,7 @@ export const deleteTenant = (id: string) => del(`/tenants/${id}`)
|
|||||||
|
|
||||||
/** 设置租户配额 */
|
/** 设置租户配额 */
|
||||||
export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
|
export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
|
||||||
put<Tenant>(`/tenants/${id}/quota`, { quota })
|
put<Tenant>(`/tenants/${id}/quota`, quota)
|
||||||
|
|
||||||
/** 设置租户留存策略 */
|
/** 设置租户留存策略 */
|
||||||
export const setTenantRetention = (id: string, retention_policy_id: string) =>
|
export const setTenantRetention = (id: string, retention_policy_id: string) =>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const menuGroups: MenuGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
|
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
|
||||||
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
|
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
|
||||||
|
{ key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', permission: 'user-settings' },
|
||||||
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', 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-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' },
|
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
|
||||||
@@ -94,17 +95,30 @@ const menuGroups: MenuGroup[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单可见性过滤规则(基于 governance-user-guide.md 设计):
|
||||||
|
*
|
||||||
|
* 1. admin 用户:可以看到所有菜单
|
||||||
|
* 2. 非 admin 用户:
|
||||||
|
* - 默认可见所有业务菜单(模型训练、评测、推理、数据集、数据处理、转换、性能、日志等)
|
||||||
|
* - 仅以下菜单对非 admin 不可见:
|
||||||
|
* - user-settings(用户设置、租户管理、项目空间、审批模板/中心、审计日志)
|
||||||
|
* - compute(算力节点/GPU 分配)
|
||||||
|
*
|
||||||
|
* 注意:移除了旧的权限码(permission code)过滤逻辑,
|
||||||
|
* 非管理员用户默认拥有所有业务功能的访问权限。
|
||||||
|
*/
|
||||||
|
const ADMIN_ONLY_PERMISSIONS: PermissionCode[] = ['user-settings', 'compute']
|
||||||
|
|
||||||
const visibleMenuGroups = computed(() =>
|
const visibleMenuGroups = computed(() =>
|
||||||
menuGroups
|
menuGroups
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
items: group.items.filter((item) => {
|
items: group.items.filter((item) => {
|
||||||
if (!auth.hasPermission(item.permission)) return false
|
// admin 可以看到所有菜单
|
||||||
// user-settings 权限对应的菜单仅管理员可见
|
if (auth.isAdmin) return true
|
||||||
if (item.permission === 'user-settings' && !auth.isAdmin) return false
|
// 非 admin 用户:仅隐藏管理员专属菜单
|
||||||
// 算力节点仅管理员可见
|
return !ADMIN_ONLY_PERMISSIONS.includes(item.permission)
|
||||||
if (item.permission === 'compute' && !auth.isAdmin) return false
|
|
||||||
return true
|
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
.filter((group) => group.items.length > 0),
|
.filter((group) => group.items.length > 0),
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
|
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
|
||||||
meta: { title: '审批中心', permission: 'user-settings' },
|
meta: { title: '审批中心', permission: 'user-settings' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'resource-acl',
|
||||||
|
name: 'resource-acl',
|
||||||
|
component: () => import('@/views/governance/ResourceAclView.vue'),
|
||||||
|
meta: { title: '资源授权', permission: 'user-settings' },
|
||||||
|
},
|
||||||
// 模型调优
|
// 模型调优
|
||||||
{
|
{
|
||||||
path: 'fine-tune',
|
path: 'fine-tune',
|
||||||
@@ -351,6 +357,7 @@ const permissionBySegment: Record<string, PermissionCode> = {
|
|||||||
'audit-logs': 'user-settings',
|
'audit-logs': 'user-settings',
|
||||||
'approval-templates': 'user-settings',
|
'approval-templates': 'user-settings',
|
||||||
'approval-instances': 'user-settings',
|
'approval-instances': 'user-settings',
|
||||||
|
'resource-acl': 'user-settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiredPermission(path: string, explicit?: unknown) {
|
function requiredPermission(path: string, explicit?: unknown) {
|
||||||
@@ -361,6 +368,11 @@ function requiredPermission(path: string, explicit?: unknown) {
|
|||||||
|
|
||||||
// 全局守卫:登录校验
|
// 全局守卫:登录校验
|
||||||
// 离开页面超时由 App.vue 的 visibilitychange 监听接管
|
// 离开页面超时由 App.vue 的 visibilitychange 监听接管
|
||||||
|
//
|
||||||
|
// 权限控制规则(基于 governance-user-guide.md 设计):
|
||||||
|
// - admin 用户:可以访问所有页面
|
||||||
|
// - 非 admin 用户:默认可访问所有业务页面(训练、评测、推理、数据等)
|
||||||
|
// 仅以下页面限制 admin 访问:user-settings、compute(算力节点)
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
if (!to.meta.public) routeLoading.value = true
|
if (!to.meta.public) routeLoading.value = true
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -384,20 +396,18 @@ router.beforeEach((to, _from, next) => {
|
|||||||
|
|
||||||
if (!to.meta.skipPermission) {
|
if (!to.meta.skipPermission) {
|
||||||
const permission = requiredPermission(to.path, to.meta.permission)
|
const permission = requiredPermission(to.path, to.meta.permission)
|
||||||
if (permission && !auth.hasPermission(permission)) {
|
// 仅限制管理员专属页面的访问权限
|
||||||
next({ name: 'permission-denied', replace: true })
|
// user-settings(用户设置、租户管理、项目空间、审批、审计日志)仅 admin 可访问
|
||||||
return
|
|
||||||
}
|
|
||||||
// user-settings 权限对应的页面仅管理员可访问
|
|
||||||
if (permission === 'user-settings' && !auth.isAdmin) {
|
if (permission === 'user-settings' && !auth.isAdmin) {
|
||||||
next({ name: 'permission-denied', replace: true })
|
next({ name: 'permission-denied', replace: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 算力节点仅管理员可访问
|
// compute(算力节点/GPU 分配)仅 admin 可访问
|
||||||
if (permission === 'compute' && !auth.isAdmin) {
|
if (permission === 'compute' && !auth.isAdmin) {
|
||||||
next({ name: 'permission-denied', replace: true })
|
next({ name: 'permission-denied', replace: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 其他所有业务页面对已登录用户开放,不再检查权限码
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)
|
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)
|
||||||
|
|||||||
377
frontend/src/views/governance/ResourceAclView.vue
Normal file
377
frontend/src/views/governance/ResourceAclView.vue
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getDatasetList, type DatasetItem } from '@/api/modules/dataset'
|
||||||
|
import { getTrainedModels } from '@/api/modules/model'
|
||||||
|
import type { TrainedModel } from '@/types'
|
||||||
|
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||||
|
import { getResourceAcl, setResourceAcl, type AclEntry } from '@/api/modules/resource-acl'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// 资源类型选项
|
||||||
|
const resourceTypes = [
|
||||||
|
{ value: 'dataset', label: '数据集' },
|
||||||
|
{ value: 'trained_model', label: '训练产物(微调模型)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 资源列表
|
||||||
|
const datasets = ref<DatasetItem[]>([])
|
||||||
|
const models = ref<TrainedModel[]>([])
|
||||||
|
const users = ref<SystemUser[]>([])
|
||||||
|
|
||||||
|
// 当前选择
|
||||||
|
const form = reactive({
|
||||||
|
resourceType: 'dataset',
|
||||||
|
resourceIds: [] as string[],
|
||||||
|
resourceNames: '' as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ACL 编辑
|
||||||
|
const aclEntries = ref<AclEntry[]>([])
|
||||||
|
const showAddEntry = ref(false)
|
||||||
|
const newEntry = reactive({
|
||||||
|
principal_id: '',
|
||||||
|
permissions: ['read', 'execute'] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const permissionOptions = [
|
||||||
|
{ label: '查看 (read)', value: 'read' },
|
||||||
|
{ label: '编辑 (write)', value: 'write' },
|
||||||
|
{ label: '使用 (execute)', value: 'execute' },
|
||||||
|
{ label: '下载 (download)', value: 'download' },
|
||||||
|
{ label: '删除 (delete)', value: 'delete' },
|
||||||
|
{ label: '全部权限 (admin)', value: 'admin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加载所有数据
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [dsRes, trainedRes, userRes] = await Promise.all([
|
||||||
|
getDatasetList().catch(() => []),
|
||||||
|
getTrainedModels().catch(() => ({ models: [] })),
|
||||||
|
getUsers().catch(() => []),
|
||||||
|
])
|
||||||
|
datasets.value = dsRes || []
|
||||||
|
models.value = trainedRes?.models || []
|
||||||
|
users.value = userRes || []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前资源类型的列表
|
||||||
|
function currentResourceList() {
|
||||||
|
if (form.resourceType === 'dataset') {
|
||||||
|
return datasets.value.map((d) => ({ id: d.id, name: d.name || d.id }))
|
||||||
|
}
|
||||||
|
if (form.resourceType === 'trained_model') {
|
||||||
|
return models.value.map((m) => ({ id: String(m.id || m.name || ''), name: m.name || String(m.id) || '' }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前资源的名称
|
||||||
|
function getResourceName(id: string) {
|
||||||
|
return currentResourceList().find((r) => r.id === id)?.name || id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择资源后加载 ACL
|
||||||
|
async function onResourceChange() {
|
||||||
|
if (!form.resourceIds || form.resourceIds.length === 0) {
|
||||||
|
aclEntries.value = []
|
||||||
|
form.resourceNames = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 显示选中的资源名称
|
||||||
|
const names = form.resourceIds.map((id) => getResourceName(id))
|
||||||
|
form.resourceNames = names.join('、')
|
||||||
|
|
||||||
|
// 加载第一个资源的 ACL 作为初始值(多个资源的 ACL 合并显示)
|
||||||
|
try {
|
||||||
|
const res = await getResourceAcl(form.resourceType, form.resourceIds[0])
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
aclEntries.value = res.map((e: any) => ({
|
||||||
|
...e,
|
||||||
|
principal_type: e.principal_type || 'user',
|
||||||
|
permissions: Array.isArray(e.permissions) ? e.permissions : [],
|
||||||
|
}))
|
||||||
|
} else if (res && Array.isArray((res as any).entries)) {
|
||||||
|
aclEntries.value = (res as any).entries.map((e: any) => ({
|
||||||
|
...e,
|
||||||
|
principal_type: e.principal_type || 'user',
|
||||||
|
permissions: Array.isArray(e.permissions) ? e.permissions : [],
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
aclEntries.value = []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载 ACL 失败:', e)
|
||||||
|
aclEntries.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换资源类型时重置
|
||||||
|
function onTypeChange() {
|
||||||
|
form.resourceIds = []
|
||||||
|
form.resourceNames = ''
|
||||||
|
aclEntries.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加授权条目
|
||||||
|
function addEntry() {
|
||||||
|
const trimmedId = (newEntry.principal_id || '').trim()
|
||||||
|
if (!trimmedId) {
|
||||||
|
ElMessage.warning('请选择要授权的用户')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!newEntry.permissions || newEntry.permissions.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一个权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exists = aclEntries.value.some((e) => e.principal_id === trimmedId)
|
||||||
|
if (exists) {
|
||||||
|
ElMessage.warning('该用户已存在,请先删除再重新添加')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aclEntries.value.push({
|
||||||
|
principal_type: 'user',
|
||||||
|
principal_id: trimmedId,
|
||||||
|
permissions: [...newEntry.permissions],
|
||||||
|
})
|
||||||
|
showAddEntry.value = false
|
||||||
|
newEntry.principal_id = ''
|
||||||
|
newEntry.permissions = ['read', 'execute']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除授权条目
|
||||||
|
function removeEntry(index: number) {
|
||||||
|
aclEntries.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 ACL
|
||||||
|
async function saveAcl() {
|
||||||
|
if (!form.resourceIds || form.resourceIds.length === 0) {
|
||||||
|
ElMessage.warning('请先选择资源')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (aclEntries.value.length === 0) {
|
||||||
|
ElMessage.warning('请至少添加一个授权用户')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// 为每个选中的资源保存相同的 ACL 授权
|
||||||
|
for (const resourceId of form.resourceIds) {
|
||||||
|
await setResourceAcl(form.resourceType, resourceId, aclEntries.value)
|
||||||
|
}
|
||||||
|
ElMessage.success(`已为 ${form.resourceIds.length} 个资源保存授权配置`)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示名称
|
||||||
|
function principalName(entry: AclEntry) {
|
||||||
|
const user = users.value.find((u) => u.id === entry.principal_id)
|
||||||
|
return user ? `${user.display_name || user.username}` : entry.principal_id
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page" v-loading="loading">
|
||||||
|
<h2 class="page-title">资源授权管理</h2>
|
||||||
|
<p class="page-desc">将数据集或微调模型授权给指定用户使用。被授权的用户可以在自己的页面看到并使用该资源。</p>
|
||||||
|
|
||||||
|
<!-- 步骤1:选择资源 -->
|
||||||
|
<el-card class="section-card">
|
||||||
|
<template #header>
|
||||||
|
<span>① 选择要授权的资源</span>
|
||||||
|
</template>
|
||||||
|
<el-form :inline="true" label-width="90px">
|
||||||
|
<el-form-item label="资源类型">
|
||||||
|
<el-select v-model="form.resourceType" @change="onTypeChange" style="width: 200px">
|
||||||
|
<el-option v-for="t in resourceTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="选择资源">
|
||||||
|
<el-select
|
||||||
|
v-model="form.resourceIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="搜索并选择多个资源..."
|
||||||
|
style="width: 400px"
|
||||||
|
@change="onResourceChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="r in currentResourceList()"
|
||||||
|
:key="r.id"
|
||||||
|
:label="`${r.name} (${r.id.slice(0, 8)}...)`"
|
||||||
|
:value="r.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 步骤2:当前授权列表 + 添加授权 -->
|
||||||
|
<el-card class="section-card" v-if="form.resourceIds.length > 0">
|
||||||
|
<template #header>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||||
|
<span>② 管理授权 — {{ form.resourceNames }}</span>
|
||||||
|
<el-button type="primary" size="small" @click="showAddEntry = true">+ 添加授权</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 已有授权列表 -->
|
||||||
|
<el-table :data="aclEntries" border empty-text="暂无授权,点击上方按钮添加" style="width: 100%">
|
||||||
|
<el-table-column label="用户">
|
||||||
|
<template #default="{ row }">{{ principalName(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="授权资源" min-width="200">
|
||||||
|
<template #default>
|
||||||
|
<el-tag v-for="(id, idx) in form.resourceIds" :key="id" size="small" style="margin: 2px">
|
||||||
|
{{ getResourceName(id) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="权限">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-space wrap>
|
||||||
|
<el-tag
|
||||||
|
v-for="p in row.permissions"
|
||||||
|
:key="p"
|
||||||
|
size="small"
|
||||||
|
:type="p === 'admin' ? 'danger' : p === 'delete' || p === 'write' ? 'warning' : ''"
|
||||||
|
>{{ p }}</el-tag>
|
||||||
|
</el-space>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80" align="center">
|
||||||
|
<template #default="{ $index }">
|
||||||
|
<el-button type="danger" size="small" link @click="removeEntry($index)">移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 添加授权表单 -->
|
||||||
|
<el-divider v-if="showAddEntry" content-position="left">添加新授权</el-divider>
|
||||||
|
<div v-if="showAddEntry" class="add-entry-form">
|
||||||
|
<el-form :inline="true" label-width="80px">
|
||||||
|
<el-form-item label="选择用户">
|
||||||
|
<el-select
|
||||||
|
v-model="newEntry.principal_id"
|
||||||
|
filterable
|
||||||
|
placeholder="选择要授权的用户"
|
||||||
|
style="width: 240px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="u in users"
|
||||||
|
:key="u.id"
|
||||||
|
:label="`${u.display_name || u.username} (${u.username})`"
|
||||||
|
:value="u.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="权限">
|
||||||
|
<el-checkbox-group v-model="newEntry.permissions">
|
||||||
|
<el-checkbox v-for="p in permissionOptions" :key="p.value" :value="p.value">{{ p.label }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="addEntry">确认添加</el-button>
|
||||||
|
<el-button @click="showAddEntry = false">取消</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 保存按钮 -->
|
||||||
|
<div class="save-bar">
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveAcl">保存授权配置</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 使用说明 -->
|
||||||
|
<el-card class="section-card help-card">
|
||||||
|
<template #header><span>使用说明</span></template>
|
||||||
|
<div class="help-content">
|
||||||
|
<p><strong>什么是资源授权?</strong></p>
|
||||||
|
<p>默认情况下,普通用户只能看到自己创建的资源。通过授权,管理员可以让指定用户也能看到和使用别人的资源。</p>
|
||||||
|
<br />
|
||||||
|
<p><strong>典型场景:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>zhangsan 训练出了模型 ft_qwen_001,admin 授权给 lisi,lisi 可以用这个模型做推理</li>
|
||||||
|
<li>admin 上传了公共数据集,授权给指定用户,被授权用户可以在训练时使用该数据集</li>
|
||||||
|
</ul>
|
||||||
|
<br />
|
||||||
|
<p><strong>权限说明:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>read</code> - 在列表中看到该资源</li>
|
||||||
|
<li><code>execute</code> - 用该资源做训练/推理/评测</li>
|
||||||
|
<li><code>write</code> - 修改资源信息</li>
|
||||||
|
<li><code>download</code> - 下载文件</li>
|
||||||
|
<li><code>delete</code> - 删除资源</li>
|
||||||
|
<li><code>admin</code> - 全部权限(含转授权)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.page-desc {
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.section-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.add-entry-form {
|
||||||
|
background: #f9fafc;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed #dcdfe6;
|
||||||
|
}
|
||||||
|
.save-bar {
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.help-card {
|
||||||
|
.help-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #555;
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 4px 0;
|
||||||
|
li { margin-bottom: 4px; }
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
changeMyPassword,
|
changeMyPassword,
|
||||||
@@ -8,30 +8,12 @@ import {
|
|||||||
resetUserPassword,
|
resetUserPassword,
|
||||||
updateUserAccess,
|
updateUserAccess,
|
||||||
} from '@/api/modules/system'
|
} from '@/api/modules/system'
|
||||||
import type { PermissionCode, SystemUser, UserStatus } from '@/types'
|
import type { 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 {
|
||||||
@@ -59,14 +41,6 @@ function asSystemUser(row: unknown): SystemUser {
|
|||||||
return row as SystemUser
|
return row as SystemUser
|
||||||
}
|
}
|
||||||
|
|
||||||
function userPermissions(row: unknown): PermissionCode[] {
|
|
||||||
return (asSystemUser(row).permissions || []) as PermissionCode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function permissionLabel(code: PermissionCode): string {
|
|
||||||
return PERMISSION_LABELS[code] || code
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 启停 ----------
|
// ---------- 启停 ----------
|
||||||
async function toggleStatus(row: SystemUser, next: boolean) {
|
async function toggleStatus(row: SystemUser, next: boolean) {
|
||||||
const nextStatus: UserStatus = next ? 'active' : 'disabled'
|
const nextStatus: UserStatus = next ? 'active' : 'disabled'
|
||||||
@@ -135,60 +109,6 @@ async function confirmChangeMyPwd() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 页面权限 ----------
|
|
||||||
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
|
|
||||||
// admin 用户强制全选且只读
|
|
||||||
if (row.role === 'admin' || row.protected) {
|
|
||||||
permDialog.checked = [...ALL_PERMISSIONS]
|
|
||||||
} else {
|
|
||||||
// 非 admin 用户去掉 user-settings
|
|
||||||
permDialog.checked = (row.permissions || []).filter((p) => p !== 'user-settings')
|
|
||||||
}
|
|
||||||
permDialog.visible = true
|
|
||||||
}
|
|
||||||
async function confirmPerms() {
|
|
||||||
if (permReadonly.value) {
|
|
||||||
permDialog.visible = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 双重保险:非 admin 用户不允许勾选 user-settings
|
|
||||||
if (!isTargetAdmin.value) {
|
|
||||||
permDialog.checked = permDialog.checked.filter((p) => p !== 'user-settings')
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 权限列:admin 用户全选且只读,非 admin 用户不显示 user-settings */
|
|
||||||
const isTargetAdmin = computed(() => {
|
|
||||||
const u = users.value.find((u) => u.id === permDialog.id)
|
|
||||||
return u?.role === 'admin' || u?.protected === true
|
|
||||||
})
|
|
||||||
const permColumns = computed(() => {
|
|
||||||
if (isTargetAdmin.value) return ALL_PERMISSIONS
|
|
||||||
// 非 admin 用户不能拥有 user-settings 权限
|
|
||||||
return ALL_PERMISSIONS.filter((c) => c !== 'user-settings')
|
|
||||||
})
|
|
||||||
const permReadonly = computed(() => isTargetAdmin.value)
|
|
||||||
|
|
||||||
// ---------- 删除 ----------
|
// ---------- 删除 ----------
|
||||||
async function removeUser(row: SystemUser) {
|
async function removeUser(row: SystemUser) {
|
||||||
try {
|
try {
|
||||||
@@ -216,7 +136,7 @@ async function removeUser(row: SystemUser) {
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>用户设置</h1>
|
<h1>用户设置</h1>
|
||||||
<p>管理平台账号、角色状态、登录密码与页面权限。</p>
|
<p>管理平台账号、角色状态与登录密码。</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<el-button @click="openChangeMyPwd">修改密码</el-button>
|
<el-button @click="openChangeMyPwd">修改密码</el-button>
|
||||||
@@ -233,21 +153,6 @@ async function removeUser(row: SystemUser) {
|
|||||||
<el-tag :type="statusTagType(asSystemUser(row).status)" size="small">{{ statusLabel(asSystemUser(row).status) }}</el-tag>
|
<el-tag :type="statusTagType(asSystemUser(row).status)" size="small">{{ statusLabel(asSystemUser(row).status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="页面权限" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag
|
|
||||||
v-for="p in userPermissions(row).slice(0, 3)"
|
|
||||||
:key="p"
|
|
||||||
size="small"
|
|
||||||
type="info"
|
|
||||||
class="perm-tag"
|
|
||||||
>{{ permissionLabel(p) }}</el-tag>
|
|
||||||
<span v-if="userPermissions(row).length > 3" class="perm-more">
|
|
||||||
+{{ userPermissions(row).length - 3 }}
|
|
||||||
</span>
|
|
||||||
<span v-if="!userPermissions(row).length" class="perm-more">无</span>
|
|
||||||
</template>
|
|
||||||
</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">
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -265,11 +170,6 @@ async function removeUser(row: SystemUser) {
|
|||||||
:disabled="asSystemUser(row).protected"
|
:disabled="asSystemUser(row).protected"
|
||||||
@click="openResetPwd(asSystemUser(row))"
|
@click="openResetPwd(asSystemUser(row))"
|
||||||
>重置密码</el-button>
|
>重置密码</el-button>
|
||||||
<el-button
|
|
||||||
link
|
|
||||||
type="primary"
|
|
||||||
@click="openPerms(asSystemUser(row))"
|
|
||||||
>页面权限</el-button>
|
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -290,26 +190,6 @@ async function removeUser(row: SystemUser) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 页面权限 -->
|
|
||||||
<el-dialog v-model="permDialog.visible" title="页面权限" width="540px">
|
|
||||||
<p class="dlg-tip">
|
|
||||||
为 <b>{{ permDialog.name }}</b> 分配可访问的页面模块:
|
|
||||||
<el-tag v-if="permReadonly" type="warning" size="small" style="margin-left: 8px">管理员权限不可更改</el-tag>
|
|
||||||
</p>
|
|
||||||
<el-checkbox-group v-model="permDialog.checked" class="perm-group" :disabled="permReadonly">
|
|
||||||
<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">{{ permReadonly ? '关闭' : '取消' }}</el-button>
|
|
||||||
<el-button v-if="!permReadonly" type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<!-- 修改自己的密码 -->
|
<!-- 修改自己的密码 -->
|
||||||
<el-dialog v-model="myPwdDialog.visible" title="修改密码" width="420px">
|
<el-dialog v-model="myPwdDialog.visible" title="修改密码" width="420px">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
@@ -350,24 +230,8 @@ async function removeUser(row: SystemUser) {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.perm-tag {
|
|
||||||
margin-right: 4px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perm-more {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlg-tip {
|
.dlg-tip {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.perm-group {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,8 +10,18 @@ const tenant = ref<Tenant | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
||||||
|
|
||||||
function parseQuota(quota: Record<string, unknown> | undefined | null) {
|
function parseQuota(quota: Record<string, unknown> | undefined | null | string) {
|
||||||
const q = quota || {}
|
// 兼容处理:quota 可能是 JSON 字符串或嵌套 { quota: {...} } 格式
|
||||||
|
let q: Record<string, unknown> = {}
|
||||||
|
if (typeof quota === 'string') {
|
||||||
|
try { q = JSON.parse(quota) || {} } catch { q = {} }
|
||||||
|
} else if (quota && typeof quota === 'object') {
|
||||||
|
q = quota
|
||||||
|
}
|
||||||
|
// 如果是嵌套格式 { quota: { gpu: 4, ... } },提取内层
|
||||||
|
if (q.quota && typeof q.quota === 'object' && !q.gpu) {
|
||||||
|
q = q.quota as Record<string, unknown>
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
gpu: Number(q.gpu || q.gpu_quota || 0),
|
gpu: Number(q.gpu || q.gpu_quota || 0),
|
||||||
storage: Number(q.storage || q.storage_quota || 0),
|
storage: Number(q.storage || q.storage_quota || 0),
|
||||||
|
|||||||
@@ -15,8 +15,17 @@ const currentTenant = ref<Tenant | null>(null)
|
|||||||
const form = ref({ name: '', code: '', gpu: 0, storage: 0, maxProjects: 0 })
|
const form = ref({ name: '', code: '', gpu: 0, storage: 0, maxProjects: 0 })
|
||||||
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
||||||
|
|
||||||
function parseQuota(quota: Record<string, unknown> | undefined | null) {
|
function parseQuota(quota: Record<string, unknown> | undefined | null | string) {
|
||||||
const q = quota || {}
|
// 兼容处理:quota 可能是 JSON 字符串或嵌套 { quota: {...} } 格式
|
||||||
|
let q: Record<string, unknown> = {}
|
||||||
|
if (typeof quota === 'string') {
|
||||||
|
try { q = JSON.parse(quota) || {} } catch { q = {} }
|
||||||
|
} else if (quota && typeof quota === 'object') {
|
||||||
|
q = quota
|
||||||
|
}
|
||||||
|
if (q.quota && typeof q.quota === 'object' && !q.gpu) {
|
||||||
|
q = q.quota as Record<string, unknown>
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
gpu: Number(q.gpu || q.gpu_quota || 0),
|
gpu: Number(q.gpu || q.gpu_quota || 0),
|
||||||
storage: Number(q.storage || q.storage_quota || 0),
|
storage: Number(q.storage || q.storage_quota || 0),
|
||||||
|
|||||||
Reference in New Issue
Block a user