This commit is contained in:
wangjiming
2026-08-03 09:34:08 +08:00
parent b975de02da
commit 15c4223f2c
43 changed files with 4498 additions and 234 deletions

View File

@@ -1,9 +1,62 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { ElMessage } from 'element-plus'
import { routeLoading } from '@/router'
import { useAuthStore } from '@/stores/auth'
import { SESSION_TIMEOUT } from '@/constants'
const router = useRouter()
const auth = useAuthStore()
/**
* 离开页面超时:
* - 标签页切走/最小化document.hidden时记录时间
* - 切回来时若超过 SESSION_TIMEOUT5分钟强制跳登录
* - 不管是否在操作,只要离开页面超过 5 分钟就跳
*/
let hiddenAt = 0
function handleVisibility() {
if (document.hidden) {
hiddenAt = Date.now()
} else {
if (hiddenAt > 0 && Date.now() - hiddenAt >= SESSION_TIMEOUT) {
auth.logout()
ElMessage.warning('登录已过期,请重新登录')
router.push('/login')
}
hiddenAt = 0
}
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibility)
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibility)
})
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view />
<div v-loading="routeLoading" element-loading-text="加载中..." class="app-root">
<router-view />
</div>
</el-config-provider>
</template>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
}
.app-root {
height: 100%;
position: relative;
}
</style>

View File

@@ -0,0 +1,15 @@
import { get, put } from '../request'
export interface AclEntry {
subject_type: string
subject_id: string
permissions: string[]
}
/** 资源 ACL 查询 */
export const getAcl = (resourceType: string, resourceId: string) =>
get<AclEntry[]>(`/resources/${resourceType}/${resourceId}/acl`)
/** 资源 ACL 设置 */
export const setAcl = (resourceType: string, resourceId: string, entries: AclEntry[]) =>
put<AclEntry[]>(`/resources/${resourceType}/${resourceId}/acl`, { entries })

View File

@@ -0,0 +1,50 @@
import { get, post } from '../request'
export interface ApprovalStep {
approver_id?: string | null
status: string
}
export interface ApprovalTemplate {
id: string
name: string
steps: ApprovalStep[]
create_time?: string
}
export interface ApprovalInstance {
id: string
template_id?: string | null
resource_type: string
resource_id: string
applicant_id: string
status: string
current_step: number
create_time?: string
steps: Array<ApprovalStep & { step_index: number; comment?: string | null; time?: string | null }>
}
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 createApprovalInstance = (payload: {
template_id?: string
resource_type: string
resource_id: string
applicant_id: string
}) => post<ApprovalInstance>('/approvals', payload)
export const getApprovalInstance = (id: string) =>
get<ApprovalInstance>(`/approvals/${id}`)
export const decideApproval = (
id: string,
step_index: number,
payload: { approver_id: string; approved: boolean; comment?: string },
) => post<ApprovalInstance>(`/approvals/${id}/steps/${step_index}/decision`, payload)

View File

@@ -0,0 +1,40 @@
import { get } from '../request'
import request from '../request'
export interface AuditLog {
id: string
tenant_id?: string
project_id?: string
actor_id?: string
action?: string
target_type?: string
target_id?: string
detail?: string
client_ip?: string
time?: string
}
export interface AuditQuery {
tenant_id?: string
project_id?: string
actor_id?: string
action?: string
target_type?: string
start_time?: string
end_time?: string
limit?: number
offset?: number
}
/** 审计日志查询:使用 get 辅助函数,拦截器已解包,直接返回 { items, total } */
export const getAuditLogs = (query: AuditQuery = {}) =>
get<{ items: AuditLog[]; total: number }>('/system/audit-logs', query)
/** 审计日志导出 CSVblob 响应走完整 axios response需手动取 data */
export const exportAuditLogs = (query: AuditQuery = {}) =>
request<Blob>({
url: '/system/audit-logs/export',
method: 'get',
params: query,
responseType: 'blob',
}).then((res) => res.data)

View File

@@ -0,0 +1,35 @@
import { get } from '../request'
export interface ServiceStatusStat {
type: string
status: 'normal' | 'busy' | 'error'
count: number
}
export interface TrainingTaskStat {
id: string
name: string
status: string
train_type: string
train_method: string
base_model: string
progress: number
accuracy: number | null
started_at: string
}
export interface DashboardStats {
online_services: number
running_tasks: number
pending_alerts: number
training_7d: { date: string; train: number; gpu: number; accuracy: number | null }[]
service_status: ServiceStatusStat[]
training_tasks: TrainingTaskStat[]
operation_distribution: { name: string; value: number }[]
login_duration_rank: { user: string; role: string; duration: number }[]
recent_login_users: { user: string; role: string; last_login: string }[]
}
export function getDashboardStats() {
return get<DashboardStats>('/dashboard/stats')
}

View File

@@ -0,0 +1,55 @@
import { del, get, post, put } from '../request'
export interface Project {
id: string
tenant_id: string
name: string
code: string
description?: string
status: string
quota?: Record<string, unknown>
member_count?: number
task_count?: number
create_time?: string
}
export interface ProjectMember {
user_id: string
role: string
joined_at?: string
}
/** 项目列表(按租户过滤,默认 default */
export const getProjects = (tenantId = 'default') =>
get<Project[]>('/projects', { tenant_id: tenantId })
/** 项目详情 */
export const getProject = (id: string) => get<Project>(`/projects/${id}`)
/** 创建项目 */
export const createProject = (payload: Partial<Project>) =>
post<Project>('/projects', payload)
/** 更新项目 */
export const updateProject = (id: string, payload: Partial<Project>) =>
put<Project>(`/projects/${id}`, payload)
/** 归档项目 */
export const archiveProject = (id: string) =>
post<Project>(`/projects/${id}/archive`)
/** 项目成员列表 */
export const getProjectMembers = (id: string) =>
get<ProjectMember[]>(`/projects/${id}/members`)
/** 添加成员 */
export const addProjectMember = (id: string, payload: { user_id: string; role: string }) =>
post<ProjectMember>(`/projects/${id}/members`, payload)
/** 更新成员角色 */
export const updateProjectMember = (id: string, userId: string, role: string) =>
put<ProjectMember>(`/projects/${id}/members/${userId}`, { role })
/** 移除成员 */
export const removeProjectMember = (id: string, userId: string) =>
del(`/projects/${id}/members/${userId}`)

View File

@@ -0,0 +1,29 @@
import { del, get, post, put } from '../request'
export interface RetentionPolicy {
id: string
name: string
scope?: string | null
rule?: string | null
status: string
create_time?: string
create_by?: string | null
updated_at?: string
}
/** 留存策略列表 */
export const getRetentionPolicies = () => get<RetentionPolicy[]>('/retention-policies')
/** 留存策略详情 */
export const getRetentionPolicy = (id: string) => get<RetentionPolicy>(`/retention-policies/${id}`)
/** 创建留存策略 */
export const createRetentionPolicy = (payload: Partial<RetentionPolicy>) =>
post<RetentionPolicy>('/retention-policies', payload)
/** 更新留存策略 */
export const updateRetentionPolicy = (id: string, payload: Partial<RetentionPolicy>) =>
put<RetentionPolicy>(`/retention-policies/${id}`, payload)
/** 删除留存策略 */
export const deleteRetentionPolicy = (id: string) => del(`/retention-policies/${id}`)

