完善部分平台治理功能,及修改看板缺陷
This commit is contained in:
38
frontend/src/api/modules/gpu.ts
Normal file
38
frontend/src/api/modules/gpu.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { del, get, post } from '../request'
|
||||
|
||||
export interface GpuAssignment {
|
||||
id: string
|
||||
node_id: string
|
||||
gpu_index: number
|
||||
user_id: string
|
||||
assigned_by?: string | null
|
||||
assigned_at?: string
|
||||
username?: string
|
||||
display_name?: string
|
||||
node_code?: string
|
||||
node_name?: string
|
||||
gpu_name?: string
|
||||
}
|
||||
|
||||
export interface MyGpu {
|
||||
node_id: string
|
||||
gpu_index: number
|
||||
node_code: string
|
||||
node_name: string
|
||||
gpu_name?: string
|
||||
uuid?: string
|
||||
memory_total_gb?: number
|
||||
}
|
||||
|
||||
/** 查看全部分配关系(admin) */
|
||||
export const getGpuAssignments = () => get<GpuAssignment[]>('/compute/gpu-assignments')
|
||||
|
||||
/** 批量分配 GPU(admin) */
|
||||
export const assignGpus = (assignments: Array<{ node_id: string; gpu_index: number; user_id: string }>) =>
|
||||
post<GpuAssignment[]>('/compute/gpu-assignments', { assignments })
|
||||
|
||||
/** 撤销 GPU 分配(admin) */
|
||||
export const unassignGpu = (id: string) => del(`/compute/gpu-assignments/${id}`)
|
||||
|
||||
/** 查看当前用户可用的 GPU 列表 */
|
||||
export const getMyGpus = () => get<MyGpu[]>('/compute/my-gpus')
|
||||
@@ -42,3 +42,10 @@ export const resetUserPassword = (id: string, password?: string) =>
|
||||
/** 删除用户(protected 管理员账号不允许删除) */
|
||||
export const deleteUser = (id: string) =>
|
||||
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`)
|
||||
|
||||
/** 用户自行修改密码 */
|
||||
export const changeMyPassword = (oldPassword: string, newPassword: string) =>
|
||||
post<{ changed: boolean }>('/users/me/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
|
||||
@@ -98,7 +98,14 @@ const visibleMenuGroups = computed(() =>
|
||||
menuGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => auth.hasPermission(item.permission)),
|
||||
items: group.items.filter((item) => {
|
||||
if (!auth.hasPermission(item.permission)) return false
|
||||
// user-settings 权限对应的菜单仅管理员可见
|
||||
if (item.permission === 'user-settings' && !auth.isAdmin) return false
|
||||
// 算力节点仅管理员可见
|
||||
if (item.permission === 'compute' && !auth.isAdmin) return false
|
||||
return true
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
@@ -388,6 +388,16 @@ router.beforeEach((to, _from, next) => {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
// user-settings 权限对应的页面仅管理员可访问
|
||||
if (permission === 'user-settings' && !auth.isAdmin) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
// 算力节点仅管理员可访问
|
||||
if (permission === 'compute' && !auth.isAdmin) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
|
||||
const isLoggedIn = computed(() => currentUser.value !== null)
|
||||
const isAdmin = computed(() => currentUser.value?.role === 'admin')
|
||||
|
||||
/** 登录 */
|
||||
async function login(user: string, password: string) {
|
||||
@@ -85,6 +86,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
displayName,
|
||||
roleLabel,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
hasPermission,
|
||||
login,
|
||||
logout,
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
type ResourceReplica,
|
||||
type ResourceSyncJob,
|
||||
} from '@/api/modules/compute'
|
||||
import { getGpuAssignments, assignGpus, unassignGpu, type GpuAssignment } from '@/api/modules/gpu'
|
||||
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { statusLabel, statusTagType } from '@/utils/status'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -37,6 +40,52 @@ const gpus = ref<ComputeGpu[]>([])
|
||||
const queue = ref<ComputeQueueItem[]>([])
|
||||
const replicas = ref<ResourceReplica[]>([])
|
||||
const activeSyncJob = ref<ResourceSyncJob | null>(null)
|
||||
|
||||
// GPU 分配管理
|
||||
const auth = useAuthStore()
|
||||
const gpuAssignments = ref<GpuAssignment[]>([])
|
||||
const assignmentLoading = ref(false)
|
||||
const showAssignDialog = ref(false)
|
||||
const assignForm = ref({ node_id: '', gpu_index: 0, user_id: '' })
|
||||
const allUsers = ref<SystemUser[]>([])
|
||||
|
||||
async function loadGpuAssignments() {
|
||||
if (!auth.isAdmin) return
|
||||
assignmentLoading.value = true
|
||||
try {
|
||||
gpuAssignments.value = await getGpuAssignments()
|
||||
} catch {
|
||||
gpuAssignments.value = []
|
||||
} finally {
|
||||
assignmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign() {
|
||||
if (!assignForm.value.node_id || !assignForm.value.user_id) {
|
||||
ElMessage.warning('请选择节点和用户')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await assignGpus([{ node_id: assignForm.value.node_id, gpu_index: assignForm.value.gpu_index, user_id: assignForm.value.user_id }])
|
||||
ElMessage.success('GPU 分配成功')
|
||||
showAssignDialog.value = false
|
||||
await loadGpuAssignments()
|
||||
} catch {
|
||||
ElMessage.error('GPU 分配失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnassign(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认撤销此 GPU 分配?', '提示', { type: 'warning' })
|
||||
await unassignGpu(id)
|
||||
ElMessage.success('已撤销分配')
|
||||
await loadGpuAssignments()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
const selectedNodeId = ref('')
|
||||
const lastUpdated = ref('')
|
||||
const nodeDialogVisible = ref(false)
|
||||
@@ -127,6 +176,10 @@ async function loadReplicas() {
|
||||
|
||||
async function changeTab(name: string | number) {
|
||||
await router.replace({ path: '/compute', query: { tab: String(name) } })
|
||||
if (name === 'assignments') {
|
||||
loadGpuAssignments()
|
||||
allUsers.value = await getUsers().catch(() => [])
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNodeAction(action: 'enable' | 'disable' | 'test', node: ComputeNode) {
|
||||
@@ -309,6 +362,7 @@ function formatTime(value?: string) {
|
||||
onMounted(() => {
|
||||
load({ showLoading: true })
|
||||
timer = setInterval(() => load(), 5000)
|
||||
if (auth.isAdmin) loadGpuAssignments()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -476,6 +530,24 @@ onUnmounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="auth.isAdmin" label="GPU 分配" name="assignments">
|
||||
<div style="margin-bottom: 16px">
|
||||
<el-button type="primary" @click="showAssignDialog = true">分配 GPU</el-button>
|
||||
</div>
|
||||
<el-table :data="gpuAssignments" border v-loading="assignmentLoading">
|
||||
<el-table-column prop="node_name" label="节点" min-width="140" />
|
||||
<el-table-column prop="gpu_index" label="GPU 序号" width="100" />
|
||||
<el-table-column prop="gpu_name" label="GPU 名称" min-width="160" />
|
||||
<el-table-column prop="display_name" label="被分配用户" min-width="140" />
|
||||
<el-table-column prop="assigned_at" label="分配时间" min-width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" @click="handleUnassign(row.id)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog
|
||||
@@ -526,6 +598,29 @@ onUnmounted(() => {
|
||||
<el-button type="primary" :loading="savingNode" @click="saveNode">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- GPU 分配对话框 -->
|
||||
<el-dialog v-model="showAssignDialog" title="分配 GPU" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="算力节点">
|
||||
<el-select v-model="assignForm.node_id" placeholder="选择节点">
|
||||
<el-option v-for="n in nodes" :key="n.id" :label="n.name || n.code" :value="n.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="GPU 序号">
|
||||
<el-input-number v-model="assignForm.gpu_index" :min="0" :max="15" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户">
|
||||
<el-select v-model="assignForm.user_id" placeholder="选择用户" filterable>
|
||||
<el-option v-for="u in allUsers" :key="u.id" :label="u.display_name || u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAssignDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { getModelList } from '@/api/modules/model'
|
||||
import { getDatasetList } from '@/api/modules/dataset'
|
||||
import { getSystemInfo } from '@/api/modules/system'
|
||||
import { getComputeNodes } from '@/api/modules/compute'
|
||||
import { getMyGpus } from '@/api/modules/gpu'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { TEMPLATE_GROUPS, LR_SCHEDULER_OPTIONS, QUANTIZATION_BIT_OPTIONS, QUANT_METHOD_OPTIONS, GGUF_FORMAT_OPTIONS } from '@/constants'
|
||||
import {
|
||||
DEFAULT_TRAINING_PARAMS,
|
||||
@@ -46,8 +48,21 @@ const availableGpus = computed(() => {
|
||||
.filter((n) => n.scheduler_status === 'online' || n.scheduler_status === 'draining')
|
||||
.map((n) => n.id),
|
||||
)
|
||||
return gpus.value.filter((gpu) => !gpu.node_id || onlineNodeIds.has(gpu.node_id))
|
||||
let result = gpus.value.filter((gpu) => !gpu.node_id || onlineNodeIds.has(gpu.node_id))
|
||||
// 普通用户只能看到被分配的 GPU
|
||||
if (!auth.isAdmin) {
|
||||
const assignedKeys = new Set(
|
||||
myAssignedGpus.value.map((g) => `${g.node_id}:${g.gpu_index}`),
|
||||
)
|
||||
result = result.filter((gpu) => {
|
||||
const key = `${gpu.node_id}:${gpu.id ?? gpu.uuid ?? gpu.name}`
|
||||
return assignedKeys.has(key) || myAssignedGpus.value.length === 0
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
const auth = useAuthStore()
|
||||
const myAssignedGpus = ref<Array<{ node_id: string; gpu_index: number }>>([])
|
||||
const modelDialogVisible = ref(false)
|
||||
|
||||
const form = reactive(createDefaultFineTuneForm())
|
||||
@@ -207,6 +222,14 @@ async function loadGpus() {
|
||||
const [sys, nodes] = await Promise.all([getSystemInfo(), getComputeNodes().catch(() => [])])
|
||||
gpus.value = sys?.gpu || []
|
||||
computeNodes.value = nodes || []
|
||||
// 普通用户加载被分配的 GPU
|
||||
if (!auth.isAdmin) {
|
||||
try {
|
||||
myAssignedGpus.value = await getMyGpus()
|
||||
} catch {
|
||||
myAssignedGpus.value = []
|
||||
}
|
||||
}
|
||||
const firstIdle = availableGpus.value.find((gpu) => !isGpuUnavailable(gpu) && gpu.id != null)
|
||||
if (firstIdle) selectedGpuKeys.value = [gpuKey(firstIdle)]
|
||||
} catch {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
changeMyPassword,
|
||||
deleteUser,
|
||||
getUsers,
|
||||
resetUserPassword,
|
||||
@@ -106,6 +107,34 @@ async function confirmResetPwd() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 用户自改密码 ----------
|
||||
const myPwdDialog = reactive({ visible: false, oldPassword: '', newPassword: '', saving: false })
|
||||
function openChangeMyPwd() {
|
||||
myPwdDialog.oldPassword = ''
|
||||
myPwdDialog.newPassword = ''
|
||||
myPwdDialog.visible = true
|
||||
}
|
||||
async function confirmChangeMyPwd() {
|
||||
if (!myPwdDialog.oldPassword.trim() || !myPwdDialog.newPassword.trim()) {
|
||||
ElMessage.warning('请填写旧密码和新密码')
|
||||
return
|
||||
}
|
||||
if (myPwdDialog.newPassword.length < 6) {
|
||||
ElMessage.warning('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
myPwdDialog.saving = true
|
||||
try {
|
||||
await changeMyPassword(myPwdDialog.oldPassword.trim(), myPwdDialog.newPassword.trim())
|
||||
ElMessage.success('密码修改成功')
|
||||
myPwdDialog.visible = false
|
||||
} catch {
|
||||
ElMessage.error('密码修改失败,请检查旧密码是否正确')
|
||||
} finally {
|
||||
myPwdDialog.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 页面权限 ----------
|
||||
const permDialog = reactive({
|
||||
visible: false,
|
||||
@@ -117,10 +146,24 @@ const permDialog = reactive({
|
||||
function openPerms(row: SystemUser) {
|
||||
permDialog.id = row.id
|
||||
permDialog.name = row.display_name
|
||||
permDialog.checked = [...(row.permissions || [])]
|
||||
// admin 用户强制全选且只读
|
||||
if (row.role === 'admin' || row.protected) {
|
||||
permDialog.checked = [...ALL_PERMISSIONS]
|
||||
} else {
|
||||
// 非 admin 用户去掉 user-settings
|
||||
permDialog.checked = (row.permissions || []).filter((p) => p !== 'user-settings')
|
||||
}
|
||||
permDialog.visible = true
|
||||
}
|
||||
async function confirmPerms() {
|
||||
if (permReadonly.value) {
|
||||
permDialog.visible = false
|
||||
return
|
||||
}
|
||||
// 双重保险:非 admin 用户不允许勾选 user-settings
|
||||
if (!isTargetAdmin.value) {
|
||||
permDialog.checked = permDialog.checked.filter((p) => p !== 'user-settings')
|
||||
}
|
||||
permDialog.saving = true
|
||||
try {
|
||||
await updateUserAccess(permDialog.id, { permissions: permDialog.checked })
|
||||
@@ -134,7 +177,17 @@ async function confirmPerms() {
|
||||
}
|
||||
}
|
||||
|
||||
const permColumns = computed(() => ALL_PERMISSIONS)
|
||||
/** 权限列:admin 用户全选且只读,非 admin 用户不显示 user-settings */
|
||||
const isTargetAdmin = computed(() => {
|
||||
const u = users.value.find((u) => u.id === permDialog.id)
|
||||
return u?.role === 'admin' || u?.protected === true
|
||||
})
|
||||
const permColumns = computed(() => {
|
||||
if (isTargetAdmin.value) return ALL_PERMISSIONS
|
||||
// 非 admin 用户不能拥有 user-settings 权限
|
||||
return ALL_PERMISSIONS.filter((c) => c !== 'user-settings')
|
||||
})
|
||||
const permReadonly = computed(() => isTargetAdmin.value)
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function removeUser(row: SystemUser) {
|
||||
@@ -165,7 +218,10 @@ async function removeUser(row: SystemUser) {
|
||||
<h1>用户设置</h1>
|
||||
<p>管理平台账号、角色状态、登录密码与页面权限。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
<div>
|
||||
<el-button @click="openChangeMyPwd">修改密码</el-button>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-table :data="users" border>
|
||||
@@ -236,8 +292,11 @@ async function removeUser(row: SystemUser) {
|
||||
|
||||
<!-- 页面权限 -->
|
||||
<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">
|
||||
<p class="dlg-tip">
|
||||
为 <b>{{ permDialog.name }}</b> 分配可访问的页面模块:
|
||||
<el-tag v-if="permReadonly" type="warning" size="small" style="margin-left: 8px">管理员权限不可更改</el-tag>
|
||||
</p>
|
||||
<el-checkbox-group v-model="permDialog.checked" class="perm-group" :disabled="permReadonly">
|
||||
<el-checkbox
|
||||
v-for="code in permColumns"
|
||||
:key="code"
|
||||
@@ -246,8 +305,24 @@ async function removeUser(row: SystemUser) {
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="permDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
|
||||
<el-button @click="permDialog.visible = false">{{ permReadonly ? '关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="!permReadonly" type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改自己的密码 -->
|
||||
<el-dialog v-model="myPwdDialog.visible" title="修改密码" width="420px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="旧密码">
|
||||
<el-input v-model="myPwdDialog.oldPassword" placeholder="请输入当前密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="myPwdDialog.newPassword" placeholder="至少 6 位" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="myPwdDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="myPwdDialog.saving" @click="confirmChangeMyPwd">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user