feat: 平台治理与权限体系完善,存储进度/GPU预留/审批中心与日志整合

- 平台治理: 租户用户权限层次、资源ACL、审批中心与审批模板、访问申请
- 存储: MinIO 存储进度迁移、对象存储安全加固与测试
- 计算: GPU 资源预留、compute 轮询与同步增强
- 权限: permission v2 迁移、权限安全验收测试
- 日志: 后端运行日志中文说明、操作日志整合
- 数据处理/评测: 数据转换与模型评测优化

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-08-21 09:49:48 +08:00
parent 080ef6ab00
commit 6f0e82f351
94 changed files with 9547 additions and 1045 deletions

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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 }>
}

View File

@@ -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: {

View File

@@ -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`)

View File

@@ -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
},

View File

@@ -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;

View File

@@ -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),

View File

@@ -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

View File

@@ -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
}
// 其他所有业务页面对已登录用户开放,不再检查权限码
// 其他所有业务页面对已登录用户开放,不再检查权限码
}
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

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

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

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

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -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" />

View File

@@ -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

View File

@@ -20,7 +20,7 @@ const activeTab = computed({
<header class="page-header">
<div>
<h2>组织与权限</h2>
<p>统一管理平台用户角色租户和资源配额</p>
<p>统一管理平台身份租户成员角色和资源配额</p>
</div>
</header>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>