378 lines
12 KiB
Vue
378 lines
12 KiB
Vue
|
|
<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>
|