Compare commits

...

2 Commits

5 changed files with 482 additions and 33 deletions

2
components.d.ts vendored
View File

@ -43,6 +43,8 @@ declare module 'vue' {
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElUpload: typeof import('element-plus/es')['ElUpload']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']

View File

@ -19,6 +19,30 @@ export const serversApi = {
syncUserPermissions(id: number, users: Array<Record<string, unknown>>, partial = false) {
return request.put(`/servers/${id}/user-permissions`, { users, partial })
},
systemUsersMeta(id: number) {
return request.get(`/servers/${id}/system-users/meta`)
},
createSystemUser(id: number, data: Record<string, unknown>) {
return request.post(`/servers/${id}/system-users`, data)
},
removeSystemUser(id: number, username: string) {
return request.delete(`/servers/${id}/system-users/${encodeURIComponent(username)}`)
},
updateSystemUserPassword(id: number, username: string, data: Record<string, unknown>) {
return request.patch(`/servers/${id}/system-users/${encodeURIComponent(username)}/password`, data)
},
updateBoundSystemUserPassword(id: number, data: Record<string, unknown>) {
return request.patch(`/servers/${id}/bound-system-user/password`, data)
},
createSystemGroup(id: number, data: Record<string, unknown>) {
return request.post(`/servers/${id}/system-groups`, data)
},
removeSystemGroup(id: number, groupname: string) {
return request.delete(`/servers/${id}/system-groups/${encodeURIComponent(groupname)}`)
},
syncSystemUserGroups(id: number, username: string, groups: string[], mode = 'replace') {
return request.put(`/servers/${id}/system-users/${encodeURIComponent(username)}/groups`, { groups, mode })
},
useResource(id: number, data: Record<string, unknown>) {
return request.post(`/servers/${id}/use`, data)
},

View File

@ -22,6 +22,9 @@ export const usersApi = {
syncBatchAssignments(payload: { user_ids: number[]; role_ids: number[]; permission_ids: number[] }) {
return request.put('/users/batch-assignments', payload)
},
syncServerBindings(id: number, serverBindings: Array<Record<string, unknown>>) {
return request.put(`/users/${id}/server-bindings`, { server_bindings: serverBindings })
},
importUsers(file: File) {
const formData = new FormData()
formData.append('file', file)

View File

@ -21,6 +21,8 @@
<span class='server-name'>{{ server.display_name || server.name }}</span>
<span class='server-code'>{{ server.name }}</span>
<span class='server-ip'>{{ server.internal_ip }}</span>
<el-tag v-if='server.server_username' size='small' type='success'>{{ server.server_username }}</el-tag>
<el-button v-if='server.server_username' size='small' plain :loading='updatingBoundPassword' @click.stop='openBoundPasswordDialog(server)'>改密</el-button>
</div>
</template>
@ -28,6 +30,7 @@
<el-button size='small' @click='openEdit(server)'>编辑服务器</el-button>
<el-button size='small' type='primary' plain @click='openCreateResource(server.id)'>添加资源</el-button>
<el-button size='small' type='success' plain @click='openServerPermissionDialog(server)'>分配用户权限</el-button>
<el-button size='small' type='warning' plain @click='openSystemUsersDialog(server)'>管理用户及用户组</el-button>
<el-button size='small' type='danger' @click='removeRow(server)'>删除服务器</el-button>
</div>
@ -42,6 +45,9 @@
<el-table-column label='协议' width='120' sortable :sort-method='sortByProtocol'>
<template #default='{ row }'>{{ row.protocols?.[0] || '-' }}</template>
</el-table-column>
<el-table-column label='绑定用户' min-width='120'>
<template #default='{ row }'>{{ row.server_username || '-' }}</template>
</el-table-column>
<el-table-column label='状态' width='90' prop='is_active' sortable>
<template #default='{ row }'><el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '启用' :
'停用' }}</el-tag></template>
@ -86,6 +92,12 @@
<el-form-item label='内网IP' v-if='form.type === "server"'><el-input v-model='form.internal_ip' /></el-form-item>
<el-form-item label='asset_id' v-if='form.type === "server"'><el-input-number v-model='form.asset_id' :min='1'
class='w-full' /></el-form-item>
<el-form-item label='用户API地址' v-if='form.type === "server"'>
<el-input v-model='form.user_api_base_url' placeholder='http://10.0.0.28:8000' clearable />
</el-form-item>
<el-form-item label='用户API密钥' v-if='form.type === "server"'>
<el-input v-model='form.user_api_token' type='password' show-password placeholder='留空则不修改' clearable />
</el-form-item>
<el-form-item label='account_id' v-if='form.type === "resource"'><el-input-number v-model='form.account_id'
:min='1' class='w-full' /></el-form-item>
<el-form-item label='协议' v-if='form.type === "resource"'>
@ -106,6 +118,90 @@
</template>
</el-dialog>
<el-dialog v-model='systemUsersDialogVisible' :title='`管理用户及用户组 - ${systemUsersServer?.display_name || systemUsersServer?.name || ""}`' width='96vw' top='3vh' class='system-users-dialog'>
<div class='system-user-toolbar btn-gap-8'>
<el-button type='primary' @click='openCreateSystemUser'>新增服务器用户</el-button>
<el-button type='success' @click='openCreateSystemGroup'>新增用户组</el-button>
<el-button :loading='systemUsersLoading' @click='loadSystemUsersMeta'>刷新</el-button>
</div>
<el-tabs v-model='systemUsersTab'>
<el-tab-pane label='用户' name='users'>
<el-table :data='systemUsers' v-loading='systemUsersLoading' border max-height='62vh'>
<el-table-column prop='username' label='用户名' min-width='140' fixed='left' />
<el-table-column prop='uid' label='UID' width='90' />
<el-table-column prop='gid' label='GID' width='90' />
<el-table-column prop='home_dir' label='Home' min-width='220' />
<el-table-column prop='shell' label='Shell' min-width='130' />
<el-table-column label='用户组' min-width='260'>
<template #default='{ row }'>
<el-select v-model='systemUserGroupSelections[row.username]' multiple collapse-tags clearable class='w-full'>
<el-option v-for='group in systemGroups' :key='group.groupname' :label='group.groupname' :value='group.groupname' />
</el-select>
</template>
</el-table-column>
<el-table-column label='绑定SSO用户' min-width='180'>
<template #default='{ row }'>{{ bindingLabel(row.username) }}</template>
</el-table-column>
<el-table-column label='操作' width='260' fixed='right'>
<template #default='{ row }'>
<div class='btn-gap-8 btn-gap-8--nowrap'>
<el-button size='small' type='primary' plain :loading='savingSystemUserGroups[row.username]' @click='saveSystemUserGroups(row)'>保存组</el-button>
<el-button size='small' @click='openPasswordDialog(row)'>改密码</el-button>
<el-button size='small' type='danger' @click='removeSystemUser(row)'>删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label='用户组' name='groups'>
<el-table :data='systemGroups' v-loading='systemUsersLoading' border max-height='62vh'>
<el-table-column prop='groupname' label='用户组' min-width='180' />
<el-table-column prop='gid' label='GID' width='100' />
<el-table-column label='成员' min-width='360'>
<template #default='{ row }'>{{ (row.members || []).join(', ') || '-' }}</template>
</el-table-column>
<el-table-column label='操作' width='120'>
<template #default='{ row }'>
<el-button size='small' type='danger' @click='removeSystemGroup(row)'>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-dialog>
<el-dialog v-model='systemUserFormVisible' :title='systemUserFormMode === "group" ? "新增用户组" : "新增服务器用户"' width='520px'>
<el-form :model='systemUserForm' label-width='95px'>
<template v-if='systemUserFormMode === "user"'>
<el-form-item label='用户名'><el-input v-model='systemUserForm.username' /></el-form-item>
<el-form-item label='密码'><el-input v-model='systemUserForm.password' type='password' show-password /></el-form-item>
<el-form-item label='用户组'>
<el-select v-model='systemUserForm.groups' multiple collapse-tags clearable class='w-full'>
<el-option v-for='group in systemGroups' :key='group.groupname' :label='group.groupname' :value='group.groupname' />
</el-select>
</el-form-item>
</template>
<template v-else>
<el-form-item label='用户组'><el-input v-model='systemUserForm.groupname' /></el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click='systemUserFormVisible = false'>取消</el-button>
<el-button type='primary' :loading='systemUsersSaving' @click='submitSystemUserForm'>提交</el-button>
</template>
</el-dialog>
<el-dialog v-model='passwordDialogVisible' :title='`修改密码 - ${passwordTarget?.username || ""}`' width='460px'>
<el-form :model='passwordForm' label-width='80px'>
<el-form-item label='新密码'><el-input v-model='passwordForm.password' type='password' show-password /></el-form-item>
</el-form>
<template #footer>
<el-button @click='passwordDialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='systemUsersSaving' @click='submitPassword'>提交</el-button>
</template>
</el-dialog>
<el-dialog v-model='permissionDialogVisible' :title='permissionDialogTitle' width='1100px'>
<el-table :data='permissionMatrixRows' max-height='520' border>
<el-table-column prop='nickname' label='用户名' min-width='150' fixed='left' />
@ -149,6 +245,21 @@
<el-button type='primary' :loading='usingResource' @click='submitUseResource'>连接并访问</el-button>
</template>
</el-dialog>
<el-dialog v-model='boundPasswordDialogVisible' :title='`修改绑定账号密码 - ${boundPasswordTarget?.server_username || ""}`' width='460px'>
<el-form :model='boundPasswordForm' label-width='90px'>
<el-form-item label='服务器账号'>
<div>{{ boundPasswordTarget?.server_username || '-' }}</div>
</el-form-item>
<el-form-item label='新密码'>
<el-input v-model='boundPasswordForm.password' type='password' show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click='boundPasswordDialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='updatingBoundPassword' @click='submitBoundPassword'>提交</el-button>
</template>
</el-dialog>
</el-card>
<el-card v-loading='opsLoading' style="margin-top: 1rem;">
@ -205,11 +316,7 @@
<el-dialog v-model='protocolDialogVisible' :title='editingProtocolId ? "编辑协议" : "新增协议"' width='520px'>
<el-form :model='protocolForm' label-width='90px'>
<el-form-item label='协议名称'><el-input v-model='protocolForm.name' /></el-form-item>
<el-form-item label='协议ID'><el-input-number v-model='protocolForm.bastion_protocol_id' :min='1'
class='w-full' /></el-form-item>
<el-form-item label='描述'><el-input v-model='protocolForm.description' /></el-form-item>
<el-form-item label='排序'><el-input-number v-model='protocolForm.sort' :min='0' class='w-full' /></el-form-item>
<el-form-item label='启用'><el-switch v-model='protocolForm.is_active' /></el-form-item>
</el-form>
<template #footer>
<el-button @click='protocolDialogVisible = false'>取消</el-button>
@ -223,8 +330,6 @@
</div>
<el-table :data='softwareProtocol?.softwares || []' border>
<el-table-column prop='name' label='软件名称' min-width='160' />
<el-table-column prop='client_path' label='ClientPath' min-width='220' />
<el-table-column prop='sort' label='排序' width='80' />
<el-table-column label='启用' width='80'>
<template #default='{ row }'><el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '是' : '否'
}}</el-tag></template>
@ -238,17 +343,18 @@
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click='softwareDialogVisible = false'>关闭</el-button>
</template>
</el-dialog>
<el-divider />
<el-dialog v-model='softwareFormDialogVisible' :title='editingSoftwareId ? "编辑软件" : "新增软件"' width='520px'>
<el-form :model='softwareForm' label-width='90px'>
<el-form-item label='软件名称'><el-input v-model='softwareForm.name' /></el-form-item>
<el-form-item label='ClientPath'><el-input v-model='softwareForm.client_path'
placeholder='可为空' /></el-form-item>
<el-form-item label='排序'><el-input-number v-model='softwareForm.sort' :min='0' class='w-full' /></el-form-item>
<el-form-item label='启用'><el-switch v-model='softwareForm.is_active' /></el-form-item>
</el-form>
<template #footer>
<el-button @click='softwareDialogVisible = false'>关闭</el-button>
<el-button @click='softwareFormDialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='opsSaving' @click='submitSoftware'>{{ editingSoftwareId ? '更新软件' : '新增软件'
}}</el-button>
</template>
@ -271,11 +377,20 @@ const saving = ref(false)
const dialogVisible = ref(false)
const permissionDialogVisible = ref(false)
const useDialogVisible = ref(false)
const systemUsersDialogVisible = ref(false)
const systemUserFormVisible = ref(false)
const passwordDialogVisible = ref(false)
const boundPasswordDialogVisible = ref(false)
const protocolDialogVisible = ref(false)
const softwareDialogVisible = ref(false)
const softwareFormDialogVisible = ref(false)
const savingPermissions = ref(false)
const usingResource = ref(false)
const copyingTempPassword = ref(false)
const updatingBoundPassword = ref(false)
const systemUsersLoading = ref(false)
const systemUsersSaving = ref(false)
const savingSystemUserGroups = reactive<Record<string, boolean>>({})
const opsLoading = ref(false)
const opsSaving = ref(false)
const forceSaveOpsReady = ref(false)
@ -290,17 +405,29 @@ const permissionMatrixRows = ref<any[]>([])
const permissionResourceOptions = ref<any[]>([])
const activeServers = ref<string[]>([])
const useTargetRow = ref<any | null>(null)
const systemUsersServer = ref<any | null>(null)
const passwordTarget = ref<any | null>(null)
const boundPasswordTarget = ref<any | null>(null)
const opsProtocols = ref<any[]>([])
const softwareProtocol = ref<any | null>(null)
const opsLinkLoading = ref<Record<number, boolean>>({})
const opsSavedSelections = reactive<Record<string, number | null>>({})
const typeOptions = [{ label: '服务器', value: 'server' }, { label: '资源', value: 'resource' }]
const form = reactive<any>({ type: 'server', name: '', display_name: '', parent_id: null, internal_ip: '', asset_id: null, account_id: null, protocol: '', allow_copy_temp_password: false, description: '', is_active: true })
const form = reactive<any>({ type: 'server', name: '', display_name: '', parent_id: null, internal_ip: '', user_api_base_url: '', user_api_token: '', asset_id: null, account_id: null, protocol: '', allow_copy_temp_password: false, description: '', is_active: true })
const systemUserForm = reactive<any>({ username: '', password: '', groups: [], groupname: '' })
const useForm = reactive<any>({ protocol: 'SSH', account_name: '', password: '', remember: false, last_temp_password: '' })
const protocolForm = reactive<any>({ name: '', bastion_protocol_id: 2, description: '', sort: 0, is_active: true })
const softwareForm = reactive<any>({ name: '', client_path: '', sort: 0, is_active: true })
const passwordForm = reactive<any>({ password: '' })
const boundPasswordForm = reactive<any>({ password: '' })
const protocolForm = reactive<any>({ name: '', description: '' })
const softwareForm = reactive<any>({ name: '', is_active: true })
const opsSelections = reactive<Record<string, number | null>>({})
const systemUserGroupSelections = reactive<Record<string, string[]>>({})
const systemUsers = ref<any[]>([])
const systemGroups = ref<any[]>([])
const systemUserBindings = ref<any[]>([])
const systemUsersTab = ref<'users' | 'groups'>('users')
const systemUserFormMode = ref<'user' | 'group'>('user')
const permissionDialogTitle = computed(() => (permissionMode.value === 'server_assign' ? '服务器资源用户权限分配' : '资源已有用户权限修改'))
const opsSaveButtonLabel = computed(() => (forceSaveOpsReady.value ? '强制保存' : '保存我的软件选择'))
const resourceProtocolOptions = computed<string[]>(() => {
@ -340,6 +467,8 @@ function resetForm(): void {
display_name: '',
parent_id: null,
internal_ip: '',
user_api_base_url: '',
user_api_token: '',
asset_id: null,
account_id: null,
protocol: resourceProtocolOptions.value[0] || '',
@ -384,6 +513,8 @@ function openEdit(row: any): void {
display_name: row.display_name || '',
parent_id: row.parent_id,
internal_ip: row.internal_ip,
user_api_base_url: row.user_api_base_url || '',
user_api_token: '',
asset_id: row.asset_id,
account_id: row.account_id,
protocol: row.protocols?.[0] || 'SSH',
@ -411,6 +542,8 @@ async function submit(): Promise<void> {
name: form.name,
display_name: form.display_name || form.name,
internal_ip: form.type === 'server' ? form.internal_ip : null,
user_api_base_url: form.type === 'server' ? form.user_api_base_url || null : null,
user_api_token: form.type === 'server' ? form.user_api_token || null : null,
asset_id: form.type === 'server' ? form.asset_id : null,
account_id: form.type === 'resource' ? form.account_id : null,
protocol: form.type === 'resource' ? form.protocol : null,
@ -556,13 +689,183 @@ function openUseResourceDialog(row: any): void {
const serverId = Number(row.parent_id || row.id || 0)
const saved = getServerCredential(currentUserId.value, serverId)
useForm.protocol = row.protocols?.[0] || 'SSH'
useForm.account_name = saved?.account_name || ''
useForm.account_name = row.server_username || saved?.account_name || ''
useForm.password = saved?.password || ''
useForm.remember = Boolean(saved)
useForm.last_temp_password = ''
useDialogVisible.value = true
}
function openBoundPasswordDialog(row: any): void {
boundPasswordTarget.value = row
boundPasswordForm.password = ''
boundPasswordDialogVisible.value = true
}
async function submitBoundPassword(): Promise<void> {
if (!boundPasswordTarget.value?.id) {
ElMessage.warning('未选择服务器')
return
}
if (!boundPasswordForm.password?.trim()) {
ElMessage.warning('请输入新密码')
return
}
updatingBoundPassword.value = true
try {
await serversApi.updateBoundSystemUserPassword(boundPasswordTarget.value.id, {
password: boundPasswordForm.password,
})
boundPasswordDialogVisible.value = false
if (useTargetRow.value?.id === boundPasswordTarget.value.id || useTargetRow.value?.parent_id === boundPasswordTarget.value.id) {
useForm.password = boundPasswordForm.password
}
ElMessage.success('绑定账号密码已更新')
} catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '修改绑定账号密码失败')
} finally {
updatingBoundPassword.value = false
}
}
async function openSystemUsersDialog(server: any): Promise<void> {
systemUsersServer.value = server
systemUsersTab.value = 'users'
systemUsersDialogVisible.value = true
await loadSystemUsersMeta()
}
async function loadSystemUsersMeta(): Promise<void> {
if (!systemUsersServer.value?.id) {
return
}
systemUsersLoading.value = true
try {
const response: any = await serversApi.systemUsersMeta(systemUsersServer.value.id)
systemUsers.value = response.data.users || []
systemGroups.value = response.data.groups || []
systemUserBindings.value = response.data.bindings || []
Object.keys(systemUserGroupSelections).forEach((key) => delete systemUserGroupSelections[key])
Object.keys(savingSystemUserGroups).forEach((key) => delete savingSystemUserGroups[key])
const userGroups = response.data.user_groups || {}
for (const user of systemUsers.value) {
systemUserGroupSelections[user.username] = [...(userGroups[user.username] || [])]
}
} catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '加载服务器用户失败')
} finally {
systemUsersLoading.value = false
}
}
function bindingLabel(username: string): string {
const binding = systemUserBindings.value.find((item: any) => item.username === username)
const user = binding?.user
return user ? `${user.nickname || user.email || user.id}` : '-'
}
function openCreateSystemUser(): void {
systemUserFormMode.value = 'user'
Object.assign(systemUserForm, { username: '', password: '', groups: [], groupname: '' })
systemUserFormVisible.value = true
}
function openCreateSystemGroup(): void {
systemUserFormMode.value = 'group'
Object.assign(systemUserForm, { username: '', password: '', groups: [], groupname: '' })
systemUserFormVisible.value = true
}
async function submitSystemUserForm(): Promise<void> {
if (!systemUsersServer.value?.id) {
return
}
systemUsersSaving.value = true
try {
if (systemUserFormMode.value === 'group') {
await serversApi.createSystemGroup(systemUsersServer.value.id, { groupname: systemUserForm.groupname })
ElMessage.success('用户组创建成功')
} else {
await serversApi.createSystemUser(systemUsersServer.value.id, {
username: systemUserForm.username,
password: systemUserForm.password,
groups: systemUserForm.groups,
})
ElMessage.success('服务器用户创建成功')
}
systemUserFormVisible.value = false
await loadSystemUsersMeta()
} catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '提交失败')
} finally {
systemUsersSaving.value = false
}
}
async function saveSystemUserGroups(row: any): Promise<void> {
if (!systemUsersServer.value?.id) {
return
}
savingSystemUserGroups[row.username] = true
try {
await serversApi.syncSystemUserGroups(systemUsersServer.value.id, row.username, systemUserGroupSelections[row.username] || [], 'replace')
ElMessage.success('用户组已保存')
await loadSystemUsersMeta()
} catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '保存用户组失败')
} finally {
savingSystemUserGroups[row.username] = false
}
}
function openPasswordDialog(row: any): void {
passwordTarget.value = row
passwordForm.password = ''
passwordDialogVisible.value = true
}
async function submitPassword(): Promise<void> {
if (!systemUsersServer.value?.id || !passwordTarget.value?.username) {
return
}
systemUsersSaving.value = true
try {
await serversApi.updateSystemUserPassword(systemUsersServer.value.id, passwordTarget.value.username, { password: passwordForm.password })
ElMessage.success('密码已更新')
passwordDialogVisible.value = false
} catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '修改密码失败')
} finally {
systemUsersSaving.value = false
}
}
async function removeSystemUser(row: any): Promise<void> {
if (!systemUsersServer.value?.id) {
return
}
await ElMessageBox.confirm(`确认删除服务器用户 ${row.username} 吗?`, '提示', { type: 'warning' })
await serversApi.removeSystemUser(systemUsersServer.value.id, row.username)
ElMessage.success('服务器用户已删除')
await loadSystemUsersMeta()
}
async function removeSystemGroup(row: any): Promise<void> {
if (!systemUsersServer.value?.id) {
return
}
await ElMessageBox.confirm(`确认删除用户组 ${row.groupname} 吗?`, '提示', { type: 'warning' })
await serversApi.removeSystemGroup(systemUsersServer.value.id, row.groupname)
ElMessage.success('用户组已删除')
await loadSystemUsersMeta()
}
async function submitUseResource(): Promise<void> {
const result = await requestUseResource('connect')
if (!result) {
@ -679,7 +982,7 @@ async function fetchOpsMeta(): Promise<void> {
}
function resetProtocolForm(): void {
Object.assign(protocolForm, { name: '', bastion_protocol_id: 2, description: '', sort: 0, is_active: true })
Object.assign(protocolForm, { name: '', description: '' })
}
function openCreateProtocol(): void {
@ -692,10 +995,7 @@ function openEditProtocol(row: any): void {
editingProtocolId.value = row.id
Object.assign(protocolForm, {
name: row.name,
bastion_protocol_id: row.bastion_protocol_id || 2,
description: row.description || '',
sort: row.sort || 0,
is_active: Boolean(row.is_active),
})
protocolDialogVisible.value = true
}
@ -705,10 +1005,7 @@ async function submitProtocol(): Promise<void> {
try {
const payload = {
name: protocolForm.name,
bastion_protocol_id: Number(protocolForm.bastion_protocol_id || 2),
description: protocolForm.description || '',
sort: protocolForm.sort || 0,
is_active: Boolean(protocolForm.is_active),
}
if (editingProtocolId.value) {
await serversApi.updateOpsProtocol(editingProtocolId.value, payload)
@ -736,27 +1033,28 @@ async function removeProtocol(row: any): Promise<void> {
function resetSoftwareForm(): void {
editingSoftwareId.value = null
Object.assign(softwareForm, { name: '', client_path: '', sort: 0, is_active: true })
Object.assign(softwareForm, { name: '', is_active: true })
}
function openSoftwareDialog(row: any): void {
softwareProtocol.value = row
resetSoftwareForm()
softwareFormDialogVisible.value = false
softwareDialogVisible.value = true
}
function openCreateSoftware(): void {
resetSoftwareForm()
softwareFormDialogVisible.value = true
}
function openEditSoftware(row: any): void {
editingSoftwareId.value = row.id
Object.assign(softwareForm, {
name: row.name,
client_path: row.client_path || '',
sort: row.sort || 0,
is_active: Boolean(row.is_active),
})
softwareFormDialogVisible.value = true
}
async function submitSoftware(): Promise<void> {
@ -768,8 +1066,6 @@ async function submitSoftware(): Promise<void> {
try {
const payload = {
name: softwareForm.name,
client_path: softwareForm.client_path || '',
sort: softwareForm.sort || 0,
is_active: Boolean(softwareForm.is_active),
}
if (editingSoftwareId.value) {
@ -779,6 +1075,7 @@ async function submitSoftware(): Promise<void> {
await serversApi.createOpsSoftware(softwareProtocol.value.id, payload)
ElMessage.success('软件创建成功')
}
softwareFormDialogVisible.value = false
resetSoftwareForm()
await fetchOpsMeta()
softwareProtocol.value = opsProtocols.value.find((item) => item.id === softwareProtocol.value?.id) || null
@ -916,6 +1213,15 @@ onBeforeUnmount(() => {
font-size: 14px;
}
.system-user-toolbar {
margin-bottom: 12px;
}
:deep(.system-users-dialog .el-dialog__body) {
max-height: 78vh;
overflow: hidden;
}
:deep(.server-collapse) {
border-top: 0;
border-bottom: 0;

View File

@ -20,6 +20,9 @@
<el-table-column label='角色' min-width='160'>
<template #default='{ row }'>{{ (row.roles || []).map((r:any) => r.name).join(', ') || '-' }}</template>
</el-table-column>
<el-table-column label='服务器账号' min-width='260'>
<template #default='{ row }'>{{ serverBindingsLabel(row) }}</template>
</el-table-column>
<el-table-column label='直授权限' min-width='220'>
<template #default='{ row }'>{{ (row.permissions || []).map((p:any) => p.name).join(', ') || '-' }}</template>
</el-table-column>
@ -45,7 +48,7 @@
@size-change='handleSizeChange'
/>
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑用户" : "新增用户"' width='560px'>
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑用户" : "新增用户"' width='780px'>
<el-form :model='form' label-width='95px'>
<el-form-item label='昵称'><el-input v-model='form.nickname' /></el-form-item>
<el-form-item label='邮箱'><el-input v-model='form.email' /></el-form-item>
@ -57,6 +60,26 @@
<el-option v-for='item in roleOptions' :key='item.id' :label='item.name' :value='item.id' />
</el-select>
</el-form-item>
<el-divider>服务器账号绑定</el-divider>
<el-form-item label='服务器'>
<el-select v-model='form.server_ids' multiple collapse-tags clearable class='w-full' @change='syncServerBindingRows'>
<el-option v-for='item in serverOptions' :key='item.id' :label='item.display_name || item.name' :value='item.id' />
</el-select>
</el-form-item>
<el-table v-if='form.server_bindings.length' :data='form.server_bindings' border size='small' class='mb-3'>
<el-table-column label='服务器' min-width='160'>
<template #default='{ row }'>{{ serverLabel(row.server_resource_id) }}</template>
</el-table-column>
<el-table-column label='服务器用户名' min-width='160'>
<template #default='{ row }'><el-input v-model='row.username' /></template>
</el-table-column>
<el-table-column label='需要创建用户' width='130'>
<template #default='{ row }'><el-switch v-model='row.create_remote' /></template>
</el-table-column>
<el-table-column label='账号密码' min-width='160'>
<template #default='{ row }'><el-input v-model='row.password' type='password' show-password placeholder='默认使用用户密码' /></template>
</el-table-column>
</el-table>
</el-form>
<template #footer>
<el-button @click='dialogVisible = false'>取消</el-button>
@ -140,9 +163,10 @@
<script setup lang='ts'>
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { permissionsApi } from '@/api/permissions'
import { rolesApi } from '@/api/roles'
import { serversApi } from '@/api/servers'
import { usersApi } from '@/api/users'
import { buildPermissionCascader, extractPermissionIds } from '@/composables/permission-tree'
import { useAuthStore } from '@/stores/auth'
@ -165,6 +189,7 @@ const selectedUserIds = ref<number[]>([])
const rows = ref<any[]>([])
const roleOptions = ref<any[]>([])
const permissionOptions = ref<any[]>([])
const serverOptions = ref<any[]>([])
const selectedPermissionNodes = ref<Array<number | string>>([])
const batchAssignPermissionNodes = ref<Array<number | string>>([])
const importFile = ref<File | null>(null)
@ -175,7 +200,7 @@ const perPage = ref(20)
const sortBy = ref('created_at')
const sortOrder = ref<'asc' | 'desc'>('desc')
const form = reactive<any>({ nickname: '', email: '', phone: '', password: '', force_password_change: false, role_ids: [] })
const form = reactive<any>({ nickname: '', email: '', phone: '', password: '', force_password_change: false, role_ids: [], server_ids: [], server_bindings: [] })
const batchAssignForm = reactive<{ role_ids: number[] }>({ role_ids: [] })
const cascaderProps = { multiple: true, emitPath: false, checkStrictly: false }
const permissionCascader = computed(() => buildPermissionCascader(permissionOptions.value))
@ -200,10 +225,12 @@ async function fetchList(): Promise<void> {
async function fetchOptions(): Promise<void> {
try {
const [rolesResponse]: any = await Promise.all([
const [rolesResponse, serversResponse]: any = await Promise.all([
rolesApi.list({ page: 1, per_page: 100 }),
serversApi.list({ page: 1, per_page: 500 }),
])
roleOptions.value = rolesResponse.data.data || []
serverOptions.value = (serversResponse.data.data || []).filter((item: any) => !item.parent_id)
let nextPage = 1
const allPermissions: any[] = []
while (true) {
@ -254,7 +281,7 @@ function handleSortChange(payload: { prop: string, order: 'ascending' | 'descend
function openCreate(): void {
editingId.value = null
Object.assign(form, { nickname: '', email: '', phone: '', password: '', force_password_change: false, role_ids: [] })
Object.assign(form, { nickname: '', email: '', phone: '', password: '', force_password_change: false, role_ids: [], server_ids: [], server_bindings: [] })
dialogVisible.value = true
}
@ -267,6 +294,13 @@ function openEdit(row: any): void {
password: '',
force_password_change: Boolean(row.force_password_change),
role_ids: (row.roles || []).map((item: any) => item.id),
server_ids: (row.server_user_bindings || []).map((item: any) => item.server_resource_id),
server_bindings: (row.server_user_bindings || []).map((item: any) => ({
server_resource_id: item.server_resource_id,
username: item.username,
create_remote: false,
password: '',
})),
})
dialogVisible.value = true
}
@ -281,9 +315,16 @@ async function submit(): Promise<void> {
saving.value = true
try {
const payload: any = { ...form }
payload.server_bindings = form.server_bindings.map((item: any) => ({
server_resource_id: item.server_resource_id,
username: item.username,
create_remote: Boolean(item.create_remote),
password: item.password || undefined,
}))
if (!payload.password) {
delete payload.password
}
delete payload.server_ids
if (editingId.value) {
await usersApi.update(editingId.value, payload)
@ -303,6 +344,79 @@ async function submit(): Promise<void> {
}
}
function syncServerBindingRows(): void {
const defaultUsername = normalizeServerUsername(form.nickname || form.email || `user${editingId.value || ''}`)
const current = new Map<number, any>(form.server_bindings.map((item: any) => [Number(item.server_resource_id), item]))
form.server_bindings = form.server_ids.map((serverId: number) => {
const existing = current.get(Number(serverId))
if (existing) {
return existing
}
return {
server_resource_id: serverId,
username: defaultUsername,
create_remote: true,
password: '',
}
})
}
watch(() => form.nickname, () => {
const username = normalizeServerUsername(form.nickname || form.email || `user${editingId.value || ''}`)
form.server_bindings.forEach((item: any) => {
if (item.username) {
item.username = username
}
})
})
function serverBindingsLabel(row: any): string {
const bindings = row.server_user_bindings || []
if (!bindings.length) {
return '-'
}
return bindings
.map((item: any) => `${item.server?.display_name || item.server?.name || `服务器#${item.server_resource_id}`}: ${item.username}`)
.join('')
}
function serverLabel(serverId: number): string {
const server = serverOptions.value.find((item: any) => Number(item.id) === Number(serverId))
return server ? String(server.display_name || server.name || server.id) : `服务器#${serverId}`
}
function normalizeServerUsername(value: string): string {
const raw = String(value || '').trim()
const ascii = raw
.normalize('NFKD')
.replace(/[^\x00-\x7F]/g, (char) => pinyinMap[char] || '')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/^[^a-z_]+/, '')
.replace(/_+/g, '_')
.slice(0, 32)
return ascii || `user${Date.now().toString().slice(-6)}`
}
const pinyinMap: Record<string, string> = {
: 'zhang', : 'wang', : 'li', : 'zhao', : 'chen', : 'liu', : 'yang', : 'huang', : 'zhou', : 'wu',
: 'xu', : 'sun', : 'hu', : 'zhu', : 'gao', : 'lin', : 'he', : 'guo', : 'ma', : 'luo',
: 'liang', : 'song', : 'zheng', : 'xie', : 'han', : 'tang', : 'feng', : 'yu', : 'dong',
: 'xiao', : 'cheng', : 'cao', : 'yuan', : 'deng', : 'xu', : 'fu', : 'shen', : 'zeng',
: 'peng', : 'lv', : 'su', : 'lu', : 'jiang', : 'cai', : 'jia', : 'ding', : 'wei',
: 'xue', : 'ye', : 'yan', : 'yu', : 'pan', : 'du', : 'dai', : 'xia', : 'zhong',
: 'wang', : 'tian', : 'ren', : 'jiang', : 'fan', : 'fang', : 'shi', : 'yao', : 'tan',
: 'liao', : 'zou', : 'xiong', : 'jin', : 'lu', : 'hao', : 'kong', : 'bai', : 'cui',
: 'kang', : 'mao', : 'qiu', : 'qin', : 'jiang', : 'shi', : 'gu', : 'hou', : 'shao',
: 'meng', : 'long', : 'wan', : 'duan', : 'cao', : 'qian', : 'tang', : 'yin', : 'li',
: 'yi', : 'chang', : 'wu', : 'qiao', : 'he', : 'lai', : 'gong', : 'wen', : 'san',
: 'si', : 'wu', : 'ming', : 'hua', : 'qiang', : 'wei', : 'fang', : 'na', : 'min',
: 'jing', : 'li', : 'gang', : 'lei', : 'jun', : 'yang', : 'yong', : 'yan',
}
async function submitPermissions(): Promise<void> {
if (!permissionTargetUserId.value) {
return