feat: 进一步完成界面优化
This commit is contained in:
parent
d4bf91868d
commit
cf3301c984
1
.gitignore
vendored
1
.gitignore
vendored
@ -29,3 +29,4 @@ BACKAPI.md
|
||||
LOG.md
|
||||
REQUIRE.md
|
||||
SKILL.md
|
||||
dist.zip
|
||||
|
||||
2
components.d.ts
vendored
2
components.d.ts
vendored
@ -8,6 +8,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
@ -41,6 +42,7 @@ declare module 'vue' {
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
@ -19,4 +19,18 @@ export const usersApi = {
|
||||
syncPermissions(id: number, permissionIds: number[]) {
|
||||
return request.put(`/users/${id}/permissions`, { permission_ids: permissionIds })
|
||||
},
|
||||
importUsers(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post('/users/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
downloadImportTemplate() {
|
||||
return request.get('/users/import/template', {
|
||||
responseType: 'blob',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export const DEFAULT_SITE_TITLE = '生物信息团队服务器'
|
||||
export const CONFIG_KEY_SITE_TITLE = 'site_title'
|
||||
|
||||
// 定义配置对象的类型,允许任意键值对
|
||||
export interface AppConfig {
|
||||
[key: string]: any
|
||||
|
||||
@ -22,16 +22,117 @@ export function buildPermissionCascader(items: PermissionItem[]): CascaderNode[]
|
||||
groups.get(category)?.push(item)
|
||||
})
|
||||
|
||||
return Array.from(groups.entries()).map(([category, groupItems]) => ({
|
||||
return Array.from(groups.entries()).map(([category, groupItems]) => {
|
||||
if (category === '资源使用') {
|
||||
return buildResourceUseCategoryNode(category, groupItems)
|
||||
}
|
||||
|
||||
return {
|
||||
label: category,
|
||||
value: `category:${category}`,
|
||||
children: groupItems.map((item) => ({
|
||||
label: item.description ? `${item.description}` : item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildResourceUseCategoryNode(category: string, groupItems: PermissionItem[]): CascaderNode {
|
||||
const directItems: CascaderNode[] = []
|
||||
const serverMap = new Map<string, { label: string; value: string; children: CascaderNode[] }>()
|
||||
|
||||
groupItems.forEach((item) => {
|
||||
const name = String(item.name || '')
|
||||
const parts = name.split('.')
|
||||
const parsed = parseResourceDisplayNames(item.description)
|
||||
if (!name.startsWith('resource.servers.use.') || parts.length < 4) {
|
||||
directItems.push({
|
||||
label: item.description ? `${item.description}` : item.name,
|
||||
value: item.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackServerName = String(parts[3] || '').trim()
|
||||
const fallbackResourceName = String(parts[4] || '').trim()
|
||||
const parsedServerName = String(parsed?.serverName || '').trim()
|
||||
const serverName = (parsedServerName && parsedServerName !== '未命名服务器') ? parsedServerName : fallbackServerName
|
||||
const resourceName = String(parsed?.resourceName || fallbackResourceName).trim()
|
||||
if (!serverName) {
|
||||
directItems.push({
|
||||
label: item.description ? `${item.description}` : item.name,
|
||||
value: item.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!serverMap.has(serverName)) {
|
||||
serverMap.set(serverName, {
|
||||
label: serverName,
|
||||
value: `server:${serverName}`,
|
||||
children: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (resourceName === '' || resourceName === serverName || fallbackResourceName === fallbackServerName) {
|
||||
serverMap.get(serverName)?.children.push({
|
||||
label: `${serverName} 总权限`,
|
||||
value: item.id,
|
||||
})
|
||||
} else {
|
||||
serverMap.get(serverName)?.children.push({
|
||||
label: `${serverName} ${resourceName}`,
|
||||
value: item.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const serverNodes = Array.from(serverMap.values()).sort((a, b) => a.label.localeCompare(b.label))
|
||||
serverNodes.forEach((node) => {
|
||||
node.children.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
||||
})
|
||||
|
||||
return {
|
||||
label: category,
|
||||
value: `category:${category}`,
|
||||
children: groupItems.map((item) => ({
|
||||
label: item.description ? `${item.name}(${item.description})` : item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
}))
|
||||
children: [...directItems, ...serverNodes],
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPermissionIds(values: Array<number | string>): number[] {
|
||||
return values.filter((item): item is number => typeof item === 'number')
|
||||
}
|
||||
|
||||
function parseResourceDisplayNames(description?: string): { serverName: string; resourceName: string } | null {
|
||||
const text = String(description || '').trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = text.match(/服务器资源访问权限((.+?),资源ID[::]\s*\d+)/)
|
||||
if (!match || !match[1]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const namePart = String(match[1]).trim()
|
||||
const splitIndex = namePart.indexOf('-')
|
||||
if (splitIndex <= 0 || splitIndex >= namePart.length - 1) {
|
||||
if (namePart.startsWith('未命名服务器-')) {
|
||||
return {
|
||||
serverName: namePart.slice('未命名服务器-'.length).trim(),
|
||||
resourceName: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: namePart,
|
||||
resourceName: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: namePart.slice(0, splitIndex).trim(),
|
||||
resourceName: namePart.slice(splitIndex + 1).trim(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<div class='brand-left'>
|
||||
<div class='brand-logo'>SSO</div>
|
||||
<div>
|
||||
<div class='brand-title'>Bastion 控制台</div>
|
||||
<div class='brand-title'>{{ siteTitle }}控制台</div>
|
||||
<div class='brand-sub'>安全访问管理</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -50,9 +50,10 @@
|
||||
<script setup lang='ts'>
|
||||
import { Checked, Document, House, Key, Lock, Monitor, User, UserFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed } from 'vue'
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { CONFIG_KEY_SITE_TITLE, DEFAULT_SITE_TITLE, useConfigKey } from '@/composables/config'
|
||||
import { removeToken } from '@/composables/token'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@ -60,9 +61,15 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
const siteTitleConfig = useConfigKey<string>(CONFIG_KEY_SITE_TITLE, DEFAULT_SITE_TITLE)
|
||||
const siteTitle = computed(() => (siteTitleConfig.value || DEFAULT_SITE_TITLE).trim() || DEFAULT_SITE_TITLE)
|
||||
|
||||
const userInitial = computed(() => authStore.user?.nickname?.slice(0, 1)?.toUpperCase() || 'U')
|
||||
|
||||
watchEffect(() => {
|
||||
document.title = siteTitle.value
|
||||
})
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前登录状态吗?', '提示', { type: 'warning' })
|
||||
|
||||
|
||||
@ -25,22 +25,45 @@
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model='forcePasswordDialogVisible' title='请先修改密码' width='480px' :close-on-click-modal='true' :show-close='true'>
|
||||
<el-form :model='forcePasswordForm' label-width='96px'>
|
||||
<el-form-item label='当前密码'>
|
||||
<el-input v-model='forcePasswordForm.current_password' type='password' show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label='新密码'>
|
||||
<el-input v-model='forcePasswordForm.password' type='password' show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label='确认新密码'>
|
||||
<el-input v-model='forcePasswordForm.password_confirmation' type='password' show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='forcePasswordDialogVisible = false'>稍后修改</el-button>
|
||||
<el-button @click='handleForceUserLogout'>退出当前账号</el-button>
|
||||
<el-button type='primary' :loading='forcingPassword' @click='submitForcePasswordChange'>确认修改并进入系统</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { setToken } from '@/composables/token'
|
||||
import { getToken } from '@/composables/token'
|
||||
import { removeToken, setToken } from '@/composables/token'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const forcingPassword = ref(false)
|
||||
const forcePasswordDialogVisible = ref(false)
|
||||
const forcePasswordForm = reactive({ current_password: '', password: '', password_confirmation: '' })
|
||||
|
||||
const form = reactive({ account: '', password: '' })
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
@ -84,6 +107,13 @@ async function handleLogin(): Promise<void> {
|
||||
setToken(response.data.token)
|
||||
const me = await authApi.me()
|
||||
authStore.setAuth(me.data.user, me.data.permissions)
|
||||
if (Boolean((me.data.user as any)?.force_password_change)) {
|
||||
forcePasswordForm.current_password = form.password
|
||||
forcePasswordForm.password = ''
|
||||
forcePasswordForm.password_confirmation = ''
|
||||
forcePasswordDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace('/')
|
||||
} catch (error: any) {
|
||||
@ -93,6 +123,64 @@ async function handleLogin(): Promise<void> {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForcePasswordChange(): Promise<void> {
|
||||
if (!forcePasswordForm.current_password || !forcePasswordForm.password || !forcePasswordForm.password_confirmation) {
|
||||
ElMessage.warning('请完整填写密码信息')
|
||||
return
|
||||
}
|
||||
|
||||
forcingPassword.value = true
|
||||
try {
|
||||
await authApi.updatePassword(forcePasswordForm)
|
||||
const me = await authApi.me()
|
||||
authStore.setAuth(me.data.user, me.data.permissions)
|
||||
forcePasswordDialogVisible.value = false
|
||||
ElMessage.success('密码修改成功,欢迎使用')
|
||||
await router.replace('/')
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '修改密码失败')
|
||||
} finally {
|
||||
forcingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForceUserLogout(): Promise<void> {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
removeToken()
|
||||
authStore.clearAuth()
|
||||
forcePasswordDialogVisible.value = false
|
||||
form.password = ''
|
||||
ElMessage.success('已退出当前账号')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const me = await authApi.me()
|
||||
authStore.setAuth(me.data.user, me.data.permissions)
|
||||
if (Boolean((me.data.user as any)?.force_password_change)) {
|
||||
forcePasswordForm.current_password = ''
|
||||
forcePasswordForm.password = ''
|
||||
forcePasswordForm.password_confirmation = ''
|
||||
forcePasswordDialogVisible.value = true
|
||||
return
|
||||
} else {
|
||||
await router.replace('/')
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore: invalid token will be handled by route guard
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -53,6 +53,8 @@ async function savePassword(): Promise<void> {
|
||||
savingPassword.value = true
|
||||
try {
|
||||
await authApi.updatePassword(passwordForm)
|
||||
const me = await authApi.me()
|
||||
authStore.setAuth(me.data.user, me.data.permissions)
|
||||
ElMessage.success('密码已更新')
|
||||
passwordForm.current_password = ''
|
||||
passwordForm.password = ''
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑角色" : "新增角色"' width='760px'>
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑角色" : "新增角色"' width='92vw' top='4vh'>
|
||||
<el-form :model='form' label-width='95px'>
|
||||
<el-form-item label='角色名'><el-input v-model='form.name' /></el-form-item>
|
||||
<el-form-item label='权限选择'>
|
||||
@ -138,8 +138,19 @@ function sortByPermissions(a: any, b: any): number {
|
||||
<style scoped>
|
||||
.perm-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.perm-tag { margin: 0; }
|
||||
.permission-panel-wrap { width: 100%; overflow-x: auto; }
|
||||
.permission-panel { min-width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-menu) { min-width: 220px; }
|
||||
:deep(.el-dialog) { max-width: 1000px; }
|
||||
.permission-panel-wrap { width: 100%; overflow: hidden; }
|
||||
.permission-panel { width: 100%; height: 40vh; min-height: 420px; }
|
||||
:deep(.permission-panel .el-cascader-menu) { width: 260px; min-width: 260px; }
|
||||
:deep(.permission-panel .el-cascader-menu__wrap) { height: 100%; overflow-y: auto; overflow-x: hidden; }
|
||||
:deep(.permission-panel .el-cascader-node__label) {
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.35;
|
||||
}
|
||||
:deep(.permission-panel .el-cascader-menu__list) { width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-node) { width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-panel) { height: 100%; }
|
||||
</style>
|
||||
|
||||
|
||||
@ -11,12 +11,8 @@
|
||||
</template>
|
||||
|
||||
<el-collapse v-model='activeServers' class='server-collapse'>
|
||||
<el-collapse-item
|
||||
v-for='(server, index) in serverGroups'
|
||||
:key='server.id'
|
||||
:name='String(server.id)'
|
||||
:class='{ "with-divider": index > 0 }'
|
||||
>
|
||||
<el-collapse-item v-for='(server, index) in serverGroups' :key='server.id' :name='String(server.id)'
|
||||
:class='{ "with-divider": index > 0 }'>
|
||||
<template #title>
|
||||
<div class='server-title'>
|
||||
<span class='server-name'>{{ server.display_name || server.name }}</span>
|
||||
@ -36,21 +32,28 @@
|
||||
<el-table-column prop='display_name' label='显示名称' min-width='150' sortable />
|
||||
<el-table-column prop='name' label='名称' min-width='150' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='id' label='ID' width='70' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='asset_id' label='asset_id' width='110' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='account_id' label='account_id' width='110' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='asset_id' label='asset_id' width='110'
|
||||
sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='account_id' label='account_id'
|
||||
width='110' sortable />
|
||||
<el-table-column label='协议' width='120' sortable :sort-method='sortByProtocol'>
|
||||
<template #default='{ row }'>{{ row.protocols?.[0] || '-' }}</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>
|
||||
<template #default='{ row }'><el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '启用' :
|
||||
'停用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' min-width='420'>
|
||||
<template #default='{ row }'>
|
||||
<div class='row-actions btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEdit(row)' v-if='hasPermission("platform.servers.manage")'>编辑</el-button>
|
||||
<el-button size='small' type='primary' plain @click='openPermissionDialog(row)' v-if='hasPermission("platform.servers.manage")'>选择拥有权限用户</el-button>
|
||||
<el-button size='small' type='warning' @click='openUseResourceDialog(row)' v-if='hasPermission("resource.servers.use") || hasPermission("platform.servers.view")'>使用</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)' v-if='hasPermission("platform.servers.manage")'>删除</el-button>
|
||||
<el-button size='small' @click='openEdit(row)'
|
||||
v-if='hasPermission("platform.servers.manage")'>编辑</el-button>
|
||||
<el-button size='small' type='primary' plain @click='openPermissionDialog(row)'
|
||||
v-if='hasPermission("platform.servers.manage")'>选择拥有权限用户</el-button>
|
||||
<el-button size='small' type='warning' @click='openUseResourceDialog(row)'
|
||||
v-if='hasPermission("resource.servers.use") || hasPermission("platform.servers.view")'>使用</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)'
|
||||
v-if='hasPermission("platform.servers.manage")'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -60,13 +63,15 @@
|
||||
|
||||
<el-empty v-if='!serverGroups.length' description='暂无服务器' />
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑" : (form.type === "server" ? "新增服务器" : "新增资源")' width='640px'>
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑" : (form.type === "server" ? "新增服务器" : "新增资源")'
|
||||
width='640px'>
|
||||
<el-form :model='form' label-width='100px'>
|
||||
<el-form-item label='类型'>
|
||||
<el-segmented v-model='form.type' :options='typeOptions' />
|
||||
</el-form-item>
|
||||
<el-form-item label='所属服务器' v-if='form.type === "resource"'>
|
||||
<el-select v-model='form.parent_id'><el-option v-for='item in serverOnlyList' :key='item.id' :label='item.display_name || item.name' :value='item.id' /></el-select>
|
||||
<el-select v-model='form.parent_id'><el-option v-for='item in serverOnlyList' :key='item.id'
|
||||
:label='item.display_name || item.name' :value='item.id' /></el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label='名称' v-if='form.type === "server" || form.type === "resource"'>
|
||||
@ -76,13 +81,18 @@
|
||||
<el-input v-model='form.display_name' placeholder='可显示中文,如 生产服务器01' />
|
||||
</el-form-item>
|
||||
<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='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='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='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"'>
|
||||
<el-select v-model='form.protocol' placeholder='请选择协议'>
|
||||
<el-option v-for='item in resourceProtocolOptions' :key='item' :label='item' :value='item' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='临时密码' v-if='form.type === "resource"'>
|
||||
<el-switch v-model='form.allow_copy_temp_password' />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label='描述'><el-input v-model='form.description' type='textarea' :rows='2' /></el-form-item>
|
||||
<el-form-item label='启用'><el-switch v-model='form.is_active' /></el-form-item>
|
||||
@ -93,34 +103,16 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='permissionDialogVisible' :title='permissionDialogTitle' width='760px'>
|
||||
<el-form label-width='90px' class='mb-3' v-if='permissionMode === "server_assign"'>
|
||||
<el-form-item label='资源'>
|
||||
<el-select v-model='permissionTargetId' placeholder='请选择资源' class='w-full' @change='loadPermissionUsers(false)'>
|
||||
<el-option v-for='item in permissionResourceOptions' :key='item.id' :label='item.display_name || item.name || `资源#${item.id}`' :value='item.id' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form label-width='120px' class='mb-3' v-if='permissionMode === "resource_edit"'>
|
||||
<el-form-item label='拥有权限用户'>
|
||||
<el-select v-model='selectedPermissionUserIds' multiple filterable clearable collapse-tags collapse-tags-tooltip class='w-full' placeholder='请选择可拥有该资源权限的用户'>
|
||||
<el-option
|
||||
v-for='user in permissionRows'
|
||||
:key='user.id'
|
||||
:label='`${user.nickname || "未命名"} (${user.email || "-"})`'
|
||||
:value='user.id'
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data='permissionMode === "resource_edit" ? permissionRows.filter((user) => selectedPermissionUserIds.includes(user.id)) : permissionRows'>
|
||||
<el-table-column prop='nickname' label='用户' min-width='160' />
|
||||
<el-table-column prop='email' label='邮箱' min-width='220' />
|
||||
<el-table-column v-if='permissionMode === "server_assign"' label='SSH' width='90'><template #default='{ row }'><el-switch v-model='row.can_ssh' /></template></el-table-column>
|
||||
<el-table-column v-if='permissionMode === "server_assign"' label='SFTP' width='90'><template #default='{ row }'><el-switch v-model='row.can_sftp' /></template></el-table-column>
|
||||
<el-table-column v-if='permissionMode === "server_assign"' label='RDP' width='90'><template #default='{ row }'><el-switch v-model='row.can_rdp' /></template></el-table-column>
|
||||
<el-table-column v-if='permissionMode === "resource_edit"' label='状态' width='120'>
|
||||
<template #default><el-tag type='success'>已拥有权限</el-tag></template>
|
||||
<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' />
|
||||
<el-table-column prop='email' label='邮箱' min-width='220' fixed='left' />
|
||||
<el-table-column prop='phone' label='手机号' min-width='150' fixed='left' />
|
||||
<el-table-column v-for='resource in permissionResourceOptions' :key='`resource-col-${resource.id}`'
|
||||
:label='resource.display_name || resource.name || `资源#${resource.id}`' min-width='180'>
|
||||
<template #default='{ row }'>
|
||||
<el-switch v-model='row.resourceToggles[resource.id]' />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
@ -140,10 +132,10 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='访问用户名'>
|
||||
<el-input v-model='useForm.account_name' placeholder='可为空(ACCOUNT_NAME)' clearable />
|
||||
<el-input v-model='useForm.account_name' placeholder='请输入访问用户名' clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label='访问密码'>
|
||||
<el-input v-model='useForm.password' type='password' show-password placeholder='可为空(PASSWORD)' clearable />
|
||||
<el-input v-model='useForm.password' type='password' show-password placeholder='请输入访问密码' clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model='useForm.remember'>记住账号密码(仅本地)</el-checkbox>
|
||||
@ -151,73 +143,69 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='useDialogVisible = false'>取消</el-button>
|
||||
<el-button v-if='useTargetRow?.allow_copy_temp_password' :loading='copyingTempPassword'
|
||||
@click='copyTempPassword'>复制临时密码</el-button>
|
||||
<el-button type='primary' :loading='usingResource' @click='submitUseResource'>连接并访问</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
|
||||
<el-divider content-position='left'>运维协议与软件</el-divider>
|
||||
<el-card shadow='never' v-loading='opsLoading'>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-600'>运维协议软件配置</span>
|
||||
<div class='btn-gap-8'>
|
||||
<el-button @click='fetchOpsMeta'>刷新</el-button>
|
||||
<el-button v-if='hasPermission("platform.servers.manage")' type='primary' @click='openCreateProtocol'>新增协议</el-button>
|
||||
</div>
|
||||
<el-card v-loading='opsLoading' style="margin-top: 1rem;">
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-600'>运维协议软件配置</span>
|
||||
<div class='btn-gap-8'>
|
||||
<el-button @click='fetchOpsMeta'>刷新</el-button>
|
||||
<el-button v-if='hasPermission("platform.servers.manage")' type='primary'
|
||||
@click='openCreateProtocol'>新增协议</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data='opsProtocols' border>
|
||||
<el-table-column prop='name' label='协议' min-width='140' />
|
||||
<el-table-column label='支持软件' min-width='260'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8'>
|
||||
<el-tag v-for='software in row.softwares || []' :key='software.id' :type='software.is_active ? "success" : "info"'>
|
||||
{{ software.name }}
|
||||
</el-tag>
|
||||
<span v-if='!(row.softwares || []).length' class='text-gray-400'>暂无</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='我的软件选择' min-width='280'>
|
||||
<template #default='{ row }'>
|
||||
<el-select
|
||||
v-model='opsSelections[String(row.id)]'
|
||||
clearable
|
||||
filterable
|
||||
placeholder='请选择软件'
|
||||
class='w-full'
|
||||
>
|
||||
<el-option
|
||||
v-for='software in (row.softwares || []).filter((item: any) => item.is_active)'
|
||||
:key='software.id'
|
||||
:label='software.name'
|
||||
:value='software.id'
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' width='320'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' type='success' :loading='!!opsLinkLoading[row.id]' @click='runOpsLink(row)'>发送并执行</el-button>
|
||||
<el-button size='small' v-if='hasPermission("platform.servers.manage")' @click='openEditProtocol(row)'>编辑协议</el-button>
|
||||
<el-button size='small' type='primary' plain v-if='hasPermission("platform.servers.manage")' @click='openSoftwareDialog(row)'>管理软件</el-button>
|
||||
<el-button size='small' type='danger' v-if='hasPermission("platform.servers.manage")' @click='removeProtocol(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class='mt-3 btn-gap-8 btn-gap-8--end'>
|
||||
<el-button type='primary' :loading='opsSaving' @click='saveOpsPreferences'>保存我的软件选择</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-table :data='opsProtocols' border>
|
||||
<el-table-column prop='name' label='协议' min-width='140' />
|
||||
<el-table-column label='支持软件' min-width='260'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8'>
|
||||
<el-tag v-for='software in row.softwares || []' :key='software.id'
|
||||
:type='software.is_active ? "success" : "info"'>
|
||||
{{ software.name }}
|
||||
</el-tag>
|
||||
<span v-if='!(row.softwares || []).length' class='text-gray-400'>暂无</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='我的软件选择' min-width='280'>
|
||||
<template #default='{ row }'>
|
||||
<el-select v-model='opsSelections[String(row.id)]' clearable filterable placeholder='请选择软件' class='w-full'>
|
||||
<el-option v-for='software in (row.softwares || []).filter((item: any) => item.is_active)'
|
||||
:key='software.id' :label='software.name' :value='software.id' />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' width='320'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' v-if='hasPermission("platform.servers.manage")'
|
||||
@click='openEditProtocol(row)'>编辑协议</el-button>
|
||||
<el-button size='small' type='primary' plain v-if='hasPermission("platform.servers.manage")'
|
||||
@click='openSoftwareDialog(row)'>管理软件</el-button>
|
||||
<el-button size='small' type='danger' v-if='hasPermission("platform.servers.manage")'
|
||||
@click='removeProtocol(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class='mt-3 btn-gap-8 btn-gap-8--end'>
|
||||
<el-button type='primary' :loading='opsSaving' @click='saveOpsPreferences'>保存我的软件选择</el-button>
|
||||
</div>
|
||||
|
||||
<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='协议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>
|
||||
@ -237,7 +225,8 @@
|
||||
<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>
|
||||
<template #default='{ row }'><el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '是' : '否'
|
||||
}}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' width='180'>
|
||||
<template #default='{ row }'>
|
||||
@ -252,13 +241,15 @@
|
||||
<el-divider />
|
||||
<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='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 type='primary' :loading='opsSaving' @click='submitSoftware'>{{ editingSoftwareId ? '更新软件' : '新增软件' }}</el-button>
|
||||
<el-button type='primary' :loading='opsSaving' @click='submitSoftware'>{{ editingSoftwareId ? '更新软件' : '新增软件'
|
||||
}}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
@ -281,6 +272,7 @@ const protocolDialogVisible = ref(false)
|
||||
const softwareDialogVisible = ref(false)
|
||||
const savingPermissions = ref(false)
|
||||
const usingResource = ref(false)
|
||||
const copyingTempPassword = ref(false)
|
||||
const opsLoading = ref(false)
|
||||
const opsSaving = ref(false)
|
||||
const permissionMode = ref<'server_assign' | 'resource_edit'>('server_assign')
|
||||
@ -289,18 +281,18 @@ const editingProtocolId = ref<number | null>(null)
|
||||
const editingSoftwareId = ref<number | null>(null)
|
||||
const permissionTargetId = ref<number | null>(null)
|
||||
const rows = ref<any[]>([])
|
||||
const permissionRows = ref<any[]>([])
|
||||
const permissionMatrixRows = ref<any[]>([])
|
||||
const permissionResourceOptions = ref<any[]>([])
|
||||
const selectedPermissionUserIds = ref<number[]>([])
|
||||
const activeServers = ref<string[]>([])
|
||||
const useTargetRow = 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: '', description: '', is_active: true })
|
||||
const useForm = reactive<any>({ protocol: 'SSH', account_name: '', password: '', remember: false })
|
||||
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 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 opsSelections = reactive<Record<string, number | null>>({})
|
||||
@ -353,6 +345,7 @@ function resetForm(): void {
|
||||
asset_id: null,
|
||||
account_id: null,
|
||||
protocol: resourceProtocolOptions.value[0] || '',
|
||||
allow_copy_temp_password: false,
|
||||
description: '',
|
||||
is_active: true,
|
||||
})
|
||||
@ -384,6 +377,7 @@ function openEdit(row: any): void {
|
||||
asset_id: row.asset_id,
|
||||
account_id: row.account_id,
|
||||
protocol: row.protocols?.[0] || 'SSH',
|
||||
allow_copy_temp_password: Boolean(row.allow_copy_temp_password),
|
||||
description: row.description,
|
||||
is_active: row.is_active,
|
||||
})
|
||||
@ -410,6 +404,7 @@ async function submit(): Promise<void> {
|
||||
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,
|
||||
allow_copy_temp_password: form.type === 'resource' ? Boolean(form.allow_copy_temp_password) : false,
|
||||
description: form.description,
|
||||
is_active: form.is_active,
|
||||
}
|
||||
@ -442,7 +437,7 @@ async function openServerPermissionDialog(server: any): Promise<void> {
|
||||
permissionMode.value = 'server_assign'
|
||||
permissionResourceOptions.value = resources
|
||||
permissionTargetId.value = resources[0]?.id || null
|
||||
await loadPermissionUsers(false)
|
||||
await loadPermissionMatrixByResources(resources)
|
||||
permissionDialogVisible.value = true
|
||||
}
|
||||
|
||||
@ -450,44 +445,28 @@ async function openPermissionDialog(resource: any): Promise<void> {
|
||||
permissionMode.value = 'resource_edit'
|
||||
permissionResourceOptions.value = [resource]
|
||||
permissionTargetId.value = resource.id
|
||||
await loadPermissionUsers(false)
|
||||
selectedPermissionUserIds.value = permissionRows.value
|
||||
.filter((user) => hasPermissionByResourceProtocol(user, resource))
|
||||
.map((user) => Number(user.id))
|
||||
await loadPermissionMatrixByResources([resource])
|
||||
permissionDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function loadPermissionUsers(assignedOnly: boolean): Promise<void> {
|
||||
if (!permissionTargetId.value) {
|
||||
permissionRows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response: any = await serversApi.userPermissions(permissionTargetId.value, { assigned_only: assignedOnly ? 1 : 0 })
|
||||
permissionRows.value = response.data.users || []
|
||||
}
|
||||
|
||||
async function submitPermissions(): Promise<void> {
|
||||
if (!permissionTargetId.value) {
|
||||
if (!permissionResourceOptions.value.length) {
|
||||
return
|
||||
}
|
||||
savingPermissions.value = true
|
||||
try {
|
||||
if (permissionMode.value === 'resource_edit') {
|
||||
const resource = permissionResourceOptions.value[0]
|
||||
for (const resource of permissionResourceOptions.value) {
|
||||
const protocolKey = protocolPivotField(resource?.protocols?.[0])
|
||||
const payload = permissionRows.value.map((user) => {
|
||||
const hasPermission = selectedPermissionUserIds.value.includes(Number(user.id))
|
||||
const payload = permissionMatrixRows.value.map((row) => {
|
||||
const hasPermission = Boolean(row.resourceToggles?.[resource.id])
|
||||
return {
|
||||
id: user.id,
|
||||
can_ssh: protocolKey === 'can_ssh' ? hasPermission : Boolean(user.can_ssh),
|
||||
can_sftp: protocolKey === 'can_sftp' ? hasPermission : Boolean(user.can_sftp),
|
||||
can_rdp: protocolKey === 'can_rdp' ? hasPermission : Boolean(user.can_rdp),
|
||||
id: row.id,
|
||||
can_ssh: protocolKey === 'can_ssh' ? hasPermission : Boolean(row.basePermissions?.can_ssh),
|
||||
can_sftp: protocolKey === 'can_sftp' ? hasPermission : Boolean(row.basePermissions?.can_sftp),
|
||||
can_rdp: protocolKey === 'can_rdp' ? hasPermission : Boolean(row.basePermissions?.can_rdp),
|
||||
}
|
||||
})
|
||||
await serversApi.syncUserPermissions(permissionTargetId.value, payload)
|
||||
} else {
|
||||
await serversApi.syncUserPermissions(permissionTargetId.value, permissionRows.value, false)
|
||||
await serversApi.syncUserPermissions(resource.id, payload)
|
||||
}
|
||||
ElMessage.success('资源权限更新成功')
|
||||
permissionDialogVisible.value = false
|
||||
@ -496,6 +475,51 @@ async function submitPermissions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissionMatrixByResources(resources: any[]): Promise<void> {
|
||||
if (!resources.length) {
|
||||
permissionMatrixRows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const responses = await Promise.all(resources.map(async (resource) => {
|
||||
const response: any = await serversApi.userPermissions(resource.id, { assigned_only: 0 })
|
||||
return { resource, users: response.data.users || [] }
|
||||
}))
|
||||
|
||||
const userMap = new Map<number, any>()
|
||||
for (const item of responses) {
|
||||
for (const user of item.users) {
|
||||
if (!userMap.has(user.id)) {
|
||||
userMap.set(user.id, {
|
||||
id: user.id,
|
||||
nickname: user.nickname || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
basePermissions: {
|
||||
can_ssh: Boolean(user.can_ssh),
|
||||
can_sftp: Boolean(user.can_sftp),
|
||||
can_rdp: Boolean(user.can_rdp),
|
||||
},
|
||||
resourceToggles: {},
|
||||
})
|
||||
}
|
||||
const row = userMap.get(user.id)
|
||||
row.resourceToggles[item.resource.id] = hasPermissionByResourceProtocol(user, item.resource)
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of userMap.values()) {
|
||||
for (const resource of resources) {
|
||||
if (typeof row.resourceToggles[resource.id] === 'undefined') {
|
||||
row.resourceToggles[resource.id] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionMatrixRows.value = Array.from(userMap.values())
|
||||
.sort((a, b) => String(a.nickname || '').localeCompare(String(b.nickname || '')))
|
||||
}
|
||||
|
||||
function protocolPivotField(protocol: string | undefined): 'can_ssh' | 'can_sftp' | 'can_rdp' {
|
||||
const normalized = (protocol || 'SSH').toUpperCase()
|
||||
if (normalized === 'SFTP') {
|
||||
@ -524,16 +548,46 @@ function openUseResourceDialog(row: any): void {
|
||||
useForm.account_name = saved?.account_name || ''
|
||||
useForm.password = saved?.password || ''
|
||||
useForm.remember = Boolean(saved)
|
||||
useForm.last_temp_password = ''
|
||||
useDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitUseResource(): Promise<void> {
|
||||
if (!useTargetRow.value?.id) {
|
||||
ElMessage.warning('未选择资源')
|
||||
const result = await requestUseResource('connect')
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
usingResource.value = true
|
||||
useForm.last_temp_password = result.tempPassword
|
||||
ElMessage.success(useForm.last_temp_password ? '已连接,可复制临时密码' : '已连接成功')
|
||||
persistCredentials(useTargetRow.value.id, {
|
||||
protocol: useForm.protocol,
|
||||
account_name: useForm.account_name || '',
|
||||
password: useForm.password || '',
|
||||
remember: Boolean(useForm.remember),
|
||||
})
|
||||
window.location.href = result.url
|
||||
}
|
||||
|
||||
async function requestUseResource(action: 'connect' | 'copy'): Promise<{ url: string; tempPassword: string } | null> {
|
||||
if (!useTargetRow.value?.id) {
|
||||
ElMessage.warning('未选择资源')
|
||||
return null
|
||||
}
|
||||
if (!useForm.account_name?.trim()) {
|
||||
ElMessage.warning('请输入访问用户名')
|
||||
return null
|
||||
}
|
||||
if (!useForm.password?.trim()) {
|
||||
ElMessage.warning('请输入访问密码')
|
||||
return null
|
||||
}
|
||||
|
||||
if (action === 'connect') {
|
||||
usingResource.value = true
|
||||
} else {
|
||||
copyingTempPassword.value = true
|
||||
}
|
||||
try {
|
||||
const response: any = await serversApi.useResource(useTargetRow.value.id, {
|
||||
protocol: useForm.protocol,
|
||||
@ -544,26 +598,41 @@ async function submitUseResource(): Promise<void> {
|
||||
const ssoUrl = response?.data?.url
|
||||
if (!ssoUrl) {
|
||||
ElMessage.error('未获取到 SSO 地址')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
useDialogVisible.value = false
|
||||
ElMessage.success('已获取访问地址,正在拉起客户端...')
|
||||
persistCredentials(useTargetRow.value.id, {
|
||||
protocol: useForm.protocol,
|
||||
account_name: useForm.account_name || '',
|
||||
password: useForm.password || '',
|
||||
remember: Boolean(useForm.remember),
|
||||
})
|
||||
window.location.href = ssoUrl
|
||||
return {
|
||||
url: String(ssoUrl),
|
||||
tempPassword: String(response?.data?.temp_password || ''),
|
||||
}
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '资源访问失败')
|
||||
ElMessage.error(first || error?.message || (action === 'copy' ? '获取临时密码失败' : '资源访问失败'))
|
||||
return null
|
||||
} finally {
|
||||
usingResource.value = false
|
||||
if (action === 'connect') {
|
||||
usingResource.value = false
|
||||
} else {
|
||||
copyingTempPassword.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTempPassword(): Promise<void> {
|
||||
const result = await requestUseResource('copy')
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
const tempPassword = String(result.tempPassword || '')
|
||||
if (!tempPassword) {
|
||||
ElMessage.warning('当前没有可复制的临时密码')
|
||||
return
|
||||
}
|
||||
useForm.last_temp_password = tempPassword
|
||||
await navigator.clipboard.writeText(tempPassword)
|
||||
ElMessage.success('临时密码已复制')
|
||||
}
|
||||
|
||||
function credentialStorageKey(resourceId: number): string {
|
||||
return `bastion.resource.credentials.${resourceId}`
|
||||
}
|
||||
@ -613,7 +682,9 @@ async function fetchOpsMeta(): Promise<void> {
|
||||
const preferences = response.data.preferences || {}
|
||||
for (const protocol of opsProtocols.value) {
|
||||
const key = String(protocol.id)
|
||||
opsSelections[key] = preferences[key] ? Number(preferences[key]) : null
|
||||
const savedValue = preferences[key] ? Number(preferences[key]) : null
|
||||
opsSelections[key] = savedValue
|
||||
opsSavedSelections[key] = savedValue
|
||||
}
|
||||
if (!form.protocol) {
|
||||
form.protocol = resourceProtocolOptions.value[0] || ''
|
||||
@ -746,43 +817,55 @@ async function removeSoftware(row: any): Promise<void> {
|
||||
async function saveOpsPreferences(): Promise<void> {
|
||||
opsSaving.value = true
|
||||
try {
|
||||
const changedProtocolIds = opsProtocols.value
|
||||
.map((protocol) => String(protocol.id))
|
||||
.filter((key) => (opsSavedSelections[key] ?? null) !== (opsSelections[key] ?? null))
|
||||
|
||||
if (!changedProtocolIds.length) {
|
||||
ElMessage.info('没有修改,无需同步客户端')
|
||||
return
|
||||
}
|
||||
|
||||
const items = opsProtocols.value.map((protocol) => ({
|
||||
protocol_id: protocol.id,
|
||||
software_id: opsSelections[String(protocol.id)] || null,
|
||||
}))
|
||||
await serversApi.saveOpsPreferences(items)
|
||||
ElMessage.success('运维软件选择已保存')
|
||||
await syncOpsLinksAfterSave(changedProtocolIds)
|
||||
for (const key of changedProtocolIds) {
|
||||
opsSavedSelections[key] = opsSelections[key] ?? null
|
||||
}
|
||||
ElMessage.success('已保存并同步到客户端')
|
||||
} finally {
|
||||
opsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runOpsLink(row: any): Promise<void> {
|
||||
const protocolId = Number(row.id)
|
||||
const softwareId = opsSelections[String(protocolId)] || null
|
||||
if (!softwareId) {
|
||||
ElMessage.warning('请先为该协议选择软件')
|
||||
return
|
||||
}
|
||||
async function syncOpsLinksAfterSave(changedProtocolIds: string[]): Promise<void> {
|
||||
const selectedItems = opsProtocols.value
|
||||
.map((protocol) => ({
|
||||
protocol_id: Number(protocol.id),
|
||||
key: String(protocol.id),
|
||||
software_id: opsSelections[String(protocol.id)] || null,
|
||||
}))
|
||||
.filter((item) => changedProtocolIds.includes(item.key) && item.software_id)
|
||||
|
||||
opsLinkLoading.value[protocolId] = true
|
||||
try {
|
||||
const response: any = await serversApi.generateOpsLink({
|
||||
protocol_id: protocolId,
|
||||
software_id: softwareId,
|
||||
})
|
||||
const link = response?.data?.link
|
||||
if (!link) {
|
||||
ElMessage.error('未获取到运维连接')
|
||||
return
|
||||
for (const item of selectedItems) {
|
||||
const protocolId = item.protocol_id
|
||||
opsLinkLoading.value[protocolId] = true
|
||||
try {
|
||||
const response: any = await serversApi.generateOpsLink({
|
||||
protocol_id: protocolId,
|
||||
software_id: item.software_id,
|
||||
})
|
||||
const link = response?.data?.link
|
||||
if (link) {
|
||||
window.location.href = link
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
} finally {
|
||||
opsLinkLoading.value[protocolId] = false
|
||||
}
|
||||
ElMessage.success('已发送连接,正在执行...')
|
||||
window.location.href = link
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '生成连接失败')
|
||||
} finally {
|
||||
opsLinkLoading.value[protocolId] = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -799,13 +882,44 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-title { display: flex; align-items: center; gap: 14px; font-weight: 600; }
|
||||
.server-name { font-size: 16px; color: #0f172a; }
|
||||
.server-code { color: #475569; font-size: 13px; font-weight: 600; }
|
||||
.server-ip { color: #64748b; font-size: 14px; }
|
||||
:deep(.server-collapse) { border-top: 0; border-bottom: 0; }
|
||||
:deep(.server-collapse .el-collapse-item__header) { border-bottom: 0; height: 52px; }
|
||||
:deep(.server-collapse .el-collapse-item__wrap) { border-bottom: 0; }
|
||||
:deep(.server-collapse .with-divider .el-collapse-item__header) { border-top: 1px solid #e2e8f0; }
|
||||
</style>
|
||||
.server-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.server-code {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-ip {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.server-collapse) {
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.server-collapse .el-collapse-item__header) {
|
||||
border-bottom: 0;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
:deep(.server-collapse .el-collapse-item__wrap) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.server-collapse .with-divider .el-collapse-item__header) {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-700'>用户管理</span>
|
||||
<el-button v-if='hasPermission("platform.users.manage")' type='primary' @click='openCreate'>新增用户</el-button>
|
||||
<div class='btn-gap-8' v-if='hasPermission("platform.users.manage")'>
|
||||
<el-button type='success' :loading='importing' @click='openImportDialog'>批量导入</el-button>
|
||||
<el-button type='primary' @click='openCreate'>新增用户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -37,6 +40,7 @@
|
||||
<el-form-item label='邮箱'><el-input v-model='form.email' /></el-form-item>
|
||||
<el-form-item label='手机号'><el-input v-model='form.phone' /></el-form-item>
|
||||
<el-form-item label='密码'><el-input v-model='form.password' type='password' show-password /></el-form-item>
|
||||
<el-form-item label='需更改密码'><el-switch v-model='form.force_password_change' /></el-form-item>
|
||||
<el-form-item label='角色'>
|
||||
<el-select v-model='form.role_ids' multiple collapse-tags>
|
||||
<el-option v-for='item in roleOptions' :key='item.id' :label='item.name' :value='item.id' />
|
||||
@ -49,7 +53,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='permissionDialogVisible' title='用户直授权限分配' width='760px'>
|
||||
<el-dialog v-model='permissionDialogVisible' title='用户直授权限分配' width='92vw' top='4vh'>
|
||||
<div class='permission-panel-wrap'>
|
||||
<el-cascader-panel v-model='selectedPermissionNodes' :options='permissionCascader' :props='cascaderProps' class='permission-panel' />
|
||||
</div>
|
||||
@ -58,10 +62,50 @@
|
||||
<el-button type='primary' :loading='savingPermissions' @click='submitPermissions'>保存权限</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='importDialogVisible' title='批量导入用户' width='620px'>
|
||||
<el-alert type='info' show-icon :closable='false'>
|
||||
<template #default>
|
||||
仅支持 `xlsx`。表头示例:`nickname,email,phone,password,role_ids,force_password_change`,其中 `role_ids` 可选,多个用英文逗号分隔。
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-upload
|
||||
class='mt-4'
|
||||
drag
|
||||
:auto-upload='false'
|
||||
:show-file-list='true'
|
||||
:limit='1'
|
||||
:on-change='handleImportFileChange'
|
||||
:on-remove='handleImportFileRemove'
|
||||
:accept="'.xlsx'"
|
||||
>
|
||||
<el-icon class='el-icon--upload'><upload-filled /></el-icon>
|
||||
<div class='el-upload__text'>拖拽文件到这里,或<em>点击选择</em></div>
|
||||
</el-upload>
|
||||
|
||||
<div v-if='importResult' class='mt-4'>
|
||||
<el-alert
|
||||
:type='importResult.error_count > 0 ? "warning" : "success"'
|
||||
:closable='false'
|
||||
:title='`导入完成:成功 ${importResult.created_count} 条,跳过 ${importResult.skipped_count} 条`'
|
||||
/>
|
||||
<el-table v-if='importResult.errors?.length' :data='importResult.errors' class='mt-3' max-height='220'>
|
||||
<el-table-column prop='line' label='行号' width='90' />
|
||||
<el-table-column prop='message' label='错误信息' min-width='360' />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click='downloadImportTemplate'>下载导入模板</el-button>
|
||||
<el-button @click='importDialogVisible = false'>关闭</el-button>
|
||||
<el-button type='primary' :loading='importing' @click='submitImport'>开始导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<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 { permissionsApi } from '@/api/permissions'
|
||||
@ -78,19 +122,23 @@ const saving = ref(false)
|
||||
const savingPermissions = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const permissionDialogVisible = ref(false)
|
||||
const importDialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const permissionTargetUserId = ref<number | null>(null)
|
||||
const importing = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
const roleOptions = ref<any[]>([])
|
||||
const permissionOptions = ref<any[]>([])
|
||||
const selectedPermissionNodes = ref<Array<number | string>>([])
|
||||
const importFile = ref<File | null>(null)
|
||||
const importResult = ref<{ created_count: number; skipped_count: number; error_count: number; errors: Array<{ line: number; message: string }> } | null>(null)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const sortBy = ref('created_at')
|
||||
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const form = reactive<any>({ nickname: '', email: '', phone: '', password: '', role_ids: [] })
|
||||
const form = reactive<any>({ nickname: '', email: '', phone: '', password: '', force_password_change: false, role_ids: [] })
|
||||
const cascaderProps = { multiple: true, emitPath: false, checkStrictly: false }
|
||||
const permissionCascader = computed(() => buildPermissionCascader(permissionOptions.value))
|
||||
|
||||
@ -142,7 +190,7 @@ function handleSortChange(payload: { prop: string, order: 'ascending' | 'descend
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, { nickname: '', email: '', phone: '', password: '', role_ids: [] })
|
||||
Object.assign(form, { nickname: '', email: '', phone: '', password: '', force_password_change: false, role_ids: [] })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@ -153,6 +201,7 @@ function openEdit(row: any): void {
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
password: '',
|
||||
force_password_change: Boolean(row.force_password_change),
|
||||
role_ids: (row.roles || []).map((item: any) => item.id),
|
||||
})
|
||||
dialogVisible.value = true
|
||||
@ -214,14 +263,80 @@ async function removeRow(row: any): Promise<void> {
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
function openImportDialog(): void {
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleImportFileChange(file: any): void {
|
||||
importFile.value = file?.raw || null
|
||||
}
|
||||
|
||||
function handleImportFileRemove(): void {
|
||||
importFile.value = null
|
||||
}
|
||||
|
||||
async function submitImport(): Promise<void> {
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请先选择导入文件')
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const response: any = await usersApi.importUsers(importFile.value)
|
||||
importResult.value = {
|
||||
created_count: Number(response?.data?.created_count || 0),
|
||||
skipped_count: Number(response?.data?.skipped_count || 0),
|
||||
error_count: Array.isArray(response?.data?.errors) ? response.data.errors.length : 0,
|
||||
errors: response?.data?.errors || [],
|
||||
}
|
||||
ElMessage.success('批量导入已执行')
|
||||
await fetchList()
|
||||
await fetchOptions()
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImportTemplate(): Promise<void> {
|
||||
try {
|
||||
const blob: Blob = await usersApi.downloadImportTemplate() as any
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'users_import_template.xlsx'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (_error) {
|
||||
ElMessage.error('下载模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchOptions(), fetchList()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.permission-panel-wrap { width: 100%; overflow-x: auto; }
|
||||
.permission-panel { min-width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-menu) { min-width: 220px; }
|
||||
:deep(.el-dialog) { max-width: 1000px; }
|
||||
.permission-panel-wrap { width: 100%; overflow: hidden; }
|
||||
.permission-panel { width: 100%; height: 40vh; min-height: 420px; }
|
||||
:deep(.permission-panel .el-cascader-menu) { width: 260px; min-width: 260px; }
|
||||
:deep(.permission-panel .el-cascader-menu__wrap) { height: 100%; overflow-y: auto; overflow-x: hidden; }
|
||||
:deep(.permission-panel .el-cascader-node__label) {
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.35;
|
||||
}
|
||||
:deep(.permission-panel .el-cascader-menu__list) { width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-node) { width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-panel) { height: 100%; }
|
||||
</style>
|
||||
|
||||
|
||||
@ -47,7 +47,12 @@ router.beforeEach(async (to) => {
|
||||
const response = await authApi.me()
|
||||
authStore.setAuth(response.data.user, response.data.permissions)
|
||||
loaded = true
|
||||
} catch (_error) {
|
||||
} catch (error: any) {
|
||||
const code = Number(error?.code || 0)
|
||||
if (code === 423) {
|
||||
loaded = true
|
||||
return '/login'
|
||||
}
|
||||
removeToken()
|
||||
authStore.clearAuth()
|
||||
loaded = false
|
||||
@ -57,9 +62,21 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
if (token && to.meta.guest) {
|
||||
const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change)
|
||||
if (forcePasswordChange) {
|
||||
if (to.path === '/login') {
|
||||
return true
|
||||
}
|
||||
return '/login'
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change)
|
||||
if (token && forcePasswordChange && to.path !== '/login') {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // 后端地址
|
||||
target: 'http://localhost:8001', // 后端地址
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, '') // 去掉 /api 前缀
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user