feat: 进一步完成界面优化

This commit is contained in:
Boen_Shi 2026-04-30 16:13:14 +08:00
parent d4bf91868d
commit cf3301c984
13 changed files with 687 additions and 212 deletions

1
.gitignore vendored
View File

@ -29,3 +29,4 @@ BACKAPI.md
LOG.md
REQUIRE.md
SKILL.md
dist.zip

2
components.d.ts vendored
View File

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

View File

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

View File

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

View File

@ -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.name}${item.description}` : item.name,
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: [...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(),
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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,18 +143,21 @@
</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'>
<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>
<el-button v-if='hasPermission("platform.servers.manage")' type='primary'
@click='openCreateProtocol'>新增协议</el-button>
</div>
</div>
</template>
@ -172,7 +167,8 @@
<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"'>
<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>
@ -181,29 +177,21 @@
</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 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>
<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>
@ -212,12 +200,12 @@
<div class='mt-3 btn-gap-8 btn-gap-8--end'>
<el-button type='primary' :loading='opsSaving' @click='saveOpsPreferences'>保存我的软件选择</el-button>
</div>
</el-card>
<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
}
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,24 +598,39 @@ 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 {
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 {
@ -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,44 +817,56 @@ 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)
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: softwareId,
software_id: item.software_id,
})
const link = response?.data?.link
if (!link) {
ElMessage.error('未获取到运维连接')
return
}
ElMessage.success('已发送连接,正在执行...')
if (link) {
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 || '生成连接失败')
await new Promise((resolve) => setTimeout(resolve, 250))
}
} finally {
opsLinkLoading.value[protocolId] = false
}
}
}
async function removeRow(row: any): Promise<void> {
@ -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>

View File

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

View File

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

View File

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