更新平台治理

This commit is contained in:
wangjiming
2026-08-17 09:15:36 +08:00
parent de9c8e4ffe
commit 6c1bf61ff7
13 changed files with 561 additions and 339 deletions

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

View File

@@ -30,7 +30,7 @@ export const deleteTenant = (id: string) => del(`/tenants/${id}`)
/** 设置租户配额 */
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) =>

View File

@@ -79,6 +79,7 @@ const menuGroups: MenuGroup[] = [
items: [
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', 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: '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' },
@@ -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(() =>
menuGroups
.map((group) => ({
...group,
items: group.items.filter((item) => {
if (!auth.hasPermission(item.permission)) return false
// user-settings 权限对应的菜单仅管理员可见
if (item.permission === 'user-settings' && !auth.isAdmin) return false
// 算力节点仅管理员可见
if (item.permission === 'compute' && !auth.isAdmin) return false
return true
// admin 可以看到所有菜单
if (auth.isAdmin) return true
// 非 admin 用户:仅隐藏管理员专属菜单
return !ADMIN_ONLY_PERMISSIONS.includes(item.permission)
}),
}))
.filter((group) => group.items.length > 0),

View File

@@ -74,6 +74,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
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',
@@ -351,6 +357,7 @@ const permissionBySegment: Record<string, PermissionCode> = {
'audit-logs': 'user-settings',
'approval-templates': 'user-settings',
'approval-instances': 'user-settings',
'resource-acl': 'user-settings',
}
function requiredPermission(path: string, explicit?: unknown) {
@@ -361,6 +368,11 @@ function requiredPermission(path: string, explicit?: unknown) {
// 全局守卫:登录校验
// 离开页面超时由 App.vue 的 visibilitychange 监听接管
//
// 权限控制规则(基于 governance-user-guide.md 设计):
// - admin 用户:可以访问所有页面
// - 非 admin 用户:默认可访问所有业务页面(训练、评测、推理、数据等)
// 仅以下页面限制 admin 访问user-settings、compute算力节点
router.beforeEach((to, _from, next) => {
if (!to.meta.public) routeLoading.value = true
const auth = useAuthStore()
@@ -384,20 +396,18 @@ router.beforeEach((to, _from, next) => {
if (!to.meta.skipPermission) {
const permission = requiredPermission(to.path, to.meta.permission)
if (permission && !auth.hasPermission(permission)) {
next({ name: 'permission-denied', replace: true })
return
}
// user-settings 权限对应的页面仅管理员可访问
// 仅限制管理员专属页面的访问权限
// user-settings用户设置、租户管理、项目空间、审批、审计日志仅 admin 可访问
if (permission === 'user-settings' && !auth.isAdmin) {
next({ name: 'permission-denied', replace: true })
return
}
// 算力节点仅管理员可访问
// compute算力节点/GPU 分配)仅 admin 可访问
if (permission === 'compute' && !auth.isAdmin) {
next({ name: 'permission-denied', replace: true })
return
}
// 其他所有业务页面对已登录用户开放,不再检查权限码
}
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)

View 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_001admin 授权给 lisilisi 可以用这个模型做推理</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>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
changeMyPassword,
@@ -8,30 +8,12 @@ import {
resetUserPassword,
updateUserAccess,
} from '@/api/modules/system'
import type { PermissionCode, SystemUser, UserStatus } from '@/types'
import type { SystemUser, UserStatus } from '@/types'
import { statusLabel, statusTagType } from '@/utils/status'
const loading = ref(false)
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() {
loading.value = true
try {
@@ -59,14 +41,6 @@ function asSystemUser(row: unknown): 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) {
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) {
try {
@@ -216,7 +136,7 @@ async function removeUser(row: SystemUser) {
<header class="page-header">
<div>
<h1>用户设置</h1>
<p>管理平台账号角色状态登录密码与页面权限</p>
<p>管理平台账号角色状态与登录密码</p>
</div>
<div>
<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>
</template>
</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 label="操作" width="260" fixed="right">
<template #default="{ row }">
@@ -265,11 +170,6 @@ async function removeUser(row: SystemUser) {
:disabled="asSystemUser(row).protected"
@click="openResetPwd(asSystemUser(row))"
>重置密码</el-button>
<el-button
link
type="primary"
@click="openPerms(asSystemUser(row))"
>页面权限</el-button>
<el-button
link
type="danger"
@@ -290,26 +190,6 @@ async function removeUser(row: SystemUser) {
</template>
</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-form label-width="80px">
@@ -350,24 +230,8 @@ async function removeUser(row: SystemUser) {
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>

View File

@@ -10,8 +10,18 @@ const tenant = ref<Tenant | null>(null)
const loading = ref(false)
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
function parseQuota(quota: Record<string, unknown> | undefined | null) {
const q = quota || {}
function parseQuota(quota: Record<string, unknown> | undefined | null | string) {
// 兼容处理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 {
gpu: Number(q.gpu || q.gpu_quota || 0),
storage: Number(q.storage || q.storage_quota || 0),

View File

@@ -15,8 +15,17 @@ const currentTenant = ref<Tenant | null>(null)
const form = ref({ name: '', code: '', gpu: 0, storage: 0, maxProjects: 0 })
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
function parseQuota(quota: Record<string, unknown> | undefined | null) {
const q = quota || {}
function parseQuota(quota: Record<string, unknown> | undefined | null | string) {
// 兼容处理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 {
gpu: Number(q.gpu || q.gpu_quota || 0),
storage: Number(q.storage || q.storage_quota || 0),