feat: 平台治理与权限体系完善,存储进度/GPU预留/审批中心与日志整合
- 平台治理: 租户用户权限层次、资源ACL、审批中心与审批模板、访问申请 - 存储: MinIO 存储进度迁移、对象存储安全加固与测试 - 计算: GPU 资源预留、compute 轮询与同步增强 - 权限: permission v2 迁移、权限安全验收测试 - 日志: 后端运行日志中文说明、操作日志整合 - 数据处理/评测: 数据转换与模型评测优化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,17 +21,49 @@ export interface ApprovalInstance {
|
||||
status: string
|
||||
current_step: number
|
||||
create_time?: string
|
||||
action?: string | null
|
||||
tenant_id?: string | null
|
||||
execution_status?: string | null
|
||||
steps: Array<ApprovalStep & { step_index: number; comment?: string | null; time?: string | null }>
|
||||
}
|
||||
|
||||
export interface ResourceAccessRequest {
|
||||
id: string
|
||||
tenant_id: string
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
applicant_id: string
|
||||
principal_type: string
|
||||
principal_id: string
|
||||
requested_permissions: string[]
|
||||
reason?: string | null
|
||||
approval_id?: string | null
|
||||
status: string
|
||||
expires_at?: string | null
|
||||
created_at?: string
|
||||
cancelled_at?: string | null
|
||||
cancelled_by?: string | null
|
||||
}
|
||||
|
||||
export interface GpuRequestOption {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
gpus: Array<{
|
||||
index: number
|
||||
name: string
|
||||
memory_total_gb: number
|
||||
}>
|
||||
}
|
||||
|
||||
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 getApprovalInstances = (status?: string, mine = false) =>
|
||||
get<ApprovalInstance[]>('/approvals', { status, mine: mine || undefined })
|
||||
|
||||
export const createApprovalInstance = (payload: {
|
||||
template_id?: string
|
||||
@@ -46,5 +78,31 @@ export const getApprovalInstance = (id: string) =>
|
||||
export const decideApproval = (
|
||||
id: string,
|
||||
step_index: number,
|
||||
payload: { approver_id: string; approved: boolean; comment?: string },
|
||||
payload: { approved: boolean; comment?: string },
|
||||
) => post<ApprovalInstance>(`/approvals/${id}/steps/${step_index}/decision`, payload)
|
||||
|
||||
export const createResourceAccessRequest = (payload: {
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
principal_type?: 'user' | 'role'
|
||||
principal_id?: string
|
||||
requested_permissions: string[]
|
||||
reason?: string
|
||||
expires_at?: string
|
||||
}) => post<ResourceAccessRequest>('/approvals/resource-access/requests', payload)
|
||||
|
||||
export const getResourceAccessRequests = (status?: string) =>
|
||||
get<ResourceAccessRequest[]>('/approvals/resource-access/requests', { status })
|
||||
|
||||
export const cancelResourceAccessRequest = (id: string) =>
|
||||
post<ResourceAccessRequest>(`/approvals/resource-access/requests/${id}/cancel`, {})
|
||||
|
||||
export const getGpuRequestOptions = () =>
|
||||
get<{ nodes: GpuRequestOption[] }>('/approvals/gpu-options')
|
||||
|
||||
export const requestGpuAccess = (payload: {
|
||||
assignments: Array<{ node_id: string; gpu_index: number }>
|
||||
reason?: string
|
||||
}) => post<{ approval_required?: boolean; approval_id?: string; approval?: ApprovalInstance }>(
|
||||
'/approvals/gpu-requests', payload,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface AuditLog {
|
||||
target_id?: string
|
||||
detail?: string
|
||||
client_ip?: string
|
||||
result?: string
|
||||
reason?: string
|
||||
request_id?: string
|
||||
session_id?: string
|
||||
metadata?: string | Record<string, unknown>
|
||||
time?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, post, del } from '../request'
|
||||
import { get, post, del, getAuthHeaders } from '../request'
|
||||
import type { CompareTask, CompareModelRef } from '@/types'
|
||||
|
||||
const INFERENCE_START_TIMEOUT_MS = 15 * 60 * 1000
|
||||
@@ -78,7 +78,10 @@ export const streamChatReal = (data: any): Promise<Response> => {
|
||||
}
|
||||
return fetch('/modelTF/model-compare/stream-chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
temperature: data.temperature ?? 0.7,
|
||||
|
||||
@@ -10,7 +10,12 @@ export interface DataConvertTask {
|
||||
output_count: number
|
||||
error_message: string
|
||||
create_time: string
|
||||
update_time?: string
|
||||
update_time?: string
|
||||
created_by?: string | null
|
||||
creator_name?: string | null
|
||||
processed_by?: string | null
|
||||
processor_name?: string | null
|
||||
processed_at?: string | null
|
||||
input_files?: Array<{ name: string; size: number }>
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ export interface ModelExportJob {
|
||||
payload?: Record<string, unknown>
|
||||
create_time?: string
|
||||
completed_at?: string
|
||||
created_by?: string
|
||||
tenant_id?: string
|
||||
archive_status?: string
|
||||
archive_error?: string
|
||||
}
|
||||
|
||||
/** 模型列表 */
|
||||
@@ -51,7 +55,7 @@ export const getModelByName = (name: string) => get<ModelItem>(`/model-manage/na
|
||||
|
||||
/** 本地模型路径列表 */
|
||||
export const getLocalModels = () =>
|
||||
get<{ models: { path: string; name: string; source?: string }[] }>('/model-manage/local-models')
|
||||
get<{ models: { path: string; name: string; storage_status?: string }[] }>('/model-manage/local-models')
|
||||
|
||||
/** 已训练模型列表 */
|
||||
export const getTrainedModels = () =>
|
||||
@@ -97,9 +101,16 @@ export const mergeModel = (data: {
|
||||
output_model_name?: string
|
||||
}) => post('/model-manage/merge', data, { timeout: 15 * 60 * 1000 })
|
||||
|
||||
/** 导出已训练模型权重 */
|
||||
export const exportModelUrl = (modelName: string) =>
|
||||
`/modelTF/model-manage/trained-models/${encodeURIComponent(modelName)}/export`
|
||||
/** 导出已训练模型权重,沿用节点缓存和 MinIO 归档流程 */
|
||||
export const exportModel = (data: {
|
||||
trained_model_id?: string | number
|
||||
model_name?: string
|
||||
base_model_path?: string
|
||||
adapter_path?: string
|
||||
compute_node_id?: string
|
||||
output_model_name?: string
|
||||
export_quantization_bit?: 0 | 4 | 8
|
||||
}) => post('/model-manage/export', data, { timeout: 15 * 60 * 1000 })
|
||||
|
||||
/** 测试在线模型连通性 */
|
||||
export const testOnlineModel = (data: {
|
||||
|
||||
@@ -9,11 +9,37 @@ export interface Tenant {
|
||||
quota: Record<string, unknown>
|
||||
retention_policy_id?: string | null
|
||||
create_time?: string
|
||||
deleted_at?: string | null
|
||||
deleted_by?: string | null
|
||||
}
|
||||
|
||||
export interface TenantMember {
|
||||
tenant_id: string
|
||||
user_id: string
|
||||
username?: string
|
||||
display_name?: string
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer'
|
||||
status: 'active' | 'pending' | 'disabled' | 'expired'
|
||||
invited_by?: string | null
|
||||
joined_at?: string | null
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface TenantQuotaUsage {
|
||||
tenant_id: string
|
||||
quota: Record<string, unknown>
|
||||
gpu_limit: number
|
||||
gpu_reserved: number
|
||||
storage_reserved: number
|
||||
reservations: number
|
||||
}
|
||||
|
||||
/** 租户列表 */
|
||||
export const getTenants = () => get<Tenant[]>('/tenants')
|
||||
|
||||
/** 当前用户可切换的 active 租户及成员角色 */
|
||||
export const getMyTenants = () => get<Tenant[]>('/tenants/mine')
|
||||
|
||||
/** 租户详情 */
|
||||
export const getTenant = (id: string) => get<Tenant>(`/tenants/${id}`)
|
||||
|
||||
@@ -28,6 +54,10 @@ export const updateTenant = (id: string, payload: Partial<Tenant>) =>
|
||||
/** 删除租户 */
|
||||
export const deleteTenant = (id: string) => del(`/tenants/${id}`)
|
||||
|
||||
/** 恢复软删除租户 */
|
||||
export const restoreTenant = (id: string) =>
|
||||
post<Tenant>(`/tenants/${id}/restore`, {})
|
||||
|
||||
/** 设置租户配额 */
|
||||
export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
|
||||
put<Tenant>(`/tenants/${id}/quota`, quota)
|
||||
@@ -35,3 +65,26 @@ export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
|
||||
/** 设置租户留存策略 */
|
||||
export const setTenantRetention = (id: string, retention_policy_id: string) =>
|
||||
put<Tenant>(`/tenants/${id}/retention-policy`, { retention_policy_id })
|
||||
|
||||
export const getTenantMembers = (id: string) =>
|
||||
get<TenantMember[]>(`/tenants/${id}/members`)
|
||||
|
||||
export const inviteTenantMember = (
|
||||
id: string,
|
||||
payload: { user_id: string; role?: TenantMember['role']; expires_at?: string },
|
||||
) => post<TenantMember>(`/tenants/${id}/members/invite`, payload)
|
||||
|
||||
export const updateTenantMember = (id: string, userId: string, payload: Partial<TenantMember>) =>
|
||||
put<TenantMember>(`/tenants/${id}/members/${userId}`, payload)
|
||||
|
||||
export const removeTenantMember = (id: string, userId: string) =>
|
||||
del(`/tenants/${id}/members/${userId}`)
|
||||
|
||||
export const acceptTenantInvitation = (id: string, userId: string) =>
|
||||
post<TenantMember>(`/tenants/${id}/members/${userId}/accept`, {})
|
||||
|
||||
export const getTenantInvitations = () =>
|
||||
get<TenantMember[]>('/tenants/invitations')
|
||||
|
||||
export const getTenantQuotaUsage = (id: string) =>
|
||||
get<TenantQuotaUsage>(`/tenants/${id}/quota/usage`)
|
||||
|
||||
@@ -70,13 +70,27 @@ function getAuthToken(): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function getActiveTenantId(): string | null {
|
||||
return localStorage.getItem('activeTenantId')
|
||||
}
|
||||
|
||||
/** Headers shared by Axios and raw fetch requests such as SSE streaming. */
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getAuthToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const tenantId = getActiveTenantId()
|
||||
if (tenantId) headers['X-Tenant-ID'] = tenantId
|
||||
return headers
|
||||
}
|
||||
|
||||
// 请求拦截器:注入 Authorization header
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getAuthToken()
|
||||
if (token) {
|
||||
const authHeaders = getAuthHeaders()
|
||||
if (Object.keys(authHeaders).length) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
Object.assign(config.headers, authHeaders)
|
||||
}
|
||||
return config
|
||||
},
|
||||
|
||||
@@ -3,11 +3,17 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getMyTenants, type Tenant } from '@/api/modules/tenant'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
const { metrics } = storeToRefs(systemStore)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const activeTenantId = ref(localStorage.getItem('activeTenantId') || auth.currentUser?.tenant_id || (auth.isAdmin ? 'admin' : 'default'))
|
||||
|
||||
const showBackButton = computed(() => {
|
||||
return route.path.split('/').filter(Boolean).length > 1
|
||||
@@ -41,11 +47,27 @@ function openGuide() {
|
||||
window.open(guideUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
tenants.value = await getMyTenants().catch(() => [])
|
||||
if (!tenants.value.some((tenant) => tenant.id === activeTenantId.value)) {
|
||||
activeTenantId.value = tenants.value[0]?.id || (auth.isAdmin ? 'admin' : 'default')
|
||||
localStorage.setItem('activeTenantId', activeTenantId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function switchTenant(value: string) {
|
||||
activeTenantId.value = value
|
||||
localStorage.setItem('activeTenantId', value)
|
||||
ElMessage.success('当前租户已切换,正在刷新页面数据')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const serverIp = ref(window.location.hostname)
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
void loadTenants()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -102,6 +124,13 @@ onUnmounted(() => {
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="tenant-switcher">
|
||||
<i class="fa fa-building-o" />
|
||||
<el-select v-model="activeTenantId" size="small" @change="switchTenant">
|
||||
<el-option v-for="tenant in tenants" :key="tenant.id" :label="tenant.name || tenant.id" :value="tenant.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 时间日期与服务器IP -->
|
||||
<div class="system-info">
|
||||
<div class="info-item">
|
||||
@@ -256,6 +285,22 @@ onUnmounted(() => {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.tenant-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 150px;
|
||||
color: #64748b;
|
||||
|
||||
.fa {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
:deep(.el-select) {
|
||||
width: 132px;
|
||||
}
|
||||
}
|
||||
|
||||
.system-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -38,6 +38,7 @@ interface MenuItem {
|
||||
icon: string
|
||||
to: string
|
||||
permission: PermissionCode
|
||||
allowNonAdmin?: boolean
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
@@ -83,7 +84,7 @@ const menuGroups: MenuGroup[] = [
|
||||
items: [
|
||||
{ key: 'organization', label: '组织与权限', icon: 'fa-users', to: '/organization', permission: 'user-settings' },
|
||||
{ key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', 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?tab=mine', permission: 'user-settings', allowNonAdmin: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -115,10 +116,12 @@ const visibleMenuGroups = computed(() =>
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
// admin 可以看到所有菜单
|
||||
if (auth.isAdmin) return true
|
||||
// 非 admin 用户:仅隐藏管理员专属菜单
|
||||
return !ADMIN_ONLY_PERMISSIONS.includes(item.permission)
|
||||
// admin 可以看到所有菜单
|
||||
if (auth.isAdmin) return true
|
||||
// 运行日志是普通用户可选的自助权限;没有权限时不显示入口。
|
||||
if (item.permission === 'logs') return auth.hasPermission('logs')
|
||||
// 非 admin 用户:仅隐藏管理员专属菜单
|
||||
return item.allowNonAdmin || !ADMIN_ONLY_PERMISSIONS.includes(item.permission)
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0),
|
||||
|
||||
@@ -43,12 +43,13 @@ function defaultUsers(): SystemUser[] {
|
||||
{
|
||||
id: 'u_admin',
|
||||
username: 'admin',
|
||||
display_name: 'Platform Admin',
|
||||
display_name: 'Admin',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
permissions: allPermissions,
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
protected: true,
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
tenant_id: 'admin',
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
id: 'u_operator',
|
||||
@@ -57,8 +58,9 @@ function defaultUsers(): SystemUser[] {
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
permissions: allPermissions.filter((item) => item !== 'user-settings'),
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
protected: false,
|
||||
create_time: '2026-01-01T00:00:00Z',
|
||||
tenant_id: 'default',
|
||||
protected: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export function authenticateMockUser(username: string, password: string): LoginR
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new UserMutationError('Invalid username or disabled account', 401)
|
||||
}
|
||||
const expected = defaultPasswords[username] || 'platform123'
|
||||
const expected = defaultPasswords[username] || '123456'
|
||||
if (password !== expected) {
|
||||
throw new UserMutationError('Invalid username or password', 401)
|
||||
}
|
||||
@@ -115,7 +117,7 @@ export function createMockUser(payload: CreateUserPayload): SystemUser {
|
||||
create_time: new Date().toISOString(),
|
||||
protected: false,
|
||||
}
|
||||
defaultPasswords[user.username] = payload.password || 'platform123'
|
||||
defaultPasswords[user.username] = payload.password || '123456'
|
||||
users.push(user)
|
||||
writeUsers(users)
|
||||
return user
|
||||
|
||||
@@ -50,18 +50,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/tenants/TenantDetailView.vue'),
|
||||
meta: { title: '租户详情', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'projects',
|
||||
redirect: '/organization?tab=users',
|
||||
meta: { title: '组织与权限', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'projects/:id',
|
||||
name: 'project-detail',
|
||||
redirect: '/organization?tab=users',
|
||||
meta: { title: '组织与权限', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'audit-logs',
|
||||
@@ -72,7 +60,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'operation-logs',
|
||||
name: 'operation-logs',
|
||||
redirect: '/logs?tab=operations',
|
||||
meta: { title: '运行日志', permission: 'user-settings' },
|
||||
meta: { title: '运行日志', permission: 'logs' },
|
||||
},
|
||||
{
|
||||
path: 'approval-templates',
|
||||
@@ -84,7 +72,17 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'approval-instances',
|
||||
name: 'approval-instances',
|
||||
component: () => import('@/views/approvals/ApprovalCenterView.vue'),
|
||||
meta: { title: '审批中心', permission: 'user-settings' },
|
||||
meta: { title: '审批中心', permission: 'user-settings', selfService: true },
|
||||
},
|
||||
{
|
||||
path: 'tenant-invitations',
|
||||
redirect: '/approval-instances?tab=invitations',
|
||||
meta: { title: '审批中心', selfService: true },
|
||||
},
|
||||
{
|
||||
path: 'resource-access-requests',
|
||||
redirect: '/approval-instances?tab=access',
|
||||
meta: { title: '审批中心', selfService: true },
|
||||
},
|
||||
{
|
||||
path: 'resource-acl',
|
||||
@@ -366,7 +364,6 @@ const permissionBySegment: Record<string, PermissionCode> = {
|
||||
organization: 'user-settings',
|
||||
'user-settings': 'user-settings',
|
||||
tenants: 'user-settings',
|
||||
projects: 'user-settings',
|
||||
'audit-logs': 'user-settings',
|
||||
'operation-logs': 'user-settings',
|
||||
'approval-templates': 'user-settings',
|
||||
@@ -411,17 +408,22 @@ router.beforeEach((to, _from, next) => {
|
||||
if (!to.meta.skipPermission) {
|
||||
const permission = requiredPermission(to.path, to.meta.permission)
|
||||
// 仅限制管理员专属页面的访问权限
|
||||
// user-settings(组织与权限、资源授权、审批中心、运行日志)仅 admin 可访问
|
||||
if (permission === 'user-settings' && !auth.isAdmin) {
|
||||
// user-settings(组织与权限、资源授权、审批中心)仅 admin 可访问
|
||||
const selfService = to.meta.selfService === true && (!to.query.tab || ['mine', 'access', 'invitations', 'compute'].includes(String(to.query.tab)))
|
||||
if (permission === 'user-settings' && !auth.isAdmin && !selfService) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
// compute(算力节点/GPU 分配)仅 admin 可访问
|
||||
if (permission === 'compute' && !auth.isAdmin) {
|
||||
if (permission === 'compute' && !auth.isAdmin) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
if (permission === 'logs' && !auth.isAdmin && !auth.hasPermission('logs')) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
// 其他所有业务页面对已登录用户开放,不再检查权限码
|
||||
// 其他所有业务页面对已登录用户开放,不再检查权限码
|
||||
}
|
||||
|
||||
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)
|
||||
|
||||
@@ -68,6 +68,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return currentUser.value?.permissions.includes(permission) ?? false
|
||||
}
|
||||
|
||||
/** Button-level decision for a module action. The API remains the final authority. */
|
||||
function can(permission?: PermissionCode, action?: 'read' | 'write' | 'execute' | 'download' | 'delete' | 'admin') {
|
||||
if (!hasPermission(permission)) return false
|
||||
if (action === 'admin') return isAdmin.value
|
||||
return true
|
||||
}
|
||||
|
||||
/** 退出 */
|
||||
async function logout() {
|
||||
const sessionId = sessionStorage.getItem(SESSION_STORAGE_KEY)
|
||||
@@ -88,6 +95,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
hasPermission,
|
||||
can,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
|
||||
@@ -50,8 +50,11 @@ export interface DataProcessTask {
|
||||
filtered_count?: number
|
||||
duplicate_count?: number
|
||||
error_count?: number
|
||||
creator_name?: string | null
|
||||
creator?: string | null
|
||||
creator_name?: string | null
|
||||
creator?: string | null
|
||||
processor_name?: string | null
|
||||
processor?: string | null
|
||||
updated_by?: string | number | null
|
||||
created_by?: string | number | null
|
||||
create_time?: string
|
||||
created_at?: string
|
||||
|
||||
@@ -24,7 +24,11 @@ export interface ModelItem {
|
||||
path?: string
|
||||
api_url?: string
|
||||
api_key?: string
|
||||
/** Returned by the server without exposing the actual credential. */
|
||||
api_key_configured?: boolean
|
||||
online_model_name?: string
|
||||
storage_status?: 'pending' | 'archiving' | 'available' | 'failed' | 'not_applicable' | string
|
||||
storage_error?: string
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
@@ -42,6 +46,8 @@ export interface TrainedModel {
|
||||
merged?: boolean
|
||||
merging?: boolean
|
||||
merged_path?: string
|
||||
created_by?: string
|
||||
tenant_id?: string
|
||||
}
|
||||
|
||||
/** 创建/编辑模型请求体 */
|
||||
@@ -88,8 +94,10 @@ export interface DatasetItem {
|
||||
size?: string | number
|
||||
size_bytes?: number
|
||||
count?: number
|
||||
description?: string
|
||||
create_time?: string
|
||||
description?: string
|
||||
created_by?: string | number | null
|
||||
creator_name?: string | null
|
||||
create_time?: string
|
||||
files?: DatasetFile[]
|
||||
current_version_no?: number | null
|
||||
current_version_nos?: number[]
|
||||
@@ -168,6 +176,8 @@ export interface FineTuneTask {
|
||||
log_file?: string
|
||||
train_duration?: string
|
||||
create_time?: string
|
||||
created_by?: string
|
||||
tenant_id?: string
|
||||
}
|
||||
|
||||
export type FineTuneStartPayload = Omit<
|
||||
@@ -264,6 +274,19 @@ export interface EvalTask {
|
||||
score?: number
|
||||
status?: string
|
||||
create_time?: string
|
||||
progress?: number
|
||||
progress_detail?: EvalProgress
|
||||
}
|
||||
|
||||
export interface EvalProgress {
|
||||
status?: string
|
||||
stage?: string
|
||||
total?: number
|
||||
completed?: number
|
||||
percentage?: number
|
||||
current_index?: number
|
||||
message?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 启动评测时提交的可选基础指标配置。 */
|
||||
@@ -291,7 +314,8 @@ export interface StartEvalPayload {
|
||||
gpus?: number[]
|
||||
compute_node_id?: string
|
||||
dataset_id: string | number
|
||||
dimension_id: string | number
|
||||
dimension_id?: string | number
|
||||
dimension?: Partial<Dimension>
|
||||
data_source: 'dataset' | 'inference'
|
||||
leaderboard: boolean
|
||||
basic_metrics: BasicEvalMetricsConfig
|
||||
@@ -316,6 +340,8 @@ export interface EvalSampleResult {
|
||||
score: number
|
||||
max_score: number
|
||||
}>
|
||||
raw_score?: number | null
|
||||
raw_max_score?: number | null
|
||||
}
|
||||
|
||||
/** 评测任务详情,包含逐样本结果和综合评价 */
|
||||
@@ -334,8 +360,14 @@ export interface EvalTaskDetail extends EvalTask {
|
||||
score: number
|
||||
max_score: number
|
||||
pass_rate: number
|
||||
sample_count?: number
|
||||
available?: boolean
|
||||
error?: string
|
||||
}>
|
||||
samples: EvalSampleResult[]
|
||||
progress_detail?: EvalProgress
|
||||
basic_metrics?: Record<string, Record<string, unknown>>
|
||||
metric_summary_version?: number
|
||||
}
|
||||
|
||||
export interface Dimension {
|
||||
@@ -446,20 +478,27 @@ export type PermissionCode =
|
||||
| 'logs'
|
||||
| 'user-settings'
|
||||
|
||||
export type UserRole = 'admin' | 'operator' | 'viewer'
|
||||
export type UserRole = 'admin' | 'operator' | 'viewer' | 'user'
|
||||
|
||||
export type UserStatus = 'active' | 'disabled'
|
||||
export type UserStatus = 'active' | 'disabled' | 'pending' | 'deleted'
|
||||
|
||||
export interface SystemUser {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string
|
||||
role: UserRole
|
||||
/** Canonical platform scope; role is retained for legacy API clients. */
|
||||
platform_role?: 'platform_admin' | 'platform_user'
|
||||
status: UserStatus
|
||||
permissions: PermissionCode[]
|
||||
create_time: string
|
||||
last_login?: string
|
||||
protected?: boolean
|
||||
tenant_id?: string
|
||||
deleted_at?: string | null
|
||||
deleted_by?: string | null
|
||||
tenant_memberships?: Array<{ tenant_id: string; role: string; status: string; expires_at?: string | null }>
|
||||
tenant_count?: number
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
@@ -475,6 +514,8 @@ export interface CreateUserPayload {
|
||||
role: UserRole
|
||||
status?: UserStatus
|
||||
permissions?: PermissionCode[]
|
||||
tenant_id?: string
|
||||
tenant_role?: 'owner' | 'admin' | 'member' | 'viewer'
|
||||
}
|
||||
|
||||
export interface UpdateUserAccessPayload {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ApprovalInstanceView from './ApprovalInstanceView.vue'
|
||||
import ApprovalTemplateView from './ApprovalTemplateView.vue'
|
||||
import ResourceAccessRequestView from './ResourceAccessRequestView.vue'
|
||||
import TenantInvitationsView from './TenantInvitationsView.vue'
|
||||
import ComputeAccessRequestView from './ComputeAccessRequestView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const activeTab = computed<'instances' | 'mine' | 'strategies'>({
|
||||
get: () => route.query.tab === 'strategies' ? 'strategies' : route.query.tab === 'mine' ? 'mine' : 'instances',
|
||||
const activeTab = computed<'instances' | 'mine' | 'strategies' | 'access' | 'invitations' | 'compute'>({
|
||||
get: () => {
|
||||
const tab = String(route.query.tab || '')
|
||||
if (tab === 'access' || tab === 'invitations' || tab === 'mine' || tab === 'compute') return tab
|
||||
if (auth.isAdmin && tab === 'strategies') return 'strategies'
|
||||
return auth.isAdmin ? 'instances' : 'mine'
|
||||
},
|
||||
set: (value: string) => {
|
||||
void router.replace({ query: value === 'instances' ? {} : { tab: value } })
|
||||
},
|
||||
@@ -25,15 +35,24 @@ const activeTab = computed<'instances' | 'mine' | 'strategies'>({
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="审批申请" name="instances">
|
||||
<el-tab-pane v-if="auth.isAdmin" label="审批申请" name="instances">
|
||||
<ApprovalInstanceView v-if="activeTab === 'instances'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="我的申请" name="mine">
|
||||
<ApprovalInstanceView v-if="activeTab === 'mine'" mine />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="审批策略" name="strategies">
|
||||
<el-tab-pane v-if="auth.isAdmin" label="审批策略" name="strategies">
|
||||
<ApprovalTemplateView v-if="activeTab === 'strategies'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="访问申请" name="access">
|
||||
<ResourceAccessRequestView v-if="activeTab === 'access'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="租户邀请" name="invitations">
|
||||
<TenantInvitationsView v-if="activeTab === 'invitations'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="算力申请" name="compute">
|
||||
<ComputeAccessRequestView v-if="activeTab === 'compute'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,12 +11,13 @@ const props = defineProps<{ mine?: boolean }>()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
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 decision = ref({ step_index: 0, approved: true, comment: '' })
|
||||
|
||||
const visibleInstances = computed(() => {
|
||||
if (!props.mine) return instances.value
|
||||
@@ -31,17 +32,26 @@ const statusOptions = [
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
]
|
||||
const statusLabel = (status?: string) => ({ pending: '待审批', approved: '已通过', rejected: '已拒绝', cancelled: '已撤回', expired: '已过期' }[status || ''] || status || '—')
|
||||
const statusType = (status?: string): 'success' | 'warning' | 'danger' | 'info' => status === 'approved' ? 'success' : status === 'rejected' || status === 'expired' ? 'danger' : status === 'pending' ? 'warning' : 'info'
|
||||
const actionLabel = (action?: string | null) => ({ 'resource.access': '资源访问', 'gpu.assign': '算力卡分配', 'tenant.member.add': '添加租户成员', 'tenant.member.remove': '移除租户成员', 'tenant.quota.update': '修改租户配额', 'model.use': '使用模型', 'dataset.use': '使用数据集' }[action || ''] || action || '权限申请')
|
||||
const resourceLabel = (type?: string) => ({ dataset: '数据集', trained_model: '训练模型', model: '基座模型', compare: '推理任务', eval: '评测任务', compute: '算力资源' }[type || ''] || type || '资源')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
instances.value = await getApprovalInstances(statusFilter.value)
|
||||
loadError.value = ''
|
||||
instances.value = await getApprovalInstances(statusFilter.value, props.mine)
|
||||
} catch {
|
||||
loadError.value = '审批记录加载失败,请刷新后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
// 普通用户只查看自己的申请,不需要请求管理员用户列表。
|
||||
if (!auth.isAdmin) return
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} catch {
|
||||
@@ -49,7 +59,7 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
function userName(id?: string) {
|
||||
function userName(id?: string | null) {
|
||||
if (!id) return '—'
|
||||
return users.value.find((u) => u.id === id)?.username || id
|
||||
}
|
||||
@@ -57,22 +67,23 @@ function userName(id?: string) {
|
||||
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: '' }
|
||||
decision.value = { step_index: step ? step.step_index : 0, approved: true, comment: '' }
|
||||
showDecide.value = true
|
||||
}
|
||||
|
||||
function canDecide(inst: ApprovalInstance) {
|
||||
if (props.mine || inst.status !== 'pending' || !auth.can('user-settings', 'write')) return false
|
||||
const step = inst.steps.find((item) => item.status === 'pending')
|
||||
return !!step && (!step.approver_id || step.approver_id === auth.currentUser?.id || auth.isAdmin)
|
||||
}
|
||||
|
||||
function asApprovalInstance(row: unknown): ApprovalInstance {
|
||||
return row as ApprovalInstance
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
@@ -82,13 +93,14 @@ async function submitDecision() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
load()
|
||||
void loadUsers()
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-alert v-if="loadError" type="error" show-icon :closable="false" :title="loadError" class="state-alert" />
|
||||
<DataTablePage :title="props.mine ? '我的申请' : '审批申请'" :data="visibleInstances" :loading="loading" searchable :search-fields="['resource_type', 'resource_id']">
|
||||
<template #toolbar-extra>
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
|
||||
@@ -96,17 +108,18 @@ onMounted(() => {
|
||||
</el-select>
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="resource_type" label="资源类型" min-width="120" />
|
||||
<el-table-column label="申请事项" min-width="160"><template #default="{ row }">{{ actionLabel(asApprovalInstance(row).action) }}</template></el-table-column>
|
||||
<el-table-column label="资源类型" min-width="120"><template #default="{ row }">{{ resourceLabel(asApprovalInstance(row).resource_type) }}</template></el-table-column>
|
||||
<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(asApprovalInstance(row).applicant_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="100" />
|
||||
<el-table-column label="状态" min-width="100"><template #default="{ row }"><el-tag size="small" :type="statusType(asApprovalInstance(row).status)">{{ statusLabel(asApprovalInstance(row).status) }}</el-tag></template></el-table-column>
|
||||
<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="asApprovalInstance(row).status === 'pending'" link type="primary" @click="openDecide(asApprovalInstance(row))">审批</el-button>
|
||||
<el-button v-if="canDecide(asApprovalInstance(row))" link type="primary" @click="openDecide(asApprovalInstance(row))">审批</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
<el-dialog v-model="showDecide" title="审批决策" width="480px">
|
||||
@@ -117,10 +130,10 @@ onMounted(() => {
|
||||
<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 label="审批人">
|
||||
{{ current.steps.find((s) => s.step_index === decision.step_index)?.approver_id
|
||||
? userName(current.steps.find((s) => s.step_index === decision.step_index)?.approver_id)
|
||||
: '平台管理员' }}
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-radio-group v-model="decision.approved">
|
||||
@@ -142,4 +155,5 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
.state-alert { margin-bottom: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -1,77 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, 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'
|
||||
import { getUsers } from '@/api/modules/system'
|
||||
import type { SystemUser } from '@/types'
|
||||
|
||||
interface TemplateStep { approver_id?: string; approver_type?: 'user' | 'admin' | 'tenant_admin' }
|
||||
const loading = ref(false)
|
||||
const templates = ref<ApprovalTemplate[]>([])
|
||||
const users = ref<SystemUser[]>([])
|
||||
const showCreate = ref(false)
|
||||
const form = ref({ name: '', stepsText: '[]' })
|
||||
const form = reactive<{ name: string; steps: TemplateStep[] }>({ name: '', steps: [{ approver_type: 'user', approver_id: '' }] })
|
||||
|
||||
function userName(id?: string) { return users.value.find((user) => user.id === id)?.display_name || users.value.find((user) => user.id === id)?.username || id || '未指定' }
|
||||
function stepLabel(step: TemplateStep) { return step.approver_type === 'admin' ? '平台管理员' : step.approver_type === 'tenant_admin' ? '租户管理员' : userName(step.approver_id) }
|
||||
function addStep() { form.steps.push({ approver_type: 'user', approver_id: '' }) }
|
||||
function removeStep(index: number) { if (form.steps.length > 1) form.steps.splice(index, 1) }
|
||||
function resetForm() { form.name = ''; form.steps = [{ approver_type: 'user', approver_id: '' }] }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
templates.value = await getApprovalTemplates()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
users.value = await getUsers().catch(() => [])
|
||||
} 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('模板创建成功')
|
||||
if (!form.name.trim()) return ElMessage.warning('请填写模板名称')
|
||||
const steps = form.steps.filter((step) => step.approver_type !== 'user' || step.approver_id)
|
||||
if (!steps.length) return ElMessage.warning('请至少配置一个有效审批人')
|
||||
await createApprovalTemplate({ name: form.name.trim(), steps: steps as any })
|
||||
ElMessage.success('审批策略创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', stepsText: '[]' }
|
||||
load()
|
||||
resetForm()
|
||||
await 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>
|
||||
<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="name" label="策略名称" min-width="180" />
|
||||
<el-table-column label="审批链路" min-width="280"><template #default="{ row }"><el-space wrap><el-tag v-for="(step, index) in row.steps || []" :key="index" size="small">{{ index + 1 }}. {{ stepLabel(step) }}</el-tag></el-space></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-dialog v-model="showCreate" title="新建审批策略" width="620px">
|
||||
<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-item label="策略名称" required><el-input v-model="form.name" placeholder="例如:数据集访问审批" /></el-form-item>
|
||||
<el-form-item label="审批链路"><div class="steps-form"><div v-for="(step, index) in form.steps" :key="index" class="step-row"><span class="step-number">第 {{ index + 1 }} 步</span><el-select v-model="step.approver_type" style="width: 135px"><el-option label="指定用户" value="user" /><el-option label="租户管理员" value="tenant_admin" /><el-option label="平台管理员" value="admin" /></el-select><el-select v-if="step.approver_type === 'user'" v-model="step.approver_id" filterable placeholder="选择审批人" style="width: 190px"><el-option v-for="user in users" :key="user.id" :label="`${user.display_name || user.username}(${user.username})`" :value="user.id" /></el-select><span v-else class="role-hint">{{ stepLabel(step) }}</span><el-button text type="danger" :disabled="form.steps.length === 1" @click="removeStep(index)">移除</el-button></div><el-button text type="primary" @click="addStep">+ 添加审批步骤</el-button></div></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
<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">
|
||||
<style scoped>
|
||||
.page { padding: 16px; }
|
||||
.steps-form { width: 100%; }
|
||||
.step-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.step-number { width: 52px; color: #64748b; font-size: 13px; }
|
||||
.role-hint { min-width: 190px; color: #64748b; }
|
||||
</style>
|
||||
|
||||
92
frontend/src/views/approvals/ComputeAccessRequestView.vue
Normal file
92
frontend/src/views/approvals/ComputeAccessRequestView.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getGpuRequestOptions, requestGpuAccess, type GpuRequestOption } from '@/api/modules/approval'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const loadError = ref('')
|
||||
const nodes = ref<GpuRequestOption[]>([])
|
||||
const form = reactive({ node_id: '', gpu_indices: [] as number[], reason: '' })
|
||||
|
||||
const selectedNode = computed(() => nodes.value.find((node) => node.id === form.node_id) || null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
loadError.value = ''
|
||||
const result = await getGpuRequestOptions()
|
||||
nodes.value = result.nodes || []
|
||||
if (!nodes.value.some((node) => node.id === form.node_id)) {
|
||||
form.node_id = nodes.value[0]?.id || ''
|
||||
form.gpu_indices = []
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '可申请算力加载失败,请刷新后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeNode() {
|
||||
form.gpu_indices = []
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.node_id) return ElMessage.warning('请选择算力节点')
|
||||
if (!form.gpu_indices.length) return ElMessage.warning('请选择至少一张算力卡')
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await requestGpuAccess({
|
||||
assignments: form.gpu_indices.map((gpu_index) => ({ node_id: form.node_id, gpu_index })),
|
||||
reason: form.reason.trim() || undefined,
|
||||
})
|
||||
ElMessage.success(result.approval_required === false ? '算力分配成功' : '算力申请已提交,等待审批')
|
||||
form.gpu_indices = []
|
||||
form.reason = ''
|
||||
} finally {
|
||||
submitting.value = false
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compute-request" v-loading="loading">
|
||||
<el-alert v-if="loadError" type="error" show-icon :closable="false" :title="loadError" class="state-alert" />
|
||||
<el-alert v-else-if="!nodes.length && !loading" type="info" show-icon :closable="false" title="当前没有可申请的空闲算力卡,请稍后重试或联系管理员。" class="state-alert" />
|
||||
<el-card shadow="never">
|
||||
<template #header><span>申请算力卡</span></template>
|
||||
<el-form label-width="100px" class="request-form">
|
||||
<el-form-item label="算力节点">
|
||||
<el-select v-model="form.node_id" filterable placeholder="选择在线算力节点" style="width: min(520px, 100%)" @change="changeNode">
|
||||
<el-option v-for="node in nodes" :key="node.id" :label="`${node.name}(${node.code})`" :value="node.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="算力卡">
|
||||
<el-checkbox-group v-if="selectedNode" v-model="form.gpu_indices">
|
||||
<el-checkbox v-for="gpu in selectedNode.gpus" :key="gpu.index" :value="gpu.index">
|
||||
GPU {{ gpu.index }} · {{ gpu.name }} · {{ gpu.memory_total_gb }} GB
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<span v-else class="muted">请先选择算力节点</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请理由">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="说明训练、推理或评测用途" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!nodes.length" @click="submit">提交算力申请</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.compute-request { padding: 16px; }
|
||||
.state-alert { margin-bottom: 16px; }
|
||||
.request-form { max-width: 760px; }
|
||||
.muted { color: #94a3b8; }
|
||||
</style>
|
||||
123
frontend/src/views/approvals/ResourceAccessRequestView.vue
Normal file
123
frontend/src/views/approvals/ResourceAccessRequestView.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getDatasetList, type DatasetItem } from '@/api/modules/dataset'
|
||||
import { getTrainedModels } from '@/api/modules/model'
|
||||
import {
|
||||
cancelResourceAccessRequest,
|
||||
createResourceAccessRequest,
|
||||
getResourceAccessRequests,
|
||||
type ResourceAccessRequest,
|
||||
} from '@/api/modules/approval'
|
||||
import type { TrainedModel } from '@/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const resourcesLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const datasets = ref<DatasetItem[]>([])
|
||||
const models = ref<TrainedModel[]>([])
|
||||
const requests = ref<ResourceAccessRequest[]>([])
|
||||
const loadError = ref('')
|
||||
const statusFilter = ref('')
|
||||
const form = reactive({ resource_type: 'dataset', resource_id: '', requested_permissions: ['read', 'execute'], reason: '', expires_at: '' })
|
||||
const permissionOptions = [
|
||||
{ label: '查看', value: 'read' },
|
||||
{ label: '使用', value: 'execute' },
|
||||
{ label: '下载', value: 'download' },
|
||||
]
|
||||
|
||||
function resourceOptions() {
|
||||
if (form.resource_type === 'dataset') return datasets.value.map((item) => ({ id: String(item.id), name: item.name || String(item.id) }))
|
||||
return models.value.map((item) => ({ id: String(item.id || item.name), name: item.name || String(item.id) }))
|
||||
}
|
||||
function resetResource() { form.resource_id = '' }
|
||||
function resourceTypeLabel(type: string) { return type === 'trained_model' ? '训练模型' : type === 'dataset' ? '数据集' : type }
|
||||
function permissionLabel(permission: string) { return ({ read: '查看', write: '编辑', execute: '使用', download: '下载' } as Record<string, string>)[permission] || permission }
|
||||
function statusLabel(status: string) { return ({ pending: '审批中', approved: '已通过', rejected: '已拒绝', cancelled: '已撤回', expired: '已过期' } as Record<string, string>)[status] || status }
|
||||
function statusType(status: string): 'success' | 'warning' | 'danger' | 'info' { return status === 'approved' ? 'success' : status === 'rejected' || status === 'expired' ? 'danger' : status === 'pending' ? 'warning' : 'info' }
|
||||
function resourceName(row: ResourceAccessRequest) { return resourceOptions().find((item) => item.id === row.resource_id)?.name || row.resource_id }
|
||||
function asRequest(row: unknown) { return row as ResourceAccessRequest }
|
||||
async function loadResources() {
|
||||
if (resourcesLoading.value) return
|
||||
resourcesLoading.value = true
|
||||
try {
|
||||
const [datasetItems, trained] = await Promise.all([getDatasetList().catch(() => []), getTrainedModels().catch(() => ({ models: [] }))])
|
||||
datasets.value = datasetItems || []
|
||||
models.value = trained?.models || []
|
||||
} catch {
|
||||
// 资源下拉列表加载失败不影响申请历史展示。
|
||||
} finally { resourcesLoading.value = false }
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
loadError.value = ''
|
||||
requests.value = await getResourceAccessRequests(statusFilter.value || undefined)
|
||||
} catch {
|
||||
loadError.value = '访问申请加载失败,请刷新后重试。'
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (!form.resource_id) return ElMessage.warning('请选择资源')
|
||||
if (!form.requested_permissions.length) return ElMessage.warning('请选择申请权限')
|
||||
submitting.value = true
|
||||
try {
|
||||
await createResourceAccessRequest({ resource_type: form.resource_type, resource_id: form.resource_id, requested_permissions: form.requested_permissions, reason: form.reason || undefined, expires_at: form.expires_at || undefined })
|
||||
ElMessage.success('访问申请已提交')
|
||||
form.reason = ''
|
||||
form.expires_at = ''
|
||||
await load()
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
async function cancel(id: string) {
|
||||
await ElMessageBox.confirm('撤回后需要重新提交申请,是否继续?', '撤回访问申请', { type: 'warning' })
|
||||
await cancelResourceAccessRequest(id)
|
||||
ElMessage.success('申请已撤销')
|
||||
await load()
|
||||
}
|
||||
onMounted(() => {
|
||||
// 先显示申请记录;资源选项在后台加载,避免慢资源查询阻塞页面。
|
||||
void load()
|
||||
void loadResources()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="access-request" v-loading="loading">
|
||||
<el-alert v-if="loadError" type="error" show-icon :closable="false" :title="loadError" class="state-alert" />
|
||||
<el-card shadow="never">
|
||||
<template #header><span>申请资源访问</span></template>
|
||||
<el-form label-width="100px" class="request-form">
|
||||
<el-form-item label="资源类型">
|
||||
<el-select v-model="form.resource_type" style="width: 220px" @change="resetResource"><el-option label="数据集" value="dataset" /><el-option label="训练模型" value="trained_model" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源">
|
||||
<el-select v-model="form.resource_id" filterable placeholder="选择资源" :loading="resourcesLoading" style="width: min(520px, 100%)" @focus="loadResources"><el-option v-for="item in resourceOptions()" :key="item.id" :label="item.name" :value="item.id" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请权限"><el-checkbox-group v-model="form.requested_permissions"><el-checkbox v-for="item in permissionOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item label="有效期至"><el-date-picker v-model="form.expires_at" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可选" /></el-form-item>
|
||||
<el-form-item label="申请理由"><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="submitting" @click="submit">提交申请</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="history-card">
|
||||
<template #header><div class="history-header"><span>我的访问申请</span><el-select v-model="statusFilter" clearable placeholder="筛选状态" style="width: 130px" @change="load"><el-option label="审批中" value="pending" /><el-option label="已通过" value="approved" /><el-option label="已拒绝" value="rejected" /><el-option label="已撤回" value="cancelled" /><el-option label="已过期" value="expired" /></el-select></div></template>
|
||||
<el-table :data="requests" empty-text="暂无访问申请">
|
||||
<el-table-column label="资源" min-width="220"><template #default="{ row }"><div>{{ resourceName(asRequest(row)) }}</div><small>{{ resourceTypeLabel(asRequest(row).resource_type) }} · {{ asRequest(row).resource_id }}</small></template></el-table-column>
|
||||
<el-table-column label="申请权限" min-width="150"><template #default="{ row }">{{ row.requested_permissions.map(permissionLabel).join('、') }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column><el-table-column prop="created_at" label="申请时间" min-width="180" />
|
||||
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.status === 'pending'" link type="danger" @click="cancel(row.id)">撤销</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.access-request { padding: 16px; }
|
||||
.state-alert { margin-bottom: 16px; }
|
||||
.history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.history-header span { font-weight: 600; }
|
||||
.access-request small { color: #94a3b8; }
|
||||
.request-form { max-width: 760px; }
|
||||
.history-card { margin-top: 16px; }
|
||||
</style>
|
||||
46
frontend/src/views/approvals/TenantInvitationsView.vue
Normal file
46
frontend/src/views/approvals/TenantInvitationsView.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { acceptTenantInvitation, getTenantInvitations, type TenantMember } from '@/api/modules/tenant'
|
||||
|
||||
const loading = ref(false)
|
||||
const accepting = ref('')
|
||||
const invitations = ref<TenantMember[]>([])
|
||||
const error = ref('')
|
||||
const statusLabel = (status: string) => ({ pending: '待接受', expired: '已过期', disabled: '已失效', active: '已加入' }[status] || status)
|
||||
const statusType = (status: string): 'success' | 'warning' | 'danger' | 'info' => status === 'active' ? 'success' : status === 'expired' || status === 'disabled' ? 'danger' : 'warning'
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { error.value = ''; invitations.value = await getTenantInvitations() } catch { error.value = '租户邀请加载失败,请刷新后重试。' } finally { loading.value = false }
|
||||
}
|
||||
async function accept(item: TenantMember) {
|
||||
accepting.value = item.tenant_id
|
||||
try {
|
||||
await acceptTenantInvitation(item.tenant_id, item.user_id)
|
||||
ElMessage.success('已加入租户')
|
||||
await load()
|
||||
} finally { accepting.value = '' }
|
||||
}
|
||||
function asMember(row: unknown) { return row as TenantMember }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" v-loading="loading">
|
||||
<el-alert v-if="error" type="error" show-icon :closable="false" :title="error" class="alert" />
|
||||
<el-table :data="invitations" empty-text="暂无租户邀请">
|
||||
<el-table-column prop="tenant_id" label="租户 ID" min-width="180" />
|
||||
<el-table-column prop="role" label="加入角色" width="120"><template #default="{ row }">{{ row.role === 'admin' ? '租户管理员' : row.role === 'viewer' ? '只读成员' : '租户成员' }}</template></el-table-column>
|
||||
<el-table-column prop="invited_by" label="邀请人" min-width="150" />
|
||||
<el-table-column prop="expires_at" label="有效期至" min-width="180" />
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button v-if="asMember(row).status === 'pending'" link type="primary" :loading="accepting === asMember(row).tenant_id" @click="accept(asMember(row))">接受邀请</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 16px; }
|
||||
.alert { margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -10,6 +10,8 @@ const logs = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const users = ref<SystemUser[]>([])
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const detailLog = ref<AuditLog | null>(null)
|
||||
const query = reactive<AuditQuery>({
|
||||
tenant_id: '',
|
||||
actor_id: '',
|
||||
@@ -50,7 +52,17 @@ const actionOptions = [
|
||||
{ value: 'grant_acl', label: '授予资源权限' },
|
||||
{ value: 'revoke_acl', label: '撤销资源权限' },
|
||||
{ value: 'gpu.assign', label: '分配算力卡' },
|
||||
{ value: 'gpu.assign.request', label: '申请算力卡' },
|
||||
{ value: 'gpu.release', label: '释放算力卡' },
|
||||
{ value: 'approval.decision', label: '处理审批' },
|
||||
{ value: 'dataset.download', label: '下载数据集' },
|
||||
{ value: 'data-process', label: '访问数据处理' },
|
||||
{ value: 'data-convert', label: '访问数据类型转换' },
|
||||
{ value: 'fine-tune', label: '访问模型训练' },
|
||||
{ value: 'model-eval', label: '访问模型评测' },
|
||||
{ value: 'model-inference', label: '访问模型推理' },
|
||||
{ value: 'model-manage', label: '访问模型管理' },
|
||||
{ value: 'dashboard', label: '访问服务看板' },
|
||||
{ value: 'create', label: '创建' },
|
||||
{ value: 'update', label: '修改' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
@@ -83,10 +95,42 @@ const targetTypeOptions = [
|
||||
{ value: 'retention_policy', label: '留存策略' },
|
||||
{ value: 'module', label: '业务模块' },
|
||||
{ value: 'api', label: '接口' },
|
||||
{ value: 'approval_instance', label: '审批实例' },
|
||||
]
|
||||
|
||||
const actionLabels = Object.fromEntries(actionOptions.map((item) => [item.value, item.label]))
|
||||
const targetTypeLabels = Object.fromEntries(targetTypeOptions.map((item) => [item.value, item.label]))
|
||||
const actionVerbLabels: Record<string, string> = {
|
||||
create: '创建',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
start: '启动',
|
||||
stop: '停止',
|
||||
upload: '上传',
|
||||
download: '下载',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
merge: '合并',
|
||||
assign: '分配',
|
||||
release: '释放',
|
||||
request: '申请',
|
||||
approve: '审批通过',
|
||||
reject: '审批拒绝',
|
||||
login: '登录',
|
||||
logout: '退出登录',
|
||||
}
|
||||
const actionObjectLabels: Record<string, string> = {
|
||||
'approval.decision': '处理审批',
|
||||
'dataset.download': '下载数据集',
|
||||
'gpu.assign.request': '申请算力卡',
|
||||
'data-process': '访问数据处理',
|
||||
'data-convert': '访问数据类型转换',
|
||||
'fine-tune': '访问模型训练',
|
||||
'model-eval': '访问模型评测',
|
||||
'model-inference': '访问模型推理',
|
||||
'model-manage': '访问模型管理',
|
||||
dashboard: '访问服务看板',
|
||||
}
|
||||
|
||||
function userName(id?: string) {
|
||||
if (!id) return '系统'
|
||||
@@ -100,13 +144,68 @@ function tenantName(id?: string) {
|
||||
}
|
||||
|
||||
function actionName(action?: string) {
|
||||
return action ? actionLabels[action] || action : '未记录'
|
||||
if (!action) return '未记录'
|
||||
if (actionLabels[action]) return actionLabels[action]
|
||||
if (actionObjectLabels[action]) return actionObjectLabels[action]
|
||||
const parts = action.split('.')
|
||||
const verb = actionVerbLabels[parts[parts.length - 1]]
|
||||
const object = targetTypeName(parts.slice(0, -1).join('_'))
|
||||
return verb ? verb + object : '其他系统操作'
|
||||
}
|
||||
|
||||
function targetTypeName(type?: string) {
|
||||
return type ? targetTypeLabels[type] || type : '未指定'
|
||||
}
|
||||
|
||||
function resultName(result?: string) {
|
||||
if (result === 'success') return '成功'
|
||||
if (result === 'denied') return '已拒绝'
|
||||
if (result === 'failure') return '失败'
|
||||
return result || '已记录'
|
||||
}
|
||||
|
||||
function resultTagType(result?: string) {
|
||||
if (result === 'success') return 'success'
|
||||
if (result === 'denied' || result === 'failure') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function detailDescription(row: AuditLog) {
|
||||
const target = row.target_id
|
||||
? '目标为“' + targetTypeName(row.target_type) + ' ' + row.target_id + '”'
|
||||
: '对象为“' + targetTypeName(row.target_type) + '”'
|
||||
const raw = String(row.detail || '').trim()
|
||||
const translated = raw
|
||||
.replace(/user tombstoned; sessions, memberships and grants revoked/gi, '用户已停用,同时撤销会话、租户成员关系和权限授权')
|
||||
.replace(/module visit/gi, '访问平台业务模块')
|
||||
.replace(/dataset bundle download/gi, '下载数据集文件包')
|
||||
.replace(/evaluation report download/gi, '下载模型评测报告')
|
||||
.replace(/approval decision/gi, '处理审批结果')
|
||||
.replace(/resource request/gi, '提交资源申请')
|
||||
.replace(/engine=merge/gi, '执行权重合并')
|
||||
.replace(/engine=export/gi, '执行模型导出')
|
||||
return actionName(row.action) + ',' + target + ',结果:' + resultName(row.result) + (translated ? ';' + translated : '')
|
||||
}
|
||||
|
||||
function metadataText(value?: string | Record<string, unknown>) {
|
||||
if (!value) return '无'
|
||||
if (typeof value === 'string') {
|
||||
try { return JSON.stringify(JSON.parse(value), null, 2) } catch { return value }
|
||||
}
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function showDetail(row: AuditLog) {
|
||||
detailLog.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function applyTimeRange() {
|
||||
if (timeRange.value && timeRange.value.length === 2) {
|
||||
query.start_time = timeRange.value[0]
|
||||
@@ -169,8 +268,11 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">审计日志</h2>
|
||||
<el-button @click="handleExport">导出 CSV</el-button>
|
||||
<div>
|
||||
<h2 class="page-title">审计记录</h2>
|
||||
<p class="page-subtitle">记录管理员和系统关键操作,支持按用户、资源、结果和来源地址追溯。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleExport">导出 CSV</el-button>
|
||||
</div>
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true" class="filter-form">
|
||||
@@ -195,7 +297,7 @@ onMounted(() => {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="query.keyword" placeholder="资源 ID 或详情" clearable style="width: 220px" />
|
||||
<el-input v-model="query.keyword" placeholder="资源名称、操作说明或 ID" clearable style="width: 240px" @keyup.enter="load" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标 ID">
|
||||
<el-input v-model="query.target_id" placeholder="精确查询,可选" clearable style="width: 180px" />
|
||||
@@ -220,7 +322,9 @@ onMounted(() => {
|
||||
</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 label="时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="租户" min-width="140">
|
||||
<template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
|
||||
</el-table-column>
|
||||
@@ -233,11 +337,45 @@ onMounted(() => {
|
||||
<el-table-column label="目标类型" min-width="120">
|
||||
<template #default="{ row }">{{ targetTypeName(row.target_type) }}</template>
|
||||
</el-table-column>
|
||||
<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-column label="操作说明" min-width="300" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ detailDescription(row as AuditLog) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="resultTagType(row.result)" size="small">{{ resultName(row.result) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源 IP" width="130" prop="client_ip" />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="showDetail(row as AuditLog)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">共 {{ total }} 条</div>
|
||||
<div class="pager">
|
||||
<span>共 {{ total }} 条</span>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="query.limit" :current-page="Math.floor((query.offset || 0) / (query.limit || 50)) + 1" @current-change="(page: number) => { query.offset = (page - 1) * (query.limit || 50); void load() }" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="审计记录详情" width="820px">
|
||||
<el-descriptions v-if="detailLog" :column="2" border>
|
||||
<el-descriptions-item label="发生时间">{{ formatTime(detailLog.time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果">
|
||||
<el-tag :type="resultTagType(detailLog.result)" size="small">{{ resultName(detailLog.result) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作人">{{ userName(detailLog.actor_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属租户">{{ tenantName(detailLog.tenant_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源 IP">{{ detailLog.client_ip || '未记录' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求 ID">{{ detailLog.request_id || '未记录' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作">{{ actionName(detailLog.action) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资源类型">{{ targetTypeName(detailLog.target_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标 ID">{{ detailLog.target_id || '未指定' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="中文说明" :span="2">{{ detailDescription(detailLog) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="失败原因" :span="2">{{ detailLog.reason || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="原始详情" :span="2"><pre class="detail-box">{{ detailLog.detail || '无' }}</pre></el-descriptions-item>
|
||||
<el-descriptions-item label="扩展信息" :span="2"><pre class="detail-box">{{ metadataText(detailLog.metadata) }}</pre></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -245,8 +383,10 @@ onMounted(() => {
|
||||
.page { padding: 16px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.page-title { margin: 0; font-size: 18px; }
|
||||
.page-subtitle { margin: 6px 0 0; color: #909399; font-size: 13px; }
|
||||
.filter-card { margin-bottom: 16px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; }
|
||||
.log-table { margin-top: 8px; }
|
||||
.pager { margin-top: 12px; text-align: right; color: #909399; }
|
||||
.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; color: #909399; }
|
||||
.detail-box { max-height: 180px; margin: 0; padding: 8px 10px; overflow: auto; white-space: pre-wrap; word-break: break-all; color: #606266; background: #f5f7fa; border-radius: 4px; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
getOperationLogs,
|
||||
getOperationLogStats,
|
||||
@@ -10,17 +11,19 @@ import {
|
||||
} from '@/api/modules/operation-log'
|
||||
|
||||
const loading = ref(false)
|
||||
const auth = useAuthStore()
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
const statsLoading = ref(false)
|
||||
const logs = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const stats = ref<OperationLogStats | null>(null)
|
||||
|
||||
// 默认筛选:只看失败
|
||||
// 管理员默认查看完整操作流,失败记录通过状态筛选快速定位。
|
||||
const query = reactive<OperationLogQuery>({
|
||||
user_id: '',
|
||||
module: '',
|
||||
action: '',
|
||||
status: 'failure',
|
||||
status: '',
|
||||
keyword: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
@@ -143,6 +146,12 @@ function actionLabel(action?: string) {
|
||||
return actionOptions.find((a) => a.value === action)?.label || action || '-'
|
||||
}
|
||||
|
||||
function operationDescription(row: OperationLog) {
|
||||
const target = row.target_name || row.target_id
|
||||
const suffix = target ? ',对象为“' + target + '”' : ''
|
||||
return moduleLabel(row.module) + ':' + actionLabel(row.action) + suffix + ',结果:' + (row.status === 'success' ? '成功' : '失败')
|
||||
}
|
||||
|
||||
function toggleOnlyFailures() {
|
||||
query.status = onlyFailures.value ? '' : 'failure'
|
||||
handleSearch()
|
||||
@@ -157,7 +166,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">系统操作日志</h2>
|
||||
<h2 class="page-title">{{ isAdmin ? '系统操作日志' : '我的操作日志' }}</h2>
|
||||
<el-button :type="onlyFailures ? 'danger' : 'default'" @click="toggleOnlyFailures">
|
||||
{{ onlyFailures ? '只看失败 ✅' : '显示全部' }}
|
||||
</el-button>
|
||||
@@ -237,7 +246,7 @@ onMounted(() => {
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户">
|
||||
<el-form-item v-if="isAdmin" label="用户">
|
||||
<el-input v-model="query.user_id" placeholder="用户ID/用户名" clearable style="width: 140px" @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
@@ -273,7 +282,7 @@ onMounted(() => {
|
||||
{{ row.create_time ? new Date(row.create_time).toLocaleString('zh-CN') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" width="110" align="center" show-overflow-tooltip>
|
||||
<el-table-column v-if="isAdmin" label="用户" width="110" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.username || row.user_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模块" width="110" align="center">
|
||||
@@ -285,6 +294,9 @@ onMounted(() => {
|
||||
<el-table-column label="目标" min-width="140" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.target_name || row.target_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源 IP" width="125" align="center">
|
||||
<template #default="{ row }">{{ row.client_ip || '未记录' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small" effect="dark">
|
||||
@@ -369,6 +381,7 @@ onMounted(() => {
|
||||
<span v-else style="color: #c0c4cc">无堆栈信息</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作详情" :span="2">
|
||||
<div class="detail-summary">{{ operationDescription(detailLog) }}</div>
|
||||
<pre v-if="detailLog.detail" class="detail-box">{{ detailLog.detail }}</pre>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</el-descriptions-item>
|
||||
@@ -428,6 +441,15 @@ onMounted(() => {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-summary {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
color: #303133;
|
||||
background: #f0f9ff;
|
||||
border-left: 3px solid #409eff;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
:deep(.failure-row) {
|
||||
background-color: #fef0f0 !important;
|
||||
}
|
||||
|
||||
@@ -200,7 +200,13 @@ onMounted(load)
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="output_filename" label="输出文件名" min-width="160" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
<el-table-column label="上传人" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.creator_name || row.created_by || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理人" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.processor_name || row.processed_by || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-upload
|
||||
|
||||
@@ -145,8 +145,14 @@ onMounted(() => void loadData())
|
||||
<el-table-column label="生成个数" align="center" width="120">
|
||||
<template #default="{ row }">{{ generatedCountLabel(row as DataProcessTask) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" align="center" width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time || row.created_at) }}</template>
|
||||
<el-table-column label="创建时间" align="center" width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time || row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传/创建人" align="center" width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.creator_name || row.created_by || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后处理人" align="center" width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.processor_name || row.updated_by || '-' }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -262,9 +262,14 @@ onMounted(loadData)
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" align="center" width="180">
|
||||
<el-table-column label="创建时间" align="center" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.create_time ? new Date(row.create_time).toLocaleString('zh-CN') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传人" align="center" width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.create_time ? new Date(row.create_time).toLocaleString('zh-CN') : '-' }}
|
||||
{{ row.creator_name || row.created_by || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ import EvalTaskSetupStep, { type EvalTaskSetupDraft } from './create/EvalTaskSet
|
||||
import EvalRuleSetupStep, { type EvalRuleSetupDraft } from './create/EvalRuleSetupStep.vue'
|
||||
import BasicMetricSetupStep, { type BasicMetricSetupDraft } from './create/BasicMetricSetupStep.vue'
|
||||
import StartEvalStep from './create/StartEvalStep.vue'
|
||||
import { createDimension, startEval } from '@/api/modules/eval'
|
||||
import { startEval } from '@/api/modules/eval'
|
||||
import { getTrainedModels, getModelList } from '@/api/modules/model'
|
||||
import { getDatasetList } from '@/api/modules/dataset'
|
||||
import { getComputeGpus } from '@/api/modules/compute'
|
||||
@@ -34,7 +34,6 @@ const trainedModels = ref<TrainedModel[]>([])
|
||||
const evalDatasets = ref<DatasetItem[]>([])
|
||||
const evalModels = ref<ModelItem[]>([])
|
||||
const gpus = ref<GpuInfo[]>([])
|
||||
const createdDimensionId = ref<string | number>('')
|
||||
|
||||
const taskForm = ref<EvalTaskSetupDraft>({
|
||||
eval_task_name: '',
|
||||
@@ -69,14 +68,6 @@ const basicMetricForm = ref<BasicMetricSetupDraft>({
|
||||
output_precision: 3,
|
||||
})
|
||||
|
||||
watch(
|
||||
ruleForm,
|
||||
() => {
|
||||
createdDimensionId.value = ''
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
const results = await Promise.allSettled([
|
||||
@@ -127,22 +118,11 @@ function buildDimensionPayload() {
|
||||
return payload
|
||||
}
|
||||
|
||||
async function resolveDimensionId() {
|
||||
if (createdDimensionId.value !== '') return createdDimensionId.value
|
||||
|
||||
const created = await createDimension(buildDimensionPayload())
|
||||
if (created?.id === undefined || created.id === null || created.id === '') {
|
||||
throw new Error('评测维度已提交,但未返回维度 ID,无法启动评测任务')
|
||||
}
|
||||
createdDimensionId.value = created.id
|
||||
return created.id
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading.value || submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const dimensionId = await resolveDimensionId()
|
||||
const dimension = buildDimensionPayload()
|
||||
// GPU 选择为「节点:GPU序号」复合值,解析出节点与 GPU 序号,
|
||||
// 多算力节点时必须把节点信息传给后端,否则会派发到错误的算力节点
|
||||
const selectedGpuKeys = Array.isArray(taskForm.value.gpu_id)
|
||||
@@ -165,7 +145,7 @@ async function handleSubmit() {
|
||||
gpus: gpuIndices,
|
||||
compute_node_id: gpuNodeId || '',
|
||||
dataset_id: taskForm.value.data_source === 'dataset' ? taskForm.value.dataset_id : '',
|
||||
dimension_id: dimensionId,
|
||||
dimension,
|
||||
data_source: taskForm.value.data_source,
|
||||
leaderboard: taskForm.value.leaderboard,
|
||||
basic_metrics: {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import VChart from 'vue-echarts'
|
||||
import '@/plugins/echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||
import { getEvalDetail } from '@/api/modules/eval'
|
||||
@@ -52,6 +55,48 @@ const passRate = computed(() => {
|
||||
const overallScore = computed(() => formatScore(detail.value?.overall_score, detail.value?.overall_score_max))
|
||||
const displayModelName = computed(() => detail.value?.model_name || String(detail.value?.model_id || '-'))
|
||||
const displayMetric = computed(() => detail.value?.metric_label || detail.value?.metric || '-')
|
||||
const progressDetail = computed(() => detail.value?.progress_detail)
|
||||
const progressPercentage = computed(() => Math.max(0, Math.min(100, Math.round(Number(progressDetail.value?.percentage ?? completionRate.value)))))
|
||||
const progressStage = computed(() => ({
|
||||
dataset: '准备数据集',
|
||||
model_loading: '加载模型',
|
||||
inference: '生成回答',
|
||||
metrics: '计算指标',
|
||||
completed: '评测完成',
|
||||
failed: '评测失败',
|
||||
}[String(progressDetail.value?.stage || '')] || (detail.value?.status === 'running' ? '任务运行中' : '等待开始')))
|
||||
|
||||
const radarDimensions = computed(() => (detail.value?.dimension_summary || [])
|
||||
.filter((item) => item.available !== false && Number.isFinite(Number(item.score)))
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
value: Math.max(0, Math.min(100, Number(item.score) / Math.max(Number(item.max_score) || 100, 1) * 100)),
|
||||
})))
|
||||
|
||||
const radarOption = computed<EChartsOption>(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
radar: {
|
||||
indicator: radarDimensions.value.map((item) => ({ name: item.name, max: 100 })),
|
||||
radius: '62%',
|
||||
splitNumber: 4,
|
||||
axisName: { color: '#667085', fontSize: 11 },
|
||||
splitArea: { areaStyle: { color: ['#fbfbfd', '#f2f4f8'] } },
|
||||
splitLine: { lineStyle: { color: '#e4e7ec' } },
|
||||
axisLine: { lineStyle: { color: '#e4e7ec' } },
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
data: [{
|
||||
value: radarDimensions.value.map((item) => item.value),
|
||||
name: '评测指标',
|
||||
areaStyle: { color: 'rgba(79, 70, 229, 0.18)' },
|
||||
lineStyle: { color: '#4f46e5', width: 2 },
|
||||
itemStyle: { color: '#4f46e5' },
|
||||
}],
|
||||
}],
|
||||
}))
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return '-'
|
||||
@@ -152,8 +197,9 @@ onUnmounted(stopPolling)
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>评测进度</span>
|
||||
<strong>{{ detail.completed_count }} / {{ detail.sample_count }}</strong>
|
||||
<el-progress :percentage="completionRate" :show-text="false" :stroke-width="5" />
|
||||
<strong>{{ progressDetail?.completed ?? detail.completed_count }} / {{ progressDetail?.total ?? detail.sample_count }}</strong>
|
||||
<el-progress :percentage="progressPercentage" :show-text="false" :stroke-width="5" />
|
||||
<small>{{ progressStage }}{{ progressDetail?.message ? ' · ' + progressDetail.message : '' }}</small>
|
||||
</div>
|
||||
<div class="overview-item overview-time">
|
||||
<span>{{ detail.completed_time ? '完成时间' : '创建时间' }}</span>
|
||||
@@ -196,13 +242,18 @@ onUnmounted(stopPolling)
|
||||
<p>查看各评测指标的得分与通过率</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dimension-grid">
|
||||
<div v-for="dimension in detail.dimension_summary" :key="dimension.name" class="dimension-item">
|
||||
<div class="dimension-label">
|
||||
<strong>{{ dimension.name }}</strong>
|
||||
<span>{{ formatScore(dimension.score, dimension.max_score) }}</span>
|
||||
<div class="dimension-layout">
|
||||
<VChart v-if="radarDimensions.length >= 3" class="evaluation-radar" :option="radarOption" autoresize />
|
||||
<div v-else class="radar-fallback">可用指标少于 3 个,暂以指标明细展示。</div>
|
||||
<div class="dimension-grid">
|
||||
<div v-for="dimension in detail.dimension_summary" :key="dimension.name" class="dimension-item">
|
||||
<div class="dimension-label">
|
||||
<strong>{{ dimension.name }}</strong>
|
||||
<span>{{ formatScore(dimension.score, dimension.max_score) }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="dimension.pass_rate" :stroke-width="7" />
|
||||
<small v-if="dimension.available === false" class="metric-error">{{ dimension.error || '指标依赖不可用' }}</small>
|
||||
</div>
|
||||
<el-progress :percentage="dimension.pass_rate" :stroke-width="7" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -491,6 +542,41 @@ onUnmounted(stopPolling)
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dimension-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.85fr) minmax(0, 1.6fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.evaluation-radar,
|
||||
.radar-fallback {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.radar-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-error {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #e6a23c;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dimension-item {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
@@ -665,6 +751,10 @@ onUnmounted(stopPolling)
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dimension-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dimension-item:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,19 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="评分" prop="score" width="100" align="center" />
|
||||
<el-table-column label="评测进度" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="ACTIVE_STATUSES.has(String(row.status || ''))">
|
||||
<el-progress
|
||||
:percentage="Math.max(0, Math.min(100, Math.round(Number(row.progress_detail?.percentage ?? row.progress ?? 0))))"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
/>
|
||||
<small>{{ row.progress_detail?.completed ?? 0 }} / {{ row.progress_detail?.total ?? '-' }}</small>
|
||||
</template>
|
||||
<span v-else>{{ row.score == null ? '-' : Number(row.score).toFixed(2) + ' / 100' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<ModelStatusTag :status="row.status" />
|
||||
|
||||
@@ -56,7 +56,8 @@ const availableGpus = computed(() => {
|
||||
)
|
||||
result = result.filter((gpu) => {
|
||||
const key = `${gpu.node_id}:${gpu.id ?? gpu.uuid ?? gpu.name}`
|
||||
return assignedKeys.has(key) || myAssignedGpus.value.length === 0
|
||||
// 未完成算力审批时不展示未授权 GPU,避免接口失败时回退为全量资源。
|
||||
return assignedKeys.has(key)
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -20,7 +20,7 @@ const activeTab = computed({
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h2>组织与权限</h2>
|
||||
<p>统一管理平台用户、角色、租户和资源配额。</p>
|
||||
<p>统一管理平台身份、租户成员、角色和资源配额。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } 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'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref('')
|
||||
const auth = useAuthStore()
|
||||
|
||||
// 资源类型选项
|
||||
const resourceTypes = [
|
||||
@@ -32,6 +35,7 @@ const form = reactive({
|
||||
const aclEntries = ref<AclEntry[]>([])
|
||||
const showAddEntry = ref(false)
|
||||
const newEntry = reactive({
|
||||
principal_type: 'user' as 'user' | 'role',
|
||||
principal_id: '',
|
||||
permissions: ['read', 'execute'] as string[],
|
||||
})
|
||||
@@ -44,6 +48,11 @@ const permissionOptions = [
|
||||
{ label: '删除 (delete)', value: 'delete' },
|
||||
{ label: '全部权限 (admin)', value: 'admin' },
|
||||
]
|
||||
const roleOptions = [
|
||||
{ label: '租户管理员', value: 'admin' },
|
||||
{ label: '租户成员', value: 'member' },
|
||||
{ label: '租户只读', value: 'viewer' },
|
||||
]
|
||||
|
||||
// 加载所有数据
|
||||
async function loadAll() {
|
||||
@@ -91,6 +100,7 @@ async function onResourceChange() {
|
||||
|
||||
// 加载第一个资源的 ACL 作为初始值(多个资源的 ACL 合并显示)
|
||||
try {
|
||||
loadError.value = ''
|
||||
const res = await getResourceAcl(form.resourceType, form.resourceIds[0])
|
||||
if (Array.isArray(res)) {
|
||||
aclEntries.value = res.map((e: any) => ({
|
||||
@@ -108,7 +118,7 @@ async function onResourceChange() {
|
||||
aclEntries.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载 ACL 失败:', e)
|
||||
loadError.value = '当前资源授权读取失败,请确认你拥有该资源的查看权限。'
|
||||
aclEntries.value = []
|
||||
}
|
||||
}
|
||||
@@ -118,30 +128,32 @@ function onTypeChange() {
|
||||
form.resourceIds = []
|
||||
form.resourceNames = ''
|
||||
aclEntries.value = []
|
||||
loadError.value = ''
|
||||
}
|
||||
|
||||
// 添加授权条目
|
||||
function addEntry() {
|
||||
const trimmedId = (newEntry.principal_id || '').trim()
|
||||
if (!trimmedId) {
|
||||
ElMessage.warning('请选择要授权的用户')
|
||||
ElMessage.warning(`请选择要授权的${newEntry.principal_type === 'role' ? '角色' : '用户'}`)
|
||||
return
|
||||
}
|
||||
if (!newEntry.permissions || newEntry.permissions.length === 0) {
|
||||
ElMessage.warning('请至少选择一个权限')
|
||||
return
|
||||
}
|
||||
const exists = aclEntries.value.some((e) => e.principal_id === trimmedId)
|
||||
const exists = aclEntries.value.some((e) => e.principal_type === newEntry.principal_type && e.principal_id === trimmedId)
|
||||
if (exists) {
|
||||
ElMessage.warning('该用户已存在,请先删除再重新添加')
|
||||
return
|
||||
}
|
||||
aclEntries.value.push({
|
||||
principal_type: 'user',
|
||||
principal_type: newEntry.principal_type,
|
||||
principal_id: trimmedId,
|
||||
permissions: [...newEntry.permissions],
|
||||
})
|
||||
showAddEntry.value = false
|
||||
newEntry.principal_type = 'user'
|
||||
newEntry.principal_id = ''
|
||||
newEntry.permissions = ['read', 'execute']
|
||||
}
|
||||
@@ -157,10 +169,7 @@ async function saveAcl() {
|
||||
ElMessage.warning('请先选择资源')
|
||||
return
|
||||
}
|
||||
if (aclEntries.value.length === 0) {
|
||||
ElMessage.warning('请至少添加一个授权用户')
|
||||
return
|
||||
}
|
||||
if (aclEntries.value.length === 0 && !await ElMessageBox.confirm('当前配置为空,将撤销该资源全部 ACL,是否继续?', '撤销全部授权', { type: 'warning' }).catch(() => false)) return
|
||||
saving.value = true
|
||||
try {
|
||||
// 为每个选中的资源保存相同的 ACL 授权
|
||||
@@ -178,6 +187,7 @@ async function saveAcl() {
|
||||
// 显示名称
|
||||
function principalName(entry: unknown) {
|
||||
const aclEntry = entry as AclEntry
|
||||
if (aclEntry.principal_type === 'role') return roleOptions.find((item) => item.value === aclEntry.principal_id)?.label || `角色:${aclEntry.principal_id}`
|
||||
const user = users.value.find((u) => u.id === aclEntry.principal_id)
|
||||
return user ? `${user.display_name || user.username}` : aclEntry.principal_id
|
||||
}
|
||||
@@ -191,6 +201,7 @@ onMounted(loadAll)
|
||||
<p class="page-desc">将数据集或微调模型授权给指定用户使用。被授权的用户可以在自己的页面看到并使用该资源。</p>
|
||||
|
||||
<!-- 步骤1:选择资源 -->
|
||||
<el-alert v-if="loadError" type="error" show-icon :closable="false" :title="loadError" class="section-card" />
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span>① 选择要授权的资源</span>
|
||||
@@ -228,15 +239,16 @@ onMounted(loadAll)
|
||||
<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>
|
||||
<el-button v-if="auth.can('user-settings', 'write')" 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="用户">
|
||||
<el-table-column label="授权主体" min-width="180">
|
||||
<template #default="{ row }">{{ principalName(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主体类型" width="100"><template #default="{ row }">{{ row.principal_type === 'role' ? '租户角色' : '用户' }}</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">
|
||||
@@ -258,7 +270,7 @@ onMounted(loadAll)
|
||||
</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>
|
||||
<el-button v-if="auth.can('user-settings', 'write')" type="danger" size="small" link @click="removeEntry($index)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -267,19 +279,23 @@ onMounted(loadAll)
|
||||
<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-form-item label="主体类型">
|
||||
<el-radio-group v-model="newEntry.principal_type"><el-radio value="user">用户</el-radio><el-radio value="role">租户角色</el-radio></el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="newEntry.principal_type === 'role' ? '选择角色' : '选择用户'">
|
||||
<el-select
|
||||
v-model="newEntry.principal_id"
|
||||
filterable
|
||||
placeholder="选择要授权的用户"
|
||||
style="width: 240px"
|
||||
>
|
||||
<el-option
|
||||
<el-option v-if="newEntry.principal_type === 'user'"
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
:label="`${u.display_name || u.username} (${u.username})`"
|
||||
:value="u.id"
|
||||
/>
|
||||
<el-option v-else v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限">
|
||||
@@ -296,7 +312,7 @@ onMounted(loadAll)
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="save-bar">
|
||||
<el-button type="primary" :loading="saving" @click="saveAcl">保存授权配置</el-button>
|
||||
<el-button v-if="auth.can('user-settings', 'write')" type="primary" :loading="saving" @click="saveAcl">保存授权配置</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -12,17 +12,21 @@ import {
|
||||
} from '@/api/modules/model'
|
||||
import { MODEL_TYPE_MAP } from '@/constants'
|
||||
import type { ModelForm, ModelSource } from '@/types'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const editId = computed(() => route.params.id as string | undefined)
|
||||
const canRegisterLocalModel = computed(() => auth.isAdmin)
|
||||
|
||||
const localModels = ref<{ path: string; name: string; source?: string }[]>([])
|
||||
const apiKeyConfigured = ref(false)
|
||||
|
||||
const form = reactive<ModelForm>({
|
||||
name: '',
|
||||
@@ -62,7 +66,7 @@ function getSourceRules(): FormRules {
|
||||
}
|
||||
return {
|
||||
api_url: [{ required: true, message: '请输入 API 地址', trigger: 'blur' }],
|
||||
api_key: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
|
||||
api_key: [{ required: !isEdit.value || !apiKeyConfigured.value, message: '请输入 API Key', trigger: 'blur' }],
|
||||
online_model_name: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
|
||||
}
|
||||
}
|
||||
@@ -84,9 +88,12 @@ async function loadEditData() {
|
||||
description: model.description || '',
|
||||
path: model.path || '',
|
||||
api_url: model.api_url || '',
|
||||
api_key: model.api_key || '',
|
||||
// The API never returns the credential. Keep the input empty and only
|
||||
// send a replacement when the administrator explicitly enters one.
|
||||
api_key: '',
|
||||
online_model_name: model.online_model_name || '',
|
||||
})
|
||||
apiKeyConfigured.value = Boolean(model.api_key_configured)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
@@ -125,7 +132,11 @@ async function handleSubmit() {
|
||||
data.path = form.path
|
||||
} else {
|
||||
data.api_url = form.api_url
|
||||
data.api_key = form.api_key
|
||||
if (form.api_key?.trim()) {
|
||||
data.api_key = form.api_key
|
||||
} else if (!isEdit.value) {
|
||||
data.api_key = ''
|
||||
}
|
||||
data.online_model_name = form.online_model_name
|
||||
}
|
||||
if (isEdit.value && editId.value) {
|
||||
@@ -179,6 +190,9 @@ function handleCancel() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!canRegisterLocalModel.value && form.model_source === 'local') {
|
||||
form.model_source = 'api'
|
||||
}
|
||||
loadLocalModels()
|
||||
loadEditData()
|
||||
})
|
||||
@@ -213,18 +227,18 @@ onMounted(() => {
|
||||
|
||||
<el-form-item label="模型来源" prop="model_source">
|
||||
<el-radio-group v-model="form.model_source">
|
||||
<el-radio value="local">本地模型</el-radio>
|
||||
<el-radio value="local" :disabled="!canRegisterLocalModel">本地模型(管理员登记后平台共享)</el-radio>
|
||||
<el-radio value="api">在线模型</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 本地模型 -->
|
||||
<template v-if="form.model_source === 'local'">
|
||||
<el-form-item label="本地模型路径" prop="path">
|
||||
<el-form-item label="模型目录" prop="path">
|
||||
<div class="model-path-row">
|
||||
<el-select
|
||||
v-model="form.path"
|
||||
placeholder="请选择或输入 /data/yg-ft/models 下的模型路径"
|
||||
placeholder="请选择已登记的模型目录"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
@@ -234,13 +248,13 @@ onMounted(() => {
|
||||
<el-option
|
||||
v-for="m in localModels"
|
||||
:key="m.path"
|
||||
:label="`${m.name} (${m.path})${m.source ? ` - ${m.source}` : ''}`"
|
||||
:label="m.name"
|
||||
:value="m.path"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button @click="loadLocalModels">刷新</el-button>
|
||||
<el-tooltip
|
||||
content="模型目录需要位于算力容器可访问路径,例如默认挂载目录 docker/compute/data/yg-ft/models 对应容器内 /data/yg-ft/models。浏览器不能把电脑任意本地目录直接作为训练路径。"
|
||||
content="模型创建后会自动归档到 MinIO 共享存储,后续算力节点从共享存储准备本地缓存,无需绑定特定算力节点。"
|
||||
placement="top"
|
||||
>
|
||||
<i class="fa fa-info-circle model-path-help" aria-hidden="true" />
|
||||
@@ -255,7 +269,12 @@ onMounted(() => {
|
||||
<el-input v-model="form.api_url" placeholder="如:https://api.openai.com/v1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key" prop="api_key">
|
||||
<el-input v-model="form.api_key" type="password" show-password placeholder="请输入 API Key" />
|
||||
<el-input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="isEdit && apiKeyConfigured ? '已配置 API Key,留空则保持不变' : '请输入 API Key'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型名称" prop="online_model_name">
|
||||
<div class="model-test-row">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import {
|
||||
deleteModel,
|
||||
deleteTrainedModel,
|
||||
exportModel,
|
||||
getModelExportJobs,
|
||||
getModelList,
|
||||
getTrainedModelArtifacts,
|
||||
@@ -18,8 +19,12 @@ import {
|
||||
import { MODEL_SOURCE_MAP, MODEL_TYPE_MAP, PURPOSE_MAP } from '@/constants'
|
||||
import type { ModelItem, TrainedModel } from '@/types'
|
||||
import { mergeStatusLabel, mergeStatusType, statusLabel, statusTagType } from '@/utils/status'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canManageModels = computed(() => auth.can('model-manage'))
|
||||
const canManageConfigModels = computed(() => auth.can('model-manage', 'admin'))
|
||||
|
||||
type TabKey = 'config' | 'trained'
|
||||
type TrainedModelRuntime = {
|
||||
@@ -35,6 +40,7 @@ const loading = ref(false)
|
||||
const configList = ref<ModelItem[]>([])
|
||||
const trainedList = ref<TrainedModel[]>([])
|
||||
const runtimeMap = reactive<Record<string, TrainedModelRuntime>>({})
|
||||
const exportingId = ref('')
|
||||
|
||||
function asModelItem(row: unknown): ModelItem {
|
||||
return row as ModelItem
|
||||
@@ -87,6 +93,7 @@ function artifactTypeLabel(value?: string) {
|
||||
const map: Record<string, string> = {
|
||||
adapter: 'Adapter 权重',
|
||||
merged_model: '合并模型',
|
||||
exported_model: '导出模型',
|
||||
quantized_model: '量化模型',
|
||||
}
|
||||
return map[value || ''] || value || '-'
|
||||
@@ -101,6 +108,29 @@ async function loadConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(row: TrainedModel) {
|
||||
const id = String(row.id || row.name)
|
||||
if (!row.base_model_path && !row.merged_path) {
|
||||
ElMessage.warning('缺少可导出的模型路径')
|
||||
return
|
||||
}
|
||||
exportingId.value = id
|
||||
try {
|
||||
await exportModel({
|
||||
trained_model_id: row.id || row.name,
|
||||
model_name: row.name,
|
||||
base_model_path: row.merged ? row.merged_path : row.base_model_path,
|
||||
adapter_path: row.merged ? '' : (row.artifact_dir || row.adapter_path || row.merged_path || ''),
|
||||
compute_node_id: row.compute_node_id,
|
||||
output_model_name: `${row.name}-export`,
|
||||
})
|
||||
ElMessage.success('导出任务已提交')
|
||||
await loadTrainedRuntime(row, true)
|
||||
} finally {
|
||||
exportingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrained() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -177,7 +207,7 @@ function editModel(row: ModelItem) {
|
||||
}
|
||||
|
||||
function handleCreateClick() {
|
||||
if (activeTab.value === 'config') {
|
||||
if (activeTab.value === 'config' && canManageModels.value) {
|
||||
router.push('/model-manage/create')
|
||||
}
|
||||
}
|
||||
@@ -201,7 +231,7 @@ onMounted(loadData)
|
||||
:loading="loading"
|
||||
searchable
|
||||
:search-fields="['name', 'description']"
|
||||
create-text="添加模型"
|
||||
:create-text="canManageModels ? '添加模型' : ''"
|
||||
:delete-fn="handleDeleteConfig"
|
||||
row-key="id"
|
||||
@create="handleCreateClick"
|
||||
@@ -238,10 +268,10 @@ onMounted(loadData)
|
||||
</el-table-column>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button type="primary" link size="small" @click="editModel(asModelItem(row))">
|
||||
<el-button v-if="canManageConfigModels" type="primary" link size="small" @click="editModel(asModelItem(row))">
|
||||
<i class="fa fa-edit" /> 编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDeleteConfig(asModelItem(row))">
|
||||
<el-button v-if="canManageConfigModels" type="danger" link size="small" @click="handleDeleteConfig(asModelItem(row))">
|
||||
<i class="fa fa-trash-o" /> 删除
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -348,11 +378,14 @@ onMounted(loadData)
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button v-if="!row.merged && !row.merging" type="primary" link size="small" @click="handleMerge(asTrainedModel(row))">
|
||||
<el-button v-if="canManageModels && !row.merged && !row.merging" type="primary" link size="small" @click="handleMerge(asTrainedModel(row))">
|
||||
<i class="fa fa-code-fork" /> 合并权重
|
||||
</el-button>
|
||||
<el-button v-else-if="row.merging" type="info" link size="small" :loading="true" disabled>合并中</el-button>
|
||||
<el-button type="warning" link size="small" @click="handleDeleteWeight(asTrainedModel(row))">
|
||||
<el-button v-if="canManageModels && row.merged && !row.merging" type="success" link size="small" :loading="exportingId === String(row.id || row.name)" @click="handleExport(asTrainedModel(row))">
|
||||
<i class="fa fa-download" /> 导出
|
||||
</el-button>
|
||||
<el-button v-if="canManageModels" type="warning" link size="small" @click="handleDeleteWeight(asTrainedModel(row))">
|
||||
<i class="fa fa-eraser" /> 删除权重
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,8 @@ const trainContent = ref('')
|
||||
const keyword = ref('')
|
||||
const level = ref('') // 日志级别筛选:INFO/WARN/ERROR/空=全部
|
||||
const fullContent = ref('')
|
||||
const loading = ref(false)
|
||||
const lastRefreshedAt = ref('')
|
||||
|
||||
// 自动刷新
|
||||
const refreshInterval = ref(10)
|
||||
@@ -49,6 +51,15 @@ const filteredLog = computed(() => {
|
||||
|
||||
const filteredContent = computed(() => filteredLog.value.content)
|
||||
const matchCount = computed(() => filteredLog.value.count)
|
||||
const currentFile = computed(() => {
|
||||
const files = activeTab.value === 'system' ? sysFiles.value : trainFiles.value
|
||||
const selected = activeTab.value === 'system' ? sysSelected.value : trainSelected.value
|
||||
return files.find((file) => file.file === selected)
|
||||
})
|
||||
const displayedLineCount = computed(() => {
|
||||
const content = filteredContent.value
|
||||
return content ? content.split('\n').length : 0
|
||||
})
|
||||
|
||||
async function loadSysFiles() {
|
||||
try {
|
||||
@@ -67,12 +78,16 @@ async function loadSysFiles() {
|
||||
|
||||
async function loadSysContent() {
|
||||
if (!sysSelected.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getLogContent(sysSelected.value)
|
||||
fullContent.value = res.content || ''
|
||||
sysContent.value = fullContent.value
|
||||
} catch {
|
||||
// ignore
|
||||
sysContent.value = '日志读取失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
lastRefreshedAt.value = new Date().toLocaleTimeString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +108,16 @@ async function loadTrainFiles() {
|
||||
|
||||
async function loadTrainContent() {
|
||||
if (!trainSelected.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTrainingLogContent(trainSelected.value)
|
||||
fullContent.value = res.content || ''
|
||||
trainContent.value = fullContent.value
|
||||
} catch {
|
||||
// ignore
|
||||
trainContent.value = '日志读取失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
lastRefreshedAt.value = new Date().toLocaleTimeString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +161,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageCard title="查看日志">
|
||||
<PageCard title="运行日志" subtitle="查看系统和训练任务的实时输出,支持按级别与关键词快速定位问题。">
|
||||
<!-- Tab 切换 -->
|
||||
<el-tabs v-model="activeTab" style="margin-bottom: 16px">
|
||||
<el-tab-pane label="系统日志" name="system" />
|
||||
@@ -158,7 +177,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="option-group">
|
||||
<span class="option-label">日志类型:</span>
|
||||
<span class="option-label">日志文件:</span>
|
||||
<el-select v-if="activeTab === 'system'" v-model="sysSelected" placeholder="请选择日志文件" style="width: 320px">
|
||||
<el-option v-for="f in sysFiles" :key="f.file" :label="`${f.name} (${f.size})`" :value="f.file" />
|
||||
</el-select>
|
||||
@@ -177,7 +196,10 @@ onMounted(() => {
|
||||
<el-option :value="60" label="60秒" />
|
||||
</el-select>
|
||||
<span v-if="refreshInterval > 0" class="countdown">下次刷新: {{ remaining }}秒</span>
|
||||
<el-button @click="refresh">立即刷新</el-button>
|
||||
<el-button :loading="loading" @click="refresh">
|
||||
<i class="fa fa-refresh" />
|
||||
立即刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 日志内容 -->
|
||||
@@ -194,6 +216,12 @@ onMounted(() => {
|
||||
<el-option value="DEBUG" label="DEBUG" />
|
||||
</el-select>
|
||||
<span v-if="keyword || level" class="match-count">{{ matchCount }} 条匹配</span>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span>{{ currentFile?.name || '未选择日志文件' }}</span>
|
||||
<span v-if="currentFile">文件大小 {{ currentFile.size }}</span>
|
||||
<span>显示 {{ displayedLineCount }} 行</span>
|
||||
<span v-if="lastRefreshedAt">最近刷新 {{ lastRefreshedAt }}</span>
|
||||
</div>
|
||||
<pre class="log-pre">{{ filteredContent || (activeTab === 'system' ? sysContent : trainContent) || '日志内容将在这里显示...' }}</pre>
|
||||
</div>
|
||||
@@ -243,6 +271,17 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.log-pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
|
||||
@@ -28,21 +28,24 @@ const activeTab = computed({
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h2>运行日志</h2>
|
||||
<p>查看系统运行、训练任务、审计记录和操作诊断信息。</p>
|
||||
<p>{{ auth.isAdmin ? '查看系统运行、训练任务、审计记录和操作诊断信息。' : '查看本人在平台中的操作记录。' }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="运行日志" name="runtime">
|
||||
<LogsView v-if="activeTab === 'runtime'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin" label="审计记录" name="audit">
|
||||
<AuditLogView v-if="activeTab === 'audit'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin" label="操作诊断" name="operations">
|
||||
<OperationLogView v-if="activeTab === 'operations'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template v-if="auth.isAdmin">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="运行日志" name="runtime">
|
||||
<LogsView v-if="activeTab === 'runtime'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="审计记录" name="audit">
|
||||
<AuditLogView v-if="activeTab === 'audit'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="操作诊断" name="operations">
|
||||
<OperationLogView v-if="activeTab === 'operations'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
<OperationLogView v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,23 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createUser } from '@/api/modules/system'
|
||||
import type { CreateUserPayload } from '@/types'
|
||||
import { getTenants, type Tenant } from '@/api/modules/tenant'
|
||||
import type { CreateUserPayload, PermissionCode } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const tenants = ref<Tenant[]>([])
|
||||
|
||||
const permissionOptions: Array<{ value: PermissionCode; label: string }> = [
|
||||
{ value: 'dashboard', label: '服务看板' },
|
||||
{ value: 'fine-tune', label: '模型训练' },
|
||||
{ value: 'model-eval', label: '模型评测' },
|
||||
{ value: 'model-inference', label: '模型推理' },
|
||||
{ value: 'model-manage', label: '模型管理' },
|
||||
{ value: 'dataset', label: '数据集管理' },
|
||||
{ value: 'data-process', label: '数据处理' },
|
||||
{ value: 'data-convert', label: '数据类型转换' },
|
||||
{ value: 'compute', label: '算力节点' },
|
||||
{ value: 'hardware', label: '平台性能' },
|
||||
{ value: 'logs', label: '运行日志' },
|
||||
{ value: 'user-settings', label: '组织与权限' },
|
||||
]
|
||||
|
||||
const form = reactive<CreateUserPayload>({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: 'platform123',
|
||||
password: '123456',
|
||||
role: 'operator',
|
||||
status: 'active',
|
||||
permissions: [],
|
||||
permissions: permissionOptions.filter((item) => !['compute', 'user-settings'].includes(item.value)).map((item) => item.value),
|
||||
tenant_id: 'admin',
|
||||
tenant_role: 'member',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
tenants.value = await getTenants().catch(() => [])
|
||||
})
|
||||
|
||||
function handleRoleChange() {
|
||||
form.permissions = form.role === 'admin'
|
||||
? permissionOptions.map((item) => item.value)
|
||||
: permissionOptions.filter((item) => !['compute', 'user-settings'].includes(item.value)).map((item) => item.value)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.username.trim() || !form.display_name.trim()) {
|
||||
ElMessage.warning('请填写账号和显示名称')
|
||||
return
|
||||
}
|
||||
if (!form.password.trim()) {
|
||||
ElMessage.warning('请输入初始密码')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createUser(form)
|
||||
@@ -43,9 +80,24 @@ async function submit() {
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width: 100%">
|
||||
<el-select v-model="form.role" style="width: 100%" @change="handleRoleChange">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="普通用户" value="operator" />
|
||||
<el-option label="操作员" value="operator" />
|
||||
<el-option label="观察员" value="viewer" />
|
||||
<el-option label="普通用户" value="user" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属租户">
|
||||
<el-select v-model="form.tenant_id" filterable style="width: 100%" placeholder="选择用户所属租户">
|
||||
<el-option v-for="tenant in tenants" :key="tenant.id" :label="tenant.name + '(' + tenant.id + ')'" :value="tenant.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="租户角色">
|
||||
<el-select v-model="form.tenant_role" style="width: 100%">
|
||||
<el-option label="成员" value="member" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="只读" value="viewer" />
|
||||
<el-option v-if="form.role === 'admin'" label="所有者" value="owner" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
@@ -54,11 +106,16 @@ async function submit() {
|
||||
<el-radio value="disabled">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="页面权限">
|
||||
<el-checkbox-group v-model="form.permissions" :disabled="form.role === 'admin'">
|
||||
<el-checkbox v-for="item in permissionOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限说明">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
<span v-if="form.role === 'admin'">管理员:拥有全部权限,包括用户管理、平台治理、算力节点</span>
|
||||
<span v-else>普通用户:可见服务看板、模型服务、数据治理、其他工具、平台性能、查看日志。数据集和微调模型仅创建者和被授权用户可见。</span>
|
||||
<span v-else>非管理员:页面权限控制功能入口;数据集、训练模型等资源仍由创建者、ACL 和审批结果决定。算力节点与组织权限需要管理员单独授予。</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-form-item>
|
||||
|
||||
@@ -1,12 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getUsers, updateUserAccess } from '@/api/modules/system'
|
||||
import type { PermissionCode, SystemUser, UserRole, UserStatus } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const user = ref<SystemUser | null>(null)
|
||||
const form = reactive<{ role: UserRole; status: UserStatus; permissions: PermissionCode[] }>({ role: 'viewer', status: 'active', permissions: [] })
|
||||
const permissionGroups: Array<{ title: string; description: string; items: Array<{ value: PermissionCode; label: string }> }> = [
|
||||
{ title: '平台基础', description: '登录后的公共服务页面访问权限。', items: [{ value: 'dashboard', label: '服务看板' }, { value: 'hardware', label: '平台性能' }, { value: 'logs', label: '运行日志' }] },
|
||||
{ title: '模型服务', description: '模型训练、评测、推理和模型库功能。', items: [{ value: 'fine-tune', label: '模型训练' }, { value: 'model-eval', label: '模型评测' }, { value: 'model-inference', label: '模型推理' }, { value: 'model-manage', label: '模型管理' }] },
|
||||
{ title: '数据治理', description: '数据集、数据处理和数据格式转换功能。', items: [{ value: 'dataset', label: '数据集管理' }, { value: 'data-process', label: '数据处理' }, { value: 'data-convert', label: '数据类型转换' }] },
|
||||
{ title: '平台管理', description: '组织、租户、资源授权、审批和算力节点管理。', items: [{ value: 'user-settings', label: '组织与权限' }, { value: 'compute', label: '算力节点' }] },
|
||||
]
|
||||
const allPermissionCodes = computed(() => permissionGroups.flatMap((group) => group.items.map((item) => item.value)))
|
||||
const isDeleted = computed(() => user.value?.status === 'deleted' || !!user.value?.deleted_at)
|
||||
const roleLabel = (role?: string) => ({ admin: '管理员', operator: '操作员', viewer: '观察员', user: '普通用户' }[role || ''] || role || '—')
|
||||
const statusLabel = (status?: string) => ({ active: '启用', disabled: '停用', pending: '待激活', deleted: '已删除' }[status || ''] || status || '—')
|
||||
const statusType = (status?: string): 'success' | 'warning' | 'danger' | 'info' => status === 'active' ? 'success' : status === 'deleted' ? 'danger' : status === 'pending' ? 'warning' : 'info'
|
||||
|
||||
function setAll(enabled: boolean) { form.permissions = enabled ? [...allPermissionCodes.value] : [] }
|
||||
function resetRoleDefaults() { form.permissions = form.role === 'admin' ? [...allPermissionCodes.value] : allPermissionCodes.value.filter((item) => !['user-settings', 'compute'].includes(item)) }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const users = await getUsers()
|
||||
user.value = users.find((item) => item.id === String(route.params.id || '')) || null
|
||||
if (!user.value) throw new Error('用户不存在')
|
||||
form.role = user.value.role
|
||||
form.status = user.value.status
|
||||
form.permissions = [...(user.value.permissions || [])]
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!user.value || isDeleted.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateUserAccess(user.value.id, { role: form.role, status: form.status as Exclude<UserStatus, 'deleted'>, permissions: form.role === 'admin' ? [...allPermissionCodes.value] : form.permissions })
|
||||
ElMessage.success('用户权限已保存')
|
||||
await load()
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function confirmDisable() {
|
||||
if (!user.value || isDeleted.value || form.status !== 'active') return
|
||||
await ElMessageBox.confirm('停用后该用户现有会话会立即失效,是否继续?', '停用用户', { type: 'warning' })
|
||||
form.status = 'disabled'
|
||||
await save()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="simple-page">
|
||||
<h1>权限设置</h1>
|
||||
<p>第一版已支持账号页面权限读取与保存,精细化项目/模型/数据集权限将在后续版本补齐。</p>
|
||||
<section class="permission-page" v-loading="loading">
|
||||
<el-page-header title="返回用户列表" @back="router.push('/organization?tab=users')"><template #content><span>用户权限设置</span></template></el-page-header>
|
||||
<el-alert v-if="!user && !loading" type="error" :closable="false" title="用户不存在或已被移除" class="state-alert" />
|
||||
<template v-if="user">
|
||||
<el-card shadow="never" class="user-summary">
|
||||
<div class="summary-main"><div><h2>{{ user.display_name || user.username }}</h2><p>{{ user.username }} · {{ user.id }}</p></div><el-tag :type="statusType(user.status)">{{ statusLabel(user.status) }}</el-tag></div>
|
||||
<el-descriptions :column="3" border><el-descriptions-item label="平台角色">{{ roleLabel(user.role) }}</el-descriptions-item><el-descriptions-item label="所属租户">{{ user.tenant_id || 'default' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ user.create_time || '—' }}</el-descriptions-item></el-descriptions>
|
||||
</el-card>
|
||||
<el-alert v-if="isDeleted" type="warning" show-icon :closable="false" title="该用户已软删除,只能查看历史权限,不能重新激活。" class="state-alert" />
|
||||
<el-card shadow="never" class="permission-card">
|
||||
<template #header><div class="card-header"><span>平台页面权限</span><el-button text type="primary" :disabled="isDeleted" @click="setAll(true)">全选</el-button><el-button text :disabled="isDeleted" @click="setAll(false)">清空</el-button></div></template>
|
||||
<el-alert type="info" :closable="false" show-icon title="页面权限只控制功能入口;数据集、训练模型等具体资源仍需通过资源 ACL 或审批授权。" class="hint" />
|
||||
<div v-for="group in permissionGroups" :key="group.title" class="permission-group"><div class="group-heading"><strong>{{ group.title }}</strong><span>{{ group.description }}</span></div><el-checkbox-group v-model="form.permissions" :disabled="isDeleted || form.role === 'admin'"><el-checkbox v-for="item in group.items" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></div>
|
||||
<el-alert v-if="form.role === 'admin'" type="warning" :closable="false" title="管理员固定拥有全部平台权限,保存时会自动补齐全部权限码。" class="hint" />
|
||||
</el-card>
|
||||
<el-card shadow="never" class="settings-card"><template #header><span>角色与状态</span></template><el-form label-width="100px" class="settings-form"><el-form-item label="平台角色"><el-select v-model="form.role" :disabled="isDeleted || user.protected" @change="resetRoleDefaults"><el-option label="管理员" value="admin" /><el-option label="操作员" value="operator" /><el-option label="观察员" value="viewer" /><el-option label="普通用户" value="user" /></el-select></el-form-item><el-form-item label="账号状态"><el-radio-group v-model="form.status" :disabled="isDeleted || user.protected"><el-radio value="active">启用</el-radio><el-radio value="disabled">停用</el-radio><el-radio value="pending">待激活</el-radio></el-radio-group></el-form-item><el-form-item><el-button @click="router.push('/organization?tab=users')">取消</el-button><el-button type="primary" :loading="saving" :disabled="isDeleted || !!user.protected" @click="save">保存权限</el-button><el-button v-if="form.status === 'active' && !isDeleted" type="warning" :disabled="!!user.protected" @click="confirmDisable">停用账号</el-button></el-form-item></el-form></el-card>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simple-page {
|
||||
padding: 24px;
|
||||
}
|
||||
.permission-page { padding: 20px; max-width: 1100px; }
|
||||
.state-alert, .permission-card, .settings-card { margin-top: 16px; }
|
||||
.user-summary { margin-top: 16px; }
|
||||
.summary-main, .card-header, .group-heading { display: flex; align-items: center; gap: 12px; }
|
||||
.summary-main { justify-content: space-between; margin-bottom: 16px; }
|
||||
.summary-main h2 { margin: 0; font-size: 20px; }
|
||||
.summary-main p, .group-heading span { color: #64748b; font-size: 13px; }
|
||||
.summary-main p { margin: 6px 0 0; }
|
||||
.card-header > span { margin-right: auto; font-weight: 600; }
|
||||
.hint { margin-bottom: 16px; }
|
||||
.permission-group { padding: 16px 0; border-bottom: 1px solid #eef2f7; }
|
||||
.permission-group:last-child { border-bottom: 0; }
|
||||
.group-heading { margin-bottom: 12px; }
|
||||
.group-heading span { margin-left: auto; }
|
||||
.permission-group :deep(.el-checkbox) { min-width: 150px; }
|
||||
.settings-form { max-width: 720px; }
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
resetUserPassword,
|
||||
updateUserAccess,
|
||||
} from '@/api/modules/system'
|
||||
import { getTenants, type Tenant } from '@/api/modules/tenant'
|
||||
import type { SystemUser, UserStatus } from '@/types'
|
||||
import { statusLabel, statusTagType } from '@/utils/status'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref<SystemUser[]>([])
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const auth = useAuthStore()
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
@@ -22,7 +25,9 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadUsers(), getTenants().then((items) => { tenants.value = items }).catch(() => { tenants.value = [] })])
|
||||
})
|
||||
|
||||
// 当前登录用户,用于禁止操作自身(避免误锁自己)
|
||||
const currentUsername = ref<string>('')
|
||||
@@ -36,12 +41,35 @@ function isSelf(row: SystemUser) {
|
||||
return row.username === currentUsername.value
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
return ({ admin: '管理员', operator: '操作员', viewer: '观察员', user: '普通用户' } as Record<string, string>)[role] || role
|
||||
}
|
||||
|
||||
function platformRoleLabel(row: SystemUser) {
|
||||
return row.platform_role === 'platform_admin' || row.role === 'admin' ? '平台管理员' : '平台用户'
|
||||
}
|
||||
|
||||
function userStatusLabel(status: string) {
|
||||
return ({ active: '启用', disabled: '停用', pending: '待激活', deleted: '已删除' } as Record<string, string>)[status] || status
|
||||
}
|
||||
|
||||
function userStatusType(status: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||
return status === 'active' ? 'success' : status === 'deleted' ? 'danger' : status === 'pending' ? 'warning' : 'info'
|
||||
}
|
||||
|
||||
function asSystemUser(row: unknown): SystemUser {
|
||||
return row as SystemUser
|
||||
}
|
||||
|
||||
function tenantLabel(tenantId?: string) {
|
||||
const id = tenantId || 'default'
|
||||
const tenant = tenants.value.find((item) => item.id === id)
|
||||
return tenant ? tenant.name + '(' + tenant.id + ')' : id === 'admin' ? '管理员租户(admin)' : id === 'default' ? '历史默认租户(default)' : id
|
||||
}
|
||||
|
||||
// ---------- 启停 ----------
|
||||
async function toggleStatus(row: SystemUser, next: boolean) {
|
||||
if (row.status === 'deleted') return
|
||||
const nextStatus: UserStatus = next ? 'active' : 'disabled'
|
||||
const prev = row.status
|
||||
row.status = nextStatus
|
||||
@@ -93,7 +121,7 @@ async function removeUser(row: SystemUser) {
|
||||
}
|
||||
try {
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success(`已删除 ${row.display_name}`)
|
||||
ElMessage.success(`已软删除 ${row.display_name}`)
|
||||
await loadUsers()
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || '删除失败'
|
||||
@@ -110,31 +138,40 @@ async function removeUser(row: SystemUser) {
|
||||
<p>管理平台账号、角色状态与登录密码。</p>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
<el-button v-if="auth.can('user-settings', 'write')" type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-table :data="users" border>
|
||||
<el-table-column prop="username" label="账号" min-width="140" />
|
||||
<el-table-column prop="display_name" label="显示名称" min-width="160" />
|
||||
<el-table-column label="租户成员关系" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span>{{ (asSystemUser(row).tenant_memberships?.length || 0) }} 个租户</span>
|
||||
<span class="tenant-summary">(主租户:{{ tenantLabel(asSystemUser(row).tenant_id) }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="asSystemUser(row).role === 'admin' ? 'danger' : 'info'" size="small">
|
||||
{{ asSystemUser(row).role === 'admin' ? '管理员' : '普通用户' }}
|
||||
<el-tag :type="asSystemUser(row).role === 'admin' ? 'danger' : 'info'" size="small">
|
||||
{{ roleLabel(asSystemUser(row).role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(asSystemUser(row).status)" size="small">{{ statusLabel(asSystemUser(row).status) }}</el-tag>
|
||||
<el-tag :type="userStatusType(asSystemUser(row).status)" size="small">{{ userStatusLabel(asSystemUser(row).status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="页面权限" min-width="150">
|
||||
<template #default="{ row }">{{ asSystemUser(row).role === 'admin' ? '全部权限' : `${asSystemUser(row).permissions?.length || 0} 项` }}</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 }">
|
||||
<el-switch
|
||||
:model-value="asSystemUser(row).status === 'active'"
|
||||
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row))"
|
||||
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row)) || asSystemUser(row).status === 'deleted' || !auth.can('user-settings', 'write')"
|
||||
@change="(v: any) => toggleStatus(asSystemUser(row), v)"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
@@ -143,15 +180,16 @@ async function removeUser(row: SystemUser) {
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="asSystemUser(row).protected"
|
||||
:disabled="asSystemUser(row).protected || asSystemUser(row).status === 'deleted' || !auth.can('user-settings', 'write')"
|
||||
@click="openResetPwd(asSystemUser(row))"
|
||||
>重置密码</el-button>
|
||||
<el-button link type="primary" :disabled="!auth.can('user-settings', 'write')" @click="$router.push(`/user-settings/${asSystemUser(row).id}/permission`)">权限</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row))"
|
||||
:disabled="asSystemUser(row).protected || isSelf(asSystemUser(row)) || asSystemUser(row).status === 'deleted' || !auth.can('user-settings', 'delete')"
|
||||
@click="removeUser(asSystemUser(row))"
|
||||
>删除</el-button>
|
||||
>软删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getTenant, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getTenant,
|
||||
getTenantMembers,
|
||||
getTenantQuotaUsage,
|
||||
inviteTenantMember,
|
||||
removeTenantMember,
|
||||
setTenantQuota,
|
||||
updateTenantMember,
|
||||
type Tenant,
|
||||
type TenantMember,
|
||||
type TenantQuotaUsage,
|
||||
} from '@/api/modules/tenant'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tenant = ref<Tenant | null>(null)
|
||||
const loading = ref(false)
|
||||
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
||||
const members = ref<TenantMember[]>([])
|
||||
const quotaUsage = ref<TenantQuotaUsage | null>(null)
|
||||
const quotaForm = reactive({ gpu: 0, storage: 0 })
|
||||
const memberForm = reactive({ user_id: '', role: 'member' as TenantMember['role'], expires_at: '' })
|
||||
|
||||
function parseQuota(quota: Record<string, unknown> | undefined | null | string) {
|
||||
// 兼容处理:quota 可能是 JSON 字符串或嵌套 { quota: {...} } 格式
|
||||
@@ -25,7 +39,6 @@ function parseQuota(quota: Record<string, unknown> | undefined | null | string)
|
||||
return {
|
||||
gpu: Number(q.gpu || q.gpu_quota || 0),
|
||||
storage: Number(q.storage || q.storage_quota || 0),
|
||||
maxProjects: Number(q.max_projects || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +47,6 @@ function formatQuota(quota: Record<string, unknown> | undefined | null) {
|
||||
const parts: string[] = []
|
||||
if (q.gpu > 0) parts.push(`GPU ${q.gpu}`)
|
||||
if (q.storage > 0) parts.push(`存储 ${q.storage}GB`)
|
||||
if (q.maxProjects > 0) parts.push(`项目 ${q.maxProjects}`)
|
||||
return parts.length ? parts.join(' | ') : '—'
|
||||
}
|
||||
|
||||
@@ -42,22 +54,69 @@ async function load() {
|
||||
const id = route.params.id as string
|
||||
loading.value = true
|
||||
try {
|
||||
tenant.value = await getTenant(id)
|
||||
const [tenantResult, membersResult, usageResult] = await Promise.allSettled([
|
||||
getTenant(id),
|
||||
getTenantMembers(id),
|
||||
getTenantQuotaUsage(id),
|
||||
])
|
||||
if (tenantResult.status === 'rejected') throw tenantResult.reason
|
||||
tenant.value = tenantResult.value
|
||||
if (membersResult.status === 'fulfilled') members.value = membersResult.value
|
||||
if (usageResult.status === 'fulfilled') quotaUsage.value = usageResult.value
|
||||
const q = parseQuota(tenant.value?.quota)
|
||||
quotaForm.gpu = q.gpu
|
||||
quotaForm.storage = q.storage
|
||||
quotaForm.maxProjects = q.maxProjects
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteMember() {
|
||||
if (!tenant.value || !memberForm.user_id.trim()) {
|
||||
ElMessage.warning('请输入用户 ID')
|
||||
return
|
||||
}
|
||||
await inviteTenantMember(tenant.value.id, {
|
||||
user_id: memberForm.user_id.trim(),
|
||||
role: memberForm.role,
|
||||
expires_at: memberForm.expires_at || undefined,
|
||||
})
|
||||
memberForm.user_id = ''
|
||||
memberForm.expires_at = ''
|
||||
ElMessage.success('邀请已发送')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function changeMemberRole(member: TenantMember, role: TenantMember['role']) {
|
||||
if (!tenant.value) return
|
||||
await updateTenantMember(tenant.value.id, member.user_id, { role })
|
||||
ElMessage.success('成员角色已更新')
|
||||
await load()
|
||||
}
|
||||
|
||||
function handleMemberRoleChange(member: TenantMember, value: unknown) {
|
||||
if (value === 'owner' || value === 'admin' || value === 'member' || value === 'viewer') {
|
||||
void changeMemberRole(member, value)
|
||||
}
|
||||
}
|
||||
|
||||
function asMember(row: unknown): TenantMember {
|
||||
return row as TenantMember
|
||||
}
|
||||
|
||||
async function removeMember(member: TenantMember) {
|
||||
if (!tenant.value) return
|
||||
await ElMessageBox.confirm(`确定移除成员“${member.display_name || member.username || member.user_id}”吗?`, '移除成员', { type: 'warning' })
|
||||
await removeTenantMember(tenant.value.id, member.user_id)
|
||||
ElMessage.success('成员已移除')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function saveQuota() {
|
||||
if (!tenant.value) return
|
||||
const quota: Record<string, unknown> = {}
|
||||
if (quotaForm.gpu > 0) quota.gpu = quotaForm.gpu
|
||||
if (quotaForm.storage > 0) quota.storage = quotaForm.storage
|
||||
if (quotaForm.maxProjects > 0) quota.max_projects = quotaForm.maxProjects
|
||||
await setTenantQuota(tenant.value.id, quota)
|
||||
ElMessage.success('配额已保存')
|
||||
load()
|
||||
@@ -77,7 +136,7 @@ onMounted(load)
|
||||
<template #header>基本信息</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="名称">{{ tenant?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ tenant?.code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="租户标识">{{ tenant?.id || tenant?.code || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ tenant?.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ tenant?.create_time }}</el-descriptions-item>
|
||||
<el-descriptions-item label="配额">{{ formatQuota(tenant?.quota) }}</el-descriptions-item>
|
||||
@@ -92,14 +151,48 @@ onMounted(load)
|
||||
<el-form-item label="存储配额(GB)">
|
||||
<el-input-number v-model="quotaForm.storage" :min="0" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大项目数">
|
||||
<el-input-number v-model="quotaForm.maxProjects" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveQuota">保存配额</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="member-header">
|
||||
<span class="label">成员管理</span>
|
||||
<span v-if="quotaUsage" class="usage">GPU 预留 {{ quotaUsage.gpu_reserved }} / {{ quotaUsage.gpu_limit || '不限' }}</span>
|
||||
</div>
|
||||
<div class="member-actions">
|
||||
<el-input v-model="memberForm.user_id" placeholder="输入用户 ID" clearable @keyup.enter="inviteMember" />
|
||||
<el-select v-model="memberForm.role" style="width: 120px">
|
||||
<el-option label="成员" value="member" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="只读" value="viewer" />
|
||||
</el-select>
|
||||
<el-date-picker v-model="memberForm.expires_at" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="邀请有效期(可选)" />
|
||||
<el-button type="primary" @click="inviteMember">邀请成员</el-button>
|
||||
</div>
|
||||
<el-table :data="members" size="small" empty-text="暂无成员">
|
||||
<el-table-column prop="display_name" label="用户" min-width="160">
|
||||
<template #default="{ row }">{{ row.display_name || row.username || row.user_id }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="租户角色" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select :model-value="row.role" size="small" @change="handleMemberRoleChange(asMember(row), $event)">
|
||||
<el-option label="所有者" value="owner" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="成员" value="member" />
|
||||
<el-option label="只读" value="viewer" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="expires_at" label="邀请有效期" min-width="170" />
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeMember(asMember(row))">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -110,4 +203,7 @@ onMounted(load)
|
||||
.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; }
|
||||
.member-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.usage { color: #606266; font-size: 13px; }
|
||||
.member-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; max-width: 760px; }
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, deleteTenant, getTenants, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
import { createTenant, deleteTenant, getTenants, restoreTenant, setTenantQuota, type Tenant } from '@/api/modules/tenant'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
@@ -12,8 +12,8 @@ const tenants = ref<Tenant[]>([])
|
||||
const showCreate = ref(false)
|
||||
const showQuota = ref(false)
|
||||
const currentTenant = ref<Tenant | null>(null)
|
||||
const form = ref({ name: '', code: '', gpu: 0, storage: 0, maxProjects: 0 })
|
||||
const quotaForm = reactive({ gpu: 0, storage: 0, maxProjects: 0 })
|
||||
const form = ref({ id: '', name: '', gpu: 0, storage: 0 })
|
||||
const quotaForm = reactive({ gpu: 0, storage: 0 })
|
||||
|
||||
function parseQuota(quota: Record<string, unknown> | undefined | null | string) {
|
||||
// 兼容处理:quota 可能是 JSON 字符串或嵌套 { quota: {...} } 格式
|
||||
@@ -29,7 +29,6 @@ function parseQuota(quota: Record<string, unknown> | undefined | null | string)
|
||||
return {
|
||||
gpu: Number(q.gpu || q.gpu_quota || 0),
|
||||
storage: Number(q.storage || q.storage_quota || 0),
|
||||
maxProjects: Number(q.max_projects || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +37,6 @@ function formatQuota(quota: Record<string, unknown> | undefined | null) {
|
||||
const parts: string[] = []
|
||||
if (q.gpu > 0) parts.push(`GPU ${q.gpu}`)
|
||||
if (q.storage > 0) parts.push(`存储 ${q.storage}GB`)
|
||||
if (q.maxProjects > 0) parts.push(`项目 ${q.maxProjects}`)
|
||||
return parts.length ? parts.join(' | ') : '-'
|
||||
}
|
||||
|
||||
@@ -72,11 +70,10 @@ async function submitCreate() {
|
||||
const quota: Record<string, unknown> = {}
|
||||
if (form.value.gpu > 0) quota.gpu = form.value.gpu
|
||||
if (form.value.storage > 0) quota.storage = form.value.storage
|
||||
if (form.value.maxProjects > 0) quota.max_projects = form.value.maxProjects
|
||||
await createTenant({ name: form.value.name, code: form.value.code, quota })
|
||||
await createTenant({ id: form.value.id || undefined, name: form.value.name, quota })
|
||||
ElMessage.success('租户创建成功')
|
||||
showCreate.value = false
|
||||
form.value = { name: '', code: '', gpu: 0, storage: 0, maxProjects: 0 }
|
||||
form.value = { id: '', name: '', gpu: 0, storage: 0 }
|
||||
load()
|
||||
}
|
||||
|
||||
@@ -85,7 +82,6 @@ function openQuotaDialog(row: Tenant) {
|
||||
const q = parseQuota(row.quota)
|
||||
quotaForm.gpu = q.gpu
|
||||
quotaForm.storage = q.storage
|
||||
quotaForm.maxProjects = q.maxProjects
|
||||
showQuota.value = true
|
||||
}
|
||||
|
||||
@@ -94,7 +90,6 @@ async function submitQuota() {
|
||||
const quota: Record<string, unknown> = {}
|
||||
if (quotaForm.gpu > 0) quota.gpu = quotaForm.gpu
|
||||
if (quotaForm.storage > 0) quota.storage = quotaForm.storage
|
||||
if (quotaForm.maxProjects > 0) quota.max_projects = quotaForm.maxProjects
|
||||
await setTenantQuota(currentTenant.value.id, quota)
|
||||
ElMessage.success('配额已更新')
|
||||
showQuota.value = false
|
||||
@@ -104,7 +99,7 @@ async function submitQuota() {
|
||||
async function handleDelete(row: Tenant) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除租户「${row.name}」吗?删除后相关数据将无法恢复。`,
|
||||
`确定要删除租户「${row.name}」吗?删除后租户将停用,成员暂时无法访问,可通过“恢复”继续使用。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
@@ -116,6 +111,21 @@ async function handleDelete(row: Tenant) {
|
||||
load()
|
||||
}
|
||||
|
||||
async function handleRestore(row: Tenant) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定恢复租户「${row.name}」吗?恢复后租户成员可以继续访问原有资源。`,
|
||||
'恢复确认',
|
||||
{ confirmButtonText: '确定恢复', cancelButtonText: '取消', type: 'info' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await restoreTenant(row.id)
|
||||
ElMessage.success('租户已恢复')
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -127,25 +137,36 @@ onMounted(load)
|
||||
</template>
|
||||
<template #columns>
|
||||
<el-table-column prop="name" label="租户名称" min-width="140" />
|
||||
<el-table-column prop="code" label="租户 ID" min-width="100" />
|
||||
<el-table-column prop="id" label="租户标识" min-width="140" />
|
||||
<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="danger" @click="handleDelete(asTenant(row))">删除</el-button>
|
||||
<el-button
|
||||
v-if="asTenant(row).status === 'deleted' || !!asTenant(row).deleted_at"
|
||||
link
|
||||
type="success"
|
||||
@click="handleRestore(asTenant(row))"
|
||||
>恢复</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(asTenant(row))"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</DataTablePage>
|
||||
|
||||
<!-- 新建租户弹窗 -->
|
||||
<el-dialog v-model="showCreate" title="新建租户" width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="租户标识">
|
||||
<el-input v-model="form.id" placeholder="留空自动生成,可填写字母、数字、- 或 _" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="租户名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户ID">
|
||||
<el-input v-model="form.code" placeholder="用户ID" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">配额设置(可选,0 表示不限制)</el-divider>
|
||||
<el-form-item label="GPU 数量">
|
||||
<el-input-number v-model="form.gpu" :min="0" :step="1" placeholder="GPU 卡数" />
|
||||
@@ -153,9 +174,6 @@ onMounted(load)
|
||||
<el-form-item label="存储配额(GB)">
|
||||
<el-input-number v-model="form.storage" :min="0" :step="10" placeholder="存储大小" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大项目数">
|
||||
<el-input-number v-model="form.maxProjects" :min="0" :step="1" placeholder="项目上限" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
@@ -172,9 +190,6 @@ onMounted(load)
|
||||
<el-form-item label="存储配额(GB)">
|
||||
<el-input-number v-model="quotaForm.storage" :min="0" :step="10" placeholder="存储大小" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大项目数">
|
||||
<el-input-number v-model="quotaForm.maxProjects" :min="0" :step="1" placeholder="项目上限" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showQuota = false">取消</el-button>
|
||||
|
||||
Reference in New Issue
Block a user