View File

@@ -25,12 +25,14 @@ export const getUsers = () => get<SystemUser[]>('/users')
export const createUser = (payload: CreateUserPayload) =>
post<SystemUser>('/users', payload)
/** 删除用户currentUsername 用于防止删除当前登录账号 */
export const deleteUser = (id: string, currentUsername: string) =>
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`, {
current_username: currentUsername,
})
/** 更新用户角色、状态及页面权限 */
export const updateUserAccess = (id: string, payload: UpdateUserAccessPayload) =>
put<SystemUser>(`/users/${encodeURIComponent(id)}`, payload)
/** 重置用户密码 */
export const resetUserPassword = (id: string, password?: string) =>
post<{ reset: string }>(`/users/${encodeURIComponent(id)}/reset-password`, { password })
/** 删除用户protected 管理员账号不允许删除) */
export const deleteUser = (id: string) =>
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`)

View File

@@ -0,0 +1,34 @@
import { get, post, put } from '../request'
export interface Tenant {
id: string
name: string
code: string
status: string
owner_user_id?: string | null
quota: Record<string, unknown>
retention_policy_id?: string | null
create_time?: string
}
/** 租户列表 */
export const getTenants = () => get<Tenant[]>('/tenants')
/** 租户详情 */
export const getTenant = (id: string) => get<Tenant>(`/tenants/${id}`)
/** 创建租户 */
export const createTenant = (payload: Partial<Tenant>) =>
post<Tenant>('/tenants', payload)
/** 更新租户 */
export const updateTenant = (id: string, payload: Partial<Tenant>) =>
put<Tenant>(`/tenants/${id}`, payload)
/** 设置租户配额 */
export const setTenantQuota = (id: string, quota: Record<string, unknown>) =>
put<Tenant>(`/tenants/${id}/quota`, { quota })
/** 设置租户留存策略 */
export const setTenantRetention = (id: string, retention_policy_id: string) =>
put<Tenant>(`/tenants/${id}/retention-policy`, { retention_policy_id })

View File

@@ -1,6 +1,5 @@
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import { touchSessionActivity } from '@/utils/sessionActivity'
/**
* 后端统一响应格式
@@ -18,9 +17,37 @@ const service: AxiosInstance = axios.create({
timeout: 30000,
})
// 请求拦截器
/**
* 从 localStorage 取当前用户 token登录时后端返回 platform-token-{user_id})。
* 后端鉴权中间件依赖此 header 解析当前用户身份。
*/
function getAuthToken(): string | null {
const USER_STORAGE_KEY = 'currentUser'
const raw = localStorage.getItem(USER_STORAGE_KEY)
if (raw) {
try {
const user = JSON.parse(raw)
// 后端 login 返回的 token 格式为 platform-token-{user.id}
if (user?.id) return `platform-token-${user.id}`
} catch {
/* ignore */
}
}
// 兼容改造前 admin 会话
if (localStorage.getItem('username') === 'admin') return 'platform-token-admin'
return null
}
// 请求拦截器:注入 Authorization header
service.interceptors.request.use(
(config) => config,
(config) => {
const token = getAuthToken()
if (token) {
config.headers = config.headers || {}
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
@@ -30,12 +57,9 @@ service.interceptors.response.use(
const res = response.data as ApiResult
// 二进制流等非 JSON 响应直接返回
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
touchSessionActivity()
return response
}
if (res.code === 0) {
// 生成进度轮询也属于用户正在使用系统,避免长任务结束后被误判为会话过期。
touchSessionActivity()
return res.data
}
// 业务错误

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getAcl, setAcl, type AclEntry } from '@/api/modules/acl'
const props = defineProps<{
modelValue: boolean
resourceType: string
resourceId: string
}>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const entries = ref<AclEntry[]>([])
const loading = ref(false)
const ALL_PERMS = ['read', 'write', 'execute', 'download', 'delete', 'share']
async function load() {
loading.value = true
try {
entries.value = await getAcl(props.resourceType, props.resourceId)
} finally {
loading.value = false
}
}
watch(visible, (v) => { if (v) load() })
function addEntry() {
entries.value.push({ subject_type: 'user', subject_id: '', permissions: [] })
}
function removeEntry(idx: number) {
entries.value.splice(idx, 1)
}
async function save() {
await setAcl(props.resourceType, props.resourceId, entries.value)
ElMessage.success('ACL 已保存')
visible.value = false
}
</script>
<template>
<el-dialog v-model="visible" title="资源授权 (ACL)" width="640px">
<div v-loading="loading">
<el-button type="primary" size="small" @click="addEntry">添加授权项</el-button>
<div v-for="(entry, idx) in entries" :key="idx" class="acl-row">
<el-select v-model="entry.subject_type" style="width: 140px">
<el-option label="用户" value="user" />
<el-option label="项目角色" value="project_role" />
</el-select>
<el-input v-model="entry.subject_id" placeholder="subject ID" style="width: 200px" />
<el-checkbox-group v-model="entry.permissions">
<el-checkbox v-for="p in ALL_PERMS" :key="p" :value="p">{{ p }}</el-checkbox>
</el-checkbox-group>
<el-button link type="danger" @click="removeEntry(idx)">删除</el-button>
</div>
<el-empty v-if="entries.length === 0" description="暂无授权" />
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.acl-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
</style>

View File

@@ -74,6 +74,16 @@ const menuGroups: MenuGroup[] = [
{ key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' },
],
},
{
title: '平台治理',
items: [
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' },
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
],
},
{
title: '系统设置',
items: [

View File

@@ -1,7 +1,11 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import type { PermissionCode } from '@/types'
/** 路由切换时的全局加载态,供 App.vue 显示全屏转圈遮罩,消除懒加载时的空白卡顿感 */
export const routeLoading = ref(false)
const routes: RouteRecordRaw[] = [
{
path: '/login',
@@ -27,6 +31,49 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: '服务看板' },
},
// 平台治理
{
path: 'tenants',
name: 'tenants',
component: () => import('@/views/tenants/TenantListView.vue'),
meta: { title: '租户管理', permission: 'user-settings' },
},
{
path: 'tenants/:id',
name: 'tenant-detail',
component: () => import('@/views/tenants/TenantDetailView.vue'),
meta: { title: '租户详情', permission: 'user-settings' },
},
{
path: 'projects',
name: 'projects',
component: () => import('@/views/projects/ProjectListView.vue'),
meta: { title: '项目空间', permission: 'user-settings' },
},
{
path: 'projects/:id',
name: 'project-detail',
component: () => import('@/views/projects/ProjectDetailView.vue'),
meta: { title: '项目详情', permission: 'user-settings' },
},
{
path: 'audit-logs',
name: 'audit-logs',
component: () => import('@/views/audit/AuditLogView.vue'),
meta: { title: '审计日志', permission: 'user-settings' },
},
{
path: 'approval-templates',
name: 'approval-templates',
component: () => import('@/views/approvals/ApprovalTemplateView.vue'),
meta: { title: '审批模板', permission: 'user-settings' },
},
{
path: 'approval-instances',
name: 'approval-instances',
component: () => import('@/views/approvals/ApprovalInstanceView.vue'),
meta: { title: '审批中心', permission: 'user-settings' },
},
// 模型调优
{
path: 'fine-tune',
@@ -299,6 +346,11 @@ const permissionBySegment: Record<string, PermissionCode> = {
hardware: 'hardware',
logs: 'logs',
'user-settings': 'user-settings',
tenants: 'user-settings',
projects: 'user-settings',
'audit-logs': 'user-settings',
'approval-templates': 'user-settings',
'approval-instances': 'user-settings',
}
function requiredPermission(path: string, explicit?: unknown) {
@@ -307,10 +359,11 @@ function requiredPermission(path: string, explicit?: unknown) {
return permissionBySegment[segment]
}
// 全局守卫:登录校验 + 会话超时
// 全局守卫:登录校验
// 离开页面超时由 App.vue 的 visibilitychange 监听接管
router.beforeEach((to, _from, next) => {
if (!to.meta.public) routeLoading.value = true
const auth = useAuthStore()
auth.syncSession()
document.title = to.meta.title ? `${to.meta.title} - 远光软件微调平台` : '远光软件微调平台'
if (to.meta.public) {
@@ -324,6 +377,7 @@ router.beforeEach((to, _from, next) => {
}
if (!auth.isLoggedIn) {
auth.logout()
next({ name: 'login' })
return
}
@@ -336,9 +390,11 @@ router.beforeEach((to, _from, next) => {
}
}
// 续期会话
auth.refresh()
next()
})
router.afterEach(() => {
routeLoading.value = false
})
export default router

View File

@@ -1,15 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi } from '@/api/modules/system'
import { SESSION_TIMEOUT } from '@/constants'
import type { PermissionCode, SystemUser } from '@/types'
import {
clearSessionActivity,
sessionActivityTime,
startSessionActivity,
syncSessionActivity,
touchSessionActivity,
} from '@/utils/sessionActivity'
const USER_STORAGE_KEY = 'currentUser'
@@ -56,7 +48,8 @@ function restoreUser(): SystemUser | null {
/**
* 认证 store
* 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制
* 登录态管理:有 currentUser 即视为已登录。
* 离开页面超时由 App.vue 的 visibilitychange 监听接管。
*/
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<SystemUser | null>(restoreUser())
@@ -67,18 +60,13 @@ export const useAuthStore = defineStore('auth', () => {
if (currentUser.value?.role === 'operator') return '操作员'
return '观察员'
})
const loginTime = sessionActivityTime
const isLoggedIn = computed(() => {
if (!loginTime.value) return false
return Date.now() - loginTime.value < SESSION_TIMEOUT
})
const isLoggedIn = computed(() => currentUser.value !== null)
/** 登录 */
async function login(user: string, password: string) {
const response = await loginApi(user, password)
currentUser.value = response.user
startSessionActivity()
localStorage.setItem('username', response.user.username)
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
}
@@ -89,20 +77,9 @@ export const useAuthStore = defineStore('auth', () => {
return currentUser.value?.permissions.includes(permission) ?? false
}
/** 续期会话(活跃时刷新) */
function refresh() {
if (currentUser.value) touchSessionActivity()
}
/** 在路由判断前吸收其他标签页写入的最后活跃时间。 */
function syncSession() {
syncSessionActivity()
}
/** 退出 */
function logout() {
currentUser.value = null
clearSessionActivity()
localStorage.removeItem('username')
localStorage.removeItem(USER_STORAGE_KEY)
}
@@ -112,12 +89,9 @@ export const useAuthStore = defineStore('auth', () => {
username,
displayName,
roleLabel,
loginTime,
isLoggedIn,
hasPermission,
login,
refresh,
syncSession,
logout,
}
})

View File

@@ -8,7 +8,7 @@ function storedActivityTime() {
/**
* 会话按“最后活跃时间”计算,而不是从首次登录起固定倒计时。
* 该 ref 被认证 store 与请求层共享,确保 API 活动可以立即影响路由守卫
* 该 ref 被认证 store 与路由守卫共享,确保真实用户活动可以立即影响超时判断
*/
export const sessionActivityTime = ref(storedActivityTime())
@@ -30,3 +30,45 @@ export function clearSessionActivity() {
sessionActivityTime.value = 0
localStorage.removeItem(LOGIN_TIME_STORAGE_KEY)
}
/**
* 仅在用户真实活跃时续期会话:
* - 鼠标移动 / 键盘 / 点击 / 触摸(说明用户正在操作)
* - 标签页切回可见(说明用户回到界面)
* 页面后台轮询接口、切走标签页不会续期,从而“无操作”或“不在当前界面”
* 超过空闲时长才会被判定为会话过期并跳回登录。
*/
let userActivityBound = false
let lastTouch = 0
const ACTIVITY_THROTTLE = 5000 // 5s 内最多续期一次,避免 mousemove 过于频繁
const activityEvents = ['mousemove', 'mousedown', 'keydown', 'click', 'touchstart'] as const
function handleUserActivity() {
const now = Date.now()
if (now - lastTouch < ACTIVITY_THROTTLE) return
lastTouch = now
touchSessionActivity()
}
function handleVisibility() {
if (!document.hidden) {
touchSessionActivity()
}
}
export function bindUserActivityListeners() {
if (userActivityBound) return
userActivityBound = true
activityEvents.forEach((evt) =>
window.addEventListener(evt, handleUserActivity, { passive: true })
)
document.addEventListener('visibilitychange', handleVisibility)
}
export function unbindUserActivityListeners() {
if (!userActivityBound) return
userActivityBound = false
activityEvents.forEach((evt) => window.removeEventListener(evt, handleUserActivity))
document.removeEventListener('visibilitychange', handleVisibility)
}

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import { getApprovalInstances, decideApproval, type ApprovalInstance } from '@/api/modules/approval'
import { getUsers, type SystemUser } from '@/api/modules/system'
const loading = ref(false)
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 statusOptions = [
{ label: '待审批', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
]
async function load() {
loading.value = true
try {
instances.value = await getApprovalInstances(statusFilter.value)
} finally {
loading.value = false
}
}
async function loadUsers() {
try {
users.value = await getUsers()
} catch {
users.value = []
}
}
function userName(id?: string) {
if (!id) return '—'
return users.value.find((u) => u.id === id)?.username || id
}
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: '' }
showDecide.value = true
}
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,
})
ElMessage.success('审批已提交')
showDecide.value = false
load()
}
onMounted(() => {
loadUsers()
load()
})
</script>
<template>
<div class="page">
<DataTablePage title="审批实例" :data="instances" :loading="loading" searchable search-fields="resource_type,resource_id">
<template #toolbar-extra>
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="load">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</template>
<template #columns>
<el-table-column prop="resource_type" label="资源类型" min-width="120" />
<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(row.applicant_id) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100" />
<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="row.status === 'pending'" link type="primary" @click="openDecide(row)">审批</el-button>
</template>
</DataTablePage>
<el-dialog v-model="showDecide" title="审批决策" width="480px">
<el-form label-width="80px" v-if="current">
<el-form-item label="实例">
{{ current.resource_type }} / {{ current.resource_id }}
</el-form-item>
<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>
<el-form-item label="结果">
<el-radio-group v-model="decision.approved">
<el-radio :value="true">通过</el-radio>
<el-radio :value="false">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="意见">
<el-input v-model="decision.comment" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDecide = false">取消</el-button>
<el-button type="primary" @click="submitDecision">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.page { padding: 16px; }
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { onMounted, 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'
const loading = ref(false)
const templates = ref<ApprovalTemplate[]>([])
const showCreate = ref(false)
const form = ref({ name: '', stepsText: '[]' })
async function load() {
loading.value = true
try {
templates.value = await getApprovalTemplates()
} 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('模板创建成功')
showCreate.value = false
form.value = { name: '', stepsText: '[]' }
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>
<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="create_time" label="创建时间" min-width="180" />
</template>
</DataTablePage>
<el-dialog v-model="showCreate" title="新建审批模板" width="560px">
<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>
<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">
.page { padding: 16px; }
</style>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAuditLogs, exportAuditLogs, type AuditLog, type AuditQuery } from '@/api/modules/audit'
const loading = ref(false)
const logs = ref<AuditLog[]>([])
const total = ref(0)
const query = reactive<AuditQuery>({
tenant_id: '',
project_id: '',
actor_id: '',
action: '',
target_type: '',
start_time: '',
end_time: '',
limit: 50,
offset: 0,
})
// 时间范围el-date-picker 双向绑定数组 [start, end]
const timeRange = ref<[string, string] | null>(null)
function applyTimeRange() {
if (timeRange.value && timeRange.value.length === 2) {
query.start_time = timeRange.value[0]
query.end_time = timeRange.value[1]
} else {
query.start_time = ''
query.end_time = ''
}
}
async function load() {
loading.value = true
try {
const res = await getAuditLogs({ ...query })
logs.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
async function handleExport() {
try {
const blob = await exportAuditLogs({ ...query, limit: 10000, offset: 0 })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit_logs_${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
} catch {
ElMessage.error('导出失败')
}
}
onMounted(load)
</script>
<template>
<div class="page">
<div class="page-header">
<h2 class="page-title">审计日志</h2>
<el-button @click="handleExport">导出 CSV</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true">
<el-form-item label="租户">
<el-input v-model="query.tenant_id" placeholder="tenant_id" clearable />
</el-form-item>
<el-form-item label="项目">
<el-input v-model="query.project_id" placeholder="project_id" clearable />
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="query.actor_id" placeholder="actor_id" clearable />
</el-form-item>
<el-form-item label="动作">
<el-input v-model="query.action" placeholder="action" clearable />
</el-form-item>
<el-form-item label="目标类型">
<el-input v-model="query.target_type" placeholder="target_type" clearable />
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ss"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
clearable
style="width: 360px"
@change="applyTimeRange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
</el-form-item>
</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 prop="tenant_id" label="租户" min-width="120" />
<el-table-column prop="project_id" label="项目" min-width="120" />
<el-table-column prop="actor_id" label="操作人" min-width="120" />
<el-table-column prop="action" label="动作" min-width="140" />
<el-table-column prop="target_type" label="目标类型" min-width="120" />
<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>
<div class="pager"> {{ total }} </div>
</div>
</template>
<style scoped lang="scss">
.page { padding: 16px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.page-title { margin: 0; font-size: 18px; }
.filter-card { margin-bottom: 16px; }
.log-table { margin-top: 8px; }
.pager { margin-top: 12px; text-align: right; color: #909399; }
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import VChart from 'vue-echarts'
import '@/plugins/echarts'
import type { EChartsOption } from 'echarts'
import { getDashboardStats } from '@/api/modules/dashboard'
type ServiceState = 'normal' | 'busy' | 'error'
type TaskState = 'running' | 'pending' | 'completed' | 'failed'
@@ -16,7 +17,7 @@ interface ServiceStatus {
}
interface DashboardTask {
id: number
id: string
name: string
state: TaskState
trainType: string
@@ -43,59 +44,37 @@ interface RecentLoginUser {
const router = useRouter()
const period = ref('7d')
const serviceStatuses: ServiceStatus[] = [
{ name: '模型推理', icon: 'fa-cube', state: 'normal', instances: '6 / 6' },
{ name: '模型微调', icon: 'fa-sliders', state: 'busy', instances: '4 / 6' },
{ name: '模型评测', icon: 'fa-bar-chart', state: 'normal', instances: '3 / 3' },
{ name: '数据处理', icon: 'fa-filter', state: 'error', instances: '1 / 3' },
]
const onlineServices = ref(0)
const runningTasks = ref(0)
const pendingAlerts = ref(0)
const trainingTasks: DashboardTask[] = [
{
id: 103942,
name: 'finance-sft-003',
state: 'running',
trainType: 'SFT',
trainMethod: 'LoRA',
baseModel: 'Qwen2.5-7B-Instruct',
progress: 68,
accuracy: 89.2,
startedAt: '今天 09:18',
},
{
id: 593021,
name: 'legal-eval-008',
state: 'pending',
trainType: 'DPO',
trainMethod: 'LoRA',
baseModel: 'Qwen2.5-7B-Instruct',
progress: 0,
accuracy: null,
startedAt: '今天 08:55',
},
{
id: 849301,
name: 'medical-cpt-002',
state: 'completed',
trainType: 'CPT',
trainMethod: 'Full',
baseModel: 'Qwen2.5-14B-Instruct',
progress: 100,
accuracy: 91.6,
startedAt: '07/10 16:20',
},
{
id: 201948,
name: 'finance-sft-002',
state: 'failed',
trainType: 'SFT',
trainMethod: 'LoRA',
baseModel: 'Qwen2.5-7B-Instruct',
progress: 42,
accuracy: null,
startedAt: '07/10 11:08',
},
]
const serviceStatuses = ref<ServiceStatus[]>([])
const trainingTasks = ref<DashboardTask[]>([])
const loginDurationStats = ref<LoginDurationStat[]>([])
const recentLoginUsers = ref<RecentLoginUser[]>([])
const training7d = ref<{ date: string; train: number; gpu: number; accuracy: number | null }[]>([])
const onlineServicesHint = computed(() => {
if (onlineServices.value === 0) return '暂无在线服务'
const abnormal = serviceStatuses.value.filter(
(s) => s.state === 'busy' || s.state === 'error'
).length
return abnormal > 0 ? `${abnormal} 个异常` : '全部在线'
})
const operationDistribution = ref<{ name: string; value: number }[]>([])
const serviceIcon: Record<string, string> = {
'模型推理': 'fa-cube',
'模型微调': 'fa-sliders',
'模型评测': 'fa-bar-chart',
'数据处理': 'fa-filter',
}
const roleLabel: Record<string, string> = {
admin: '超级管理员',
operator: '操作员',
observer: '观察员',
guest: '访客',
}
const serviceStateMeta: Record<ServiceState, { label: string; className: string }> = {
normal: { label: '正常', className: 'is-normal' },
@@ -140,7 +119,7 @@ const chartOption = computed<EChartsOption>(() => ({
},
xAxis: {
type: 'category',
data: ['07/05', '07/06', '07/07', '07/08', '07/09', '07/10', '07/11\n今天'],
data: training7d.value.map((d) => d.date),
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisTick: { show: false },
axisLabel: { color: '#64748b', fontSize: 11, lineHeight: 16, margin: 12 },
@@ -175,7 +154,7 @@ const chartOption = computed<EChartsOption>(() => ({
{
name: '训练次数(次)',
type: 'bar',
data: [8, 12, 10, 15, 13, 18, 11],
data: training7d.value.map((d) => d.train),
barMaxWidth: 16,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
@@ -183,7 +162,7 @@ const chartOption = computed<EChartsOption>(() => ({
{
name: 'GPU 使用数(个)',
type: 'bar',
data: [3, 4, 4, 6, 5, 7, 5],
data: training7d.value.map((d) => d.gpu),
barMaxWidth: 16,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
@@ -192,7 +171,7 @@ const chartOption = computed<EChartsOption>(() => ({
name: '平均准确率(%',
type: 'bar',
yAxisIndex: 1,
data: [82, 85, 84, 88, 87, 91, 89],
data: training7d.value.map((d) => d.accuracy ?? null),
barMaxWidth: 16,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: { show: true, position: 'top', color: '#d97706', fontSize: 10 },
@@ -200,58 +179,61 @@ const chartOption = computed<EChartsOption>(() => ({
],
}))
const operationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
tooltip: { trigger: 'item' },
color: ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899'],
series: [
{
name: '操作分类',
type: 'pie',
radius: ['40%', '64%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
// 模块固定配色,保证每个模块颜色不同
const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6']
const operationChartOption = computed<EChartsOption>(() => {
const items = operationDistribution.value
const total = items.reduce((s, d) => s + (d.value || 0), 0)
// 完全没有操作数据时,用等分灰色占位扇区,保证 6 个模块都可见
const data =
total > 0
? items.map((d) => ({ value: d.value || 0, name: d.name }))
: items.map((d) => ({ value: 1, name: d.name, itemStyle: { color: '#e2e8f0' } }))
return {
animationDuration: 500,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
color: OPERATION_COLORS,
legend: {
type: 'scroll',
bottom: 0,
textStyle: { color: '#64748b', fontSize: 11 },
itemWidth: 10,
itemHeight: 10,
},
series: [
{
name: '操作分类',
type: 'pie',
radius: ['38%', '60%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
position: 'outside',
formatter: '{b}\n{d}%',
color: '#475569',
fontSize: 11,
lineHeight: 15,
},
emphasis: {
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' },
},
labelLine: {
show: true,
length: 8,
length2: 8,
lineStyle: { color: '#94a3b8', width: 1 },
},
data,
},
label: {
show: true,
position: 'outside',
formatter: '{b}',
color: '#475569',
fontSize: 11,
lineHeight: 16,
width: 70,
overflow: 'truncate',
},
emphasis: {
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' }
},
labelLine: {
show: true,
length: 10,
length2: 8,
lineStyle: { color: '#94a3b8', width: 1 },
},
data: [
{ value: 1048, name: '模型训练' },
{ value: 735, name: '数据处理' },
{ value: 580, name: '模型评测' },
{ value: 484, name: '模型推理' },
{ value: 300, name: '系统设置' }
]
}
]
}))
const loginDurationStats: LoginDurationStat[] = [
{ id: 1, username: 'admin', duration: 124 },
{ id: 2, username: 'zhangsan', duration: 86 },
{ id: 3, username: 'lisi', duration: 42 },
{ id: 4, username: 'wangwu', duration: 18 },
]
],
}
})
const loginDurationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
@@ -263,7 +245,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
},
xAxis: {
type: 'value',
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10) * 10,
max: Math.max(10, Math.ceil(Math.max(...loginDurationStats.value.map((user) => user.duration), 0) * 1.15 / 10) * 10),
splitNumber: 4,
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
axisLine: { show: false },
@@ -273,7 +255,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
yAxis: {
type: 'category',
inverse: true,
data: loginDurationStats.map((user) => user.username),
data: loginDurationStats.value.map((user) => user.username),
axisLabel: { color: '#475569', fontSize: 12 },
axisLine: { show: false },
axisTick: { show: false },
@@ -282,7 +264,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
{
name: '登录时长',
type: 'bar',
data: loginDurationStats.map((user) => user.duration),
data: loginDurationStats.value.map((user) => user.duration),
barMaxWidth: 18,
barCategoryGap: '34%',
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
@@ -291,19 +273,51 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
],
}))
const recentLoginUsers: RecentLoginUser[] = [
{ id: 1, username: 'admin', role: '超级管理员', lastLogin: '10 分钟前' },
{ id: 2, username: 'zhangsan', role: '操作员', lastLogin: '2 小时前' },
{ id: 5, username: 'zhaoliu', role: '观察员', lastLogin: '5 小时前' },
{ id: 3, username: 'lisi', role: '操作员', lastLogin: '昨天 15:30' },
]
const roleTagType: Record<string, 'danger' | 'primary' | 'info'> = {
'超级管理员': 'danger',
'操作员': 'primary',
'观察员': 'info',
}
async function loadStats() {
const stats = await getDashboardStats()
onlineServices.value = stats.online_services
runningTasks.value = stats.running_tasks
pendingAlerts.value = stats.pending_alerts
serviceStatuses.value = stats.service_status.map((s) => ({
name: s.type,
icon: serviceIcon[s.type] || 'fa-cube',
state: s.status as ServiceState,
instances: String(s.count),
}))
trainingTasks.value = stats.training_tasks.map((t) => ({
id: String(t.id),
name: t.name,
state: t.status as TaskState,
trainType: t.train_type,
trainMethod: t.train_method,
baseModel: t.base_model,
progress: t.progress,
accuracy: t.accuracy,
startedAt: t.started_at,
}))
loginDurationStats.value = stats.login_duration_rank.map((u, i) => ({
id: i + 1,
username: u.user,
duration: u.duration,
}))
recentLoginUsers.value = stats.recent_login_users.map((u, i) => ({
id: i + 1,
username: u.user,
role: roleLabel[u.role] || u.role,
lastLogin: u.last_login,
}))
training7d.value = stats.training_7d
operationDistribution.value = stats.operation_distribution
}
onMounted(loadStats)
function viewAllTasks() {
router.push('/fine-tune')
}
@@ -329,17 +343,17 @@ function viewTask(task: DashboardTask) {
<div class="overview-metrics">
<div class="overview-metric">
<span>在线服务</span>
<strong>12</strong>
<small>全部在线</small>
<strong>{{ onlineServices }}</strong>
<small>{{ onlineServicesHint }}</small>
</div>
<div class="overview-metric">
<span>运行中任务</span>
<strong>5</strong>
<strong>{{ runningTasks }}</strong>
<small>较昨日 +1</small>
</div>
<div class="overview-metric is-alert">
<span>待处理告警</span>
<strong>2</strong>
<strong>{{ pendingAlerts }}</strong>
<small>较昨日 -1</small>
</div>
</div>
@@ -391,7 +405,10 @@ function viewTask(task: DashboardTask) {
<section class="stat-card" aria-labelledby="login-dur-title">
<h2 id="login-dur-title" class="section-title">登录时长排行 (本月)</h2>
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
<div v-if="loginDurationStats.length" class="chart-container">
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
</div>
<div v-else class="empty-hint">暂无数据</div>
</section>
<section class="stat-card" aria-labelledby="recent-login-title">
@@ -515,6 +532,15 @@ function viewTask(task: DashboardTask) {
height: 224px;
}
.empty-hint {
flex: 1 1 auto;
display: grid;
place-items: center;
min-height: 224px;
color: #94a3b8;
font-size: 13px;
}
.duration-chart {
width: 100%;
height: 224px;

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import AclDialog from '@/components/AclDialog.vue'
import { getProject, getProjectMembers, addProjectMember, removeProjectMember, type Project, type ProjectMember } from '@/api/modules/project'
import { getUsers, type SystemUser } from '@/api/modules/system'
const route = useRoute()
const router = useRouter()
const project = ref<Project | null>(null)
const members = ref<ProjectMember[]>([])
const users = ref<SystemUser[]>([])
const loading = ref(false)
const aclVisible = ref(false)
const showAddMember = ref(false)
const addForm = ref({ user_id: '', role: 'member' })
async function load() {
const id = route.params.id as string
loading.value = true
try {
project.value = await getProject(id)
members.value = await getProjectMembers(id)
} finally {
loading.value = false
}
}
async function loadUsers() {
try {
users.value = await getUsers()
} catch {
users.value = []
}
}
async function submitAddMember() {
if (!project.value) return
if (!addForm.value.user_id) {
ElMessage.warning('请选择用户')
return
}
await addProjectMember(project.value.id, { ...addForm.value })
ElMessage.success('成员已添加')
showAddMember.value = false
addForm.value = { user_id: '', role: 'member' }
load()
}
async function removeMember(userId: string) {
if (!project.value) return
await removeProjectMember(project.value.id, userId)
ElMessage.success('已移除成员')
load()
}
onMounted(() => {
loadUsers()
load()
})
</script>
<template>
<div class="page">
<el-page-header title="返回" @back="router.back()">
<template #content>
<span class="page-title">项目详情{{ project?.name }}</span>
</template>
</el-page-header>
<el-card class="section" v-loading="loading">
<template #header>基本信息</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="名称">{{ project?.name }}</el-descriptions-item>
<el-descriptions-item label="编码">{{ project?.code }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ project?.status }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ project?.tenant_id }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ project?.description }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<el-button @click="aclVisible = true">资源授权 (ACL)</el-button>
</el-card>
<el-card class="section">
<template #header>
项目成员
<el-button type="primary" size="small" style="float: right" @click="showAddMember = true">添加成员</el-button>
</template>
<DataTablePage title="项目成员" :data="members">
<template #columns>
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column prop="display_name" label="显示名" min-width="120" />
<el-table-column prop="role" label="角色" min-width="100" />
<el-table-column prop="create_time" label="加入时间" min-width="180" />
</template>
<template #actions="{ row }">
<el-button link type="danger" @click="removeMember(row.user_id)">移除</el-button>
</template>
</DataTablePage>
</el-card>
<AclDialog v-model="aclVisible" resource-type="project" :resource-id="(route.params.id as string)" />
<el-dialog v-model="showAddMember" title="添加成员" width="420px">
<el-form label-width="80px">
<el-form-item label="用户" required>
<el-select v-model="addForm.user_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>
<el-form-item label="角色">
<el-select v-model="addForm.role" style="width: 100%">
<el-option label="member" value="member" />
<el-option label="admin" value="admin" />
<el-option label="viewer" value="viewer" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddMember = false">取消</el-button>
<el-button type="primary" @click="submitAddMember">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.page { padding: 16px; }
.section { margin-top: 16px; }
.page-title { font-size: 16px; font-weight: 600; }
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import DataTablePage from '@/components/DataTablePage.vue'
import { createProject, getProjects, type Project } from '@/api/modules/project'
import { getTenants, type Tenant } from '@/api/modules/tenant'
const router = useRouter()
const loading = ref(false)
const projects = ref<Project[]>([])
const tenants = ref<Tenant[]>([])
const tenantId = ref('default')
const showCreate = ref(false)
const form = ref({ name: '', code: '', description: '', tenant_id: 'default' })
const tenantOptions = computed(() => [
{ label: 'default', value: 'default' },
...tenants.value.map((t) => ({ label: t.name, value: t.id })),
])
async function load() {
loading.value = true
try {
projects.value = await getProjects(tenantId.value)
} finally {
loading.value = false
}
}
async function loadTenants() {
try {
tenants.value = await getTenants()
} catch {
tenants.value = []
}
}
function openDetail(id: string) {
router.push(`/projects/${id}`)
}
async function submitCreate() {
if (!form.value.name || !form.value.code) {
ElMessage.warning('请填写项目名与编码')
return
}
await createProject({ ...form.value })
ElMessage.success('项目创建成功')
showCreate.value = false
form.value = { name: '', code: '', description: '', tenant_id: 'default' }
load()
}
onMounted(() => {
loadTenants()
load()
})
</script>
<template>
<div class="page">
<DataTablePage title="项目空间" :data="projects" :loading="loading" searchable search-fields="name,code">
<template #toolbar-extra>
<el-select v-model="tenantId" placeholder="租户" style="width: 160px" @change="load">
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-button type="primary" :icon="Plus" @click="showCreate = true">新建项目</el-button>
</template>
<template #columns>
<el-table-column prop="name" label="项目名" min-width="140" />
<el-table-column prop="code" label="编码" min-width="100" />
<el-table-column prop="status" label="状态" min-width="100" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<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>
</template>
</DataTablePage>
<el-dialog v-model="showCreate" title="新建项目" width="520px">
<el-form label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="项目名" />
</el-form-item>
<el-form-item label="编码" required>
<el-input v-model="form.code" placeholder="project code" />
</el-form-item>
<el-form-item label="租户">
<el-select v-model="form.tenant_id" style="width: 100%">
<el-option v-for="t in tenantOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<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">
.page { padding: 16px; }
</style>

View File

@@ -26,6 +26,7 @@ const trainContent = ref('')
// 搜索
const keyword = ref('')
const level = ref('') // 日志级别筛选INFO/WARN/ERROR/空=全部
const fullContent = ref('')
// 自动刷新
@@ -33,11 +34,16 @@ const refreshInterval = ref(10)
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
const filteredLog = computed(() => {
if (!keyword.value.trim()) return { content: fullContent.value, count: 0 }
const kw = keyword.value.toLowerCase().trim()
const lines = fullContent.value
.split('\n')
.filter((line) => line.toLowerCase().includes(kw))
let lines = fullContent.value.split('\n')
// 级别筛选
if (level.value) {
lines = lines.filter((line) => line.toUpperCase().includes(level.value.toUpperCase()))
}
// 关键词筛选
if (keyword.value.trim()) {
const kw = keyword.value.toLowerCase().trim()
lines = lines.filter((line) => line.toLowerCase().includes(kw))
}
return { content: lines.join('\n'), count: lines.length }
})
@@ -176,12 +182,19 @@ onMounted(() => {
<!-- 日志内容 -->
<div class="log-content-box">
<div class="log-toolbar">
<el-input v-model="keyword" placeholder="搜索日志..." size="small" clearable style="width: 240px">
<template #prefix><i class="fa fa-search" /></template>
</el-input>
<span v-if="keyword" class="match-count">{{ matchCount }} 条匹配</span>
</div>
<div class="log-toolbar">
<el-input v-model="keyword" placeholder="搜索日志..." size="small" clearable style="width: 240px">
<template #prefix><i class="fa fa-search" /></template>
</el-input>
<el-select v-model="level" placeholder="日志级别" size="small" clearable style="width: 120px">
<el-option value="" label="全部级别" />
<el-option value="INFO" label="INFO" />
<el-option value="WARN" label="WARN" />
<el-option value="ERROR" label="ERROR" />
<el-option value="DEBUG" label="DEBUG" />
</el-select>
<span v-if="keyword || level" class="match-count">{{ matchCount }} 条匹配</span>
</div>
<pre class="log-pre">{{ filteredContent || (activeTab === 'system' ? sysContent : trainContent) || '日志内容将在这里显示...' }}</pre>
</div>
</PageCard>

View File

@@ -1,12 +1,36 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getUsers } from '@/api/modules/system'
import type { SystemUser } from '@/types'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
deleteUser,
getUsers,
resetUserPassword,
updateUserAccess,
} from '@/api/modules/system'
import type { PermissionCode, SystemUser, UserStatus } from '@/types'
import { statusLabel, statusTagType } from '@/utils/status'
const loading = ref(false)
const users = ref<SystemUser[]>([])
// 权限码 -> 中文名(与路由模块一一对应)
const PERMISSION_LABELS: Record<PermissionCode, string> = {
dashboard: '服务看板',
'fine-tune': '模型训练',
'model-eval': '模型评测',
'model-inference': '模型推理',
'model-manage': '模型管理',
dataset: '数据集管理',
'data-process': '数据处理',
'data-convert': '数据转换',
compute: '计算资源',
hardware: '硬件监控',
logs: '日志中心',
'user-settings': '用户与权限',
}
const ALL_PERMISSIONS = Object.keys(PERMISSION_LABELS) as PermissionCode[]
async function loadUsers() {
loading.value = true
try {
@@ -17,6 +41,109 @@ async function loadUsers() {
}
onMounted(loadUsers)
// 当前登录用户,用于禁止操作自身(避免误锁自己)
const currentUsername = ref<string>('')
try {
currentUsername.value = JSON.parse(localStorage.getItem('currentUser') || '{}').username || ''
} catch {
currentUsername.value = ''
}
function isSelf(row: SystemUser) {
return row.username === currentUsername.value
}
// ---------- 启停 ----------
async function toggleStatus(row: SystemUser, next: boolean) {
const nextStatus: UserStatus = next ? 'active' : 'disabled'
const prev = row.status
row.status = nextStatus
try {
await updateUserAccess(row.id, { status: nextStatus })
ElMessage.success(`${row.display_name}${next ? '启用' : '停用'}`)
await loadUsers()
} catch {
row.status = prev
ElMessage.error('状态更新失败')
}
}
// ---------- 重置密码 ----------
const pwdDialog = reactive({ visible: false, id: '', name: '', password: '', saving: false })
function openResetPwd(row: SystemUser) {
pwdDialog.id = row.id
pwdDialog.name = row.display_name
pwdDialog.password = 'Platform@123'
pwdDialog.visible = true
}
async function confirmResetPwd() {
if (!pwdDialog.password.trim()) {
ElMessage.warning('请输入新密码')
return
}
pwdDialog.saving = true
try {
await resetUserPassword(pwdDialog.id, pwdDialog.password.trim())
ElMessage.success(`已重置 ${pwdDialog.name} 的密码`)
pwdDialog.visible = false
} catch {
ElMessage.error('重置密码失败')
} finally {
pwdDialog.saving = false
}
}
// ---------- 页面权限 ----------
const permDialog = reactive({
visible: false,
id: '',
name: '',
checked: [] as PermissionCode[],
saving: false,
})
function openPerms(row: SystemUser) {
permDialog.id = row.id
permDialog.name = row.display_name
permDialog.checked = [...(row.permissions || [])]
permDialog.visible = true
}
async function confirmPerms() {
permDialog.saving = true
try {
await updateUserAccess(permDialog.id, { permissions: permDialog.checked })
ElMessage.success(`已更新 ${permDialog.name} 的页面权限`)
permDialog.visible = false
await loadUsers()
} catch {
ElMessage.error('权限更新失败')
} finally {
permDialog.saving = false
}
}
const permColumns = computed(() => ALL_PERMISSIONS)
// ---------- 删除 ----------
async function removeUser(row: SystemUser) {
try {
await ElMessageBox.confirm(
`确定删除用户 “${row.display_name}${row.username})” 吗?该操作不可恢复。`,
'删除用户',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await deleteUser(row.id)
ElMessage.success(`已删除 ${row.display_name}`)
await loadUsers()
} catch (err: any) {
const msg = err?.response?.data?.message || '删除失败'
ElMessage.error(msg)
}
}
</script>
<template>
@@ -24,25 +151,93 @@ onMounted(loadUsers)
<header class="page-header">
<div>
<h1>用户设置</h1>
<p>管理平台账号角色状态页面权限</p>
<p>管理平台账号角色状态登录密码与页面权限</p>
</div>
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
</header>
<el-table :data="users">
<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 prop="role" label="角色" width="120" />
<el-table-column label="状态" width="120">
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限" width="120">
<template #default="{ row }">{{ row.permissions?.length || 0 }}</template>
<el-table-column label="页面权限" min-width="160">
<template #default="{ row }">
<el-tag
v-for="p in (row.permissions || []).slice(0, 3)"
:key="p"
size="small"
type="info"
class="perm-tag"
>{{ PERMISSION_LABELS[p] || p }}</el-tag>
<span v-if="(row.permissions || []).length > 3" class="perm-more">
+{{ (row.permissions || []).length - 3 }}
</span>
<span v-if="!(row.permissions || []).length" class="perm-more"></span>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" min-width="180" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-switch
:model-value="row.status === 'active'"
:disabled="row.protected || isSelf(row)"
@change="(v: any) => toggleStatus(row, v)"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
<el-button
link
type="primary"
:disabled="row.protected"
@click="openResetPwd(row)"
>重置密码</el-button>
<el-button
link
type="primary"
@click="openPerms(row)"
>页面权限</el-button>
<el-button
link
type="danger"
:disabled="row.protected || isSelf(row)"
@click="removeUser(row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 重置密码 -->
<el-dialog v-model="pwdDialog.visible" title="重置密码" width="420px">
<p class="dlg-tip"> <b>{{ pwdDialog.name }}</b> 设置新密码</p>
<el-input v-model="pwdDialog.password" placeholder="请输入新密码" show-password />
<template #footer>
<el-button @click="pwdDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="pwdDialog.saving" @click="confirmResetPwd">确定重置</el-button>
</template>
</el-dialog>
<!-- 页面权限 -->
<el-dialog v-model="permDialog.visible" title="页面权限" width="540px">
<p class="dlg-tip"> <b>{{ permDialog.name }}</b> 分配可访问的页面模块</p>
<el-checkbox-group v-model="permDialog.checked" class="perm-group">
<el-checkbox
v-for="code in permColumns"
:key="code"
:value="code"
:label="PERMISSION_LABELS[code]"
/>
</el-checkbox-group>
<template #footer>
<el-button @click="permDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -67,4 +262,25 @@ onMounted(loadUsers)
margin: 8px 0 0;
color: #64748b;
}
.perm-tag {
margin-right: 4px;
margin-bottom: 2px;
}
.perm-more {
color: #94a3b8;
font-size: 12px;
}
.dlg-tip {
margin: 0 0 12px;
color: #475569;
}
.perm-group {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 12px;
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import { getTenant, setTenantQuota, type Tenant } from '@/api/modules/tenant'
import { getProjects, type Project } from '@/api/modules/project'
const route = useRoute()
const router = useRouter()
const tenant = ref<Tenant | null>(null)
const projects = ref<Project[]>([])
const loading = ref(false)
const quotaText = ref('')
async function load() {
const id = route.params.id as string
loading.value = true
try {
tenant.value = await getTenant(id)
projects.value = await getProjects(id)
quotaText.value = JSON.stringify(tenant.value?.quota || {})
} finally {
loading.value = false
}
}
async function saveQuota() {
if (!tenant.value) return
try {
const q = JSON.parse(quotaText.value || '{}')
await setTenantQuota(tenant.value.id, q)
ElMessage.success('配额已保存')
load()
} catch {
ElMessage.error('配额需为合法 JSON')
}
}
function openProject(id: string) {
router.push(`/projects/${id}`)
}
onMounted(load)
</script>
<template>
<div class="page">
<el-page-header title="返回" @back="router.back()">
<template #content>
<span class="page-title">租户详情{{ tenant?.name }}</span>
</template>
</el-page-header>
<el-card class="section" v-loading="loading">
<template #header>基本信息</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="名称">{{ tenant?.name }}</el-descriptions-item>
<el-descriptions-item label="编码">{{ 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>
<el-divider />
<div class="quota-edit">
<span class="label">配额 JSON</span>
<el-input v-model="quotaText" type="textarea" :rows="3" />
<el-button type="primary" @click="saveQuota">保存配额</el-button>
</div>
</el-card>
<el-card class="section">
<template #header>项目空间</template>
<DataTablePage title="项目空间" :data="projects">
<template #columns>
<el-table-column prop="name" label="项目名" min-width="140" />
<el-table-column prop="code" label="编码" min-width="100" />
<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="openProject(row.id)">打开</el-button>
</template>
</DataTablePage>
</el-card>
</div>
</template>
<style scoped lang="scss">
.page { padding: 16px; }
.section { margin-top: 16px; }
.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; }
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
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, getTenants, setTenantQuota, type Tenant } from '@/api/modules/tenant'
const router = useRouter()
const loading = ref(false)
const tenants = ref<Tenant[]>([])
const showCreate = ref(false)
const form = ref({ name: '', code: '', quota: '' as string })
async function load() {
loading.value = true
try {
tenants.value = await getTenants()
} finally {
loading.value = false
}
}
function openDetail(id: string) {
router.push(`/tenants/${id}`)
}
async function submitCreate() {
if (!form.value.name) {
ElMessage.warning('请填写租户名称')
return
}
let quota: Record<string, unknown> = {}
if (form.value.quota) {
try {
quota = JSON.parse(form.value.quota)
} catch {
ElMessage.error('配额需为合法 JSON')
return
}
}
await createTenant({ name: form.value.name, code: form.value.code, quota })
ElMessage.success('租户创建成功')
showCreate.value = false
form.value = { name: '', code: '', quota: '' }
load()
}
async function setQuota(row: Tenant) {
const input = await ElMessageBox.prompt('输入租户配额 JSON', '设置配额', {
inputValue: JSON.stringify(row.quota || {}),
}).catch(() => null)
if (!input) return
try {
const q = JSON.parse(input.value)
await setTenantQuota(row.id, q)
ElMessage.success('配额已更新')
load()
} catch {
ElMessage.error('无效的 JSON')
}
}
onMounted(load)
</script>
<template>
<div class="page">
<DataTablePage title="租户管理" :data="tenants" :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="140" />
<el-table-column prop="code" label="编码" min-width="100" />
<el-table-column label="配额" min-width="160">
<template #default="{ row }">
{{ Object.keys(row.quota || {}).length ? JSON.stringify(row.quota) : '—' }}
</template>
</el-table-column>
<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="primary" @click="setQuota(row)">配额</el-button>
</template>
</DataTablePage>
<el-dialog v-model="showCreate" title="新建租户" width="520px">
<el-form label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="租户名称" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="form.code" placeholder="tenant code" />
</el-form-item>
<el-form-item label="配额 JSON">
<el-input v-model="form.quota" type="textarea" :rows="3" placeholder='{"gpu": 8}' />
</el-form-item>
</el-form>
<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">
.page { padding: 16px; }
</style>