bastion_sso/src/pages/UsersPage.vue

434 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-card>
<template #header>
<div class='flex justify-between items-center'>
<span class='font-700'>用户管理</span>
<div class='btn-gap-8' v-if='hasPermission("platform.users.manage")'>
<el-button type='warning' :disabled='selectedUserIds.length === 0' @click='openBatchAssignDialog'>批量设置用户组和权限</el-button>
<el-button type='success' :loading='importing' @click='openImportDialog'>批量导入</el-button>
<el-button type='primary' @click='openCreate'>新增用户</el-button>
</div>
</div>
</template>
<el-table :data='rows' v-loading='loading' @sort-change='handleSortChange' @selection-change='handleSelectionChange'>
<el-table-column v-if='hasPermission("platform.users.manage")' type='selection' width='48' />
<el-table-column prop='id' label='ID' width='70' sortable='custom' />
<el-table-column prop='nickname' label='昵称' min-width='130' sortable='custom' />
<el-table-column prop='email' label='邮箱' min-width='180' sortable='custom' />
<el-table-column prop='phone' label='手机号' min-width='140' sortable='custom' />
<el-table-column label='角色' min-width='160'>
<template #default='{ row }'>{{ (row.roles || []).map((r:any) => r.name).join(', ') || '-' }}</template>
</el-table-column>
<el-table-column label='直授权限' min-width='220'>
<template #default='{ row }'>{{ (row.permissions || []).map((p:any) => p.name).join(', ') || '-' }}</template>
</el-table-column>
<el-table-column v-if='hasPermission("platform.users.manage")' label='操作' width='220'>
<template #default='{ row }'>
<div class='btn-gap-8 btn-gap-8--nowrap'>
<el-button size='small' @click='openEdit(row)'>编辑</el-button>
<el-button size='small' type='warning' @click='openPermissionDialog(row)'>分配权限</el-button>
<el-button size='small' type='danger' @click='removeRow(row)'>删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
class='mt-4'
layout='total, sizes, prev, pager, next'
:total='total'
:current-page='page'
:page-size='perPage'
:page-sizes='[10, 20, 50, 100]'
@current-change='handlePageChange'
@size-change='handleSizeChange'
/>
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑用户" : "新增用户"' width='560px'>
<el-form :model='form' label-width='95px'>
<el-form-item label='昵称'><el-input v-model='form.nickname' /></el-form-item>
<el-form-item label='邮箱'><el-input v-model='form.email' /></el-form-item>
<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' />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click='dialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
</template>
</el-dialog>
<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>
<template #footer>
<el-button @click='permissionDialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='savingPermissions' @click='submitPermissions'>保存权限</el-button>
</template>
</el-dialog>
<el-dialog v-model='batchAssignDialogVisible' title='批量设置用户组和用户权限' width='760px'>
<el-alert type='info' :closable='false' show-icon>
<template #default>已选择 {{ selectedUserIds.length }} 个用户提交后将覆盖这些用户的角色与直授权限</template>
</el-alert>
<el-form :model='batchAssignForm' label-width='90px' class='mt-4'>
<el-form-item label='用户组'>
<el-select v-model='batchAssignForm.role_ids' multiple collapse-tags clearable class='w-full' placeholder='请选择用户组'>
<el-option v-for='item in roleOptions' :key='item.id' :label='item.name' :value='item.id' />
</el-select>
</el-form-item>
<el-form-item label='用户权限'>
<div class='permission-panel-wrap'>
<el-cascader-panel v-model='batchAssignPermissionNodes' :options='permissionCascader' :props='cascaderProps' class='permission-panel' />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click='batchAssignDialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='batchAssigning' @click='submitBatchAssign'>提交</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'
import { rolesApi } from '@/api/roles'
import { usersApi } from '@/api/users'
import { buildPermissionCascader, extractPermissionIds } from '@/composables/permission-tree'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { hasPermission } = authStore
const loading = ref(false)
const saving = ref(false)
const savingPermissions = ref(false)
const dialogVisible = ref(false)
const permissionDialogVisible = ref(false)
const importDialogVisible = ref(false)
const batchAssignDialogVisible = ref(false)
const editingId = ref<number | null>(null)
const permissionTargetUserId = ref<number | null>(null)
const importing = ref(false)
const batchAssigning = ref(false)
const selectedUserIds = ref<number[]>([])
const rows = ref<any[]>([])
const roleOptions = ref<any[]>([])
const permissionOptions = ref<any[]>([])
const selectedPermissionNodes = ref<Array<number | string>>([])
const batchAssignPermissionNodes = 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: '', force_password_change: false, role_ids: [] })
const batchAssignForm = reactive<{ role_ids: number[] }>({ role_ids: [] })
const cascaderProps = { multiple: true, emitPath: false, checkStrictly: false }
const permissionCascader = computed(() => buildPermissionCascader(permissionOptions.value))
async function fetchList(): Promise<void> {
loading.value = true
try {
const response: any = await usersApi.list({
page: page.value,
per_page: perPage.value,
sort_by: sortBy.value,
sort_order: sortOrder.value,
})
const list = response.data.data || []
rows.value = await Promise.all(list.map(async (item: any) => (await usersApi.detail(item.id) as any).data))
selectedUserIds.value = []
total.value = response.data.total || 0
} finally {
loading.value = false
}
}
async function fetchOptions(): Promise<void> {
try {
const [rolesResponse, permissionsResponse]: any = await Promise.all([
rolesApi.list({ page: 1, per_page: 100 }),
permissionsApi.list({ page: 1, per_page: 200 }),
])
roleOptions.value = rolesResponse.data.data || []
permissionOptions.value = permissionsResponse.data.data || []
} catch (error: any) {
roleOptions.value = []
permissionOptions.value = []
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '加载用户组选项失败')
}
}
function handlePageChange(nextPage: number): void {
page.value = nextPage
fetchList()
}
function handleSizeChange(nextSize: number): void {
perPage.value = nextSize
page.value = 1
fetchList()
}
function handleSortChange(payload: { prop: string, order: 'ascending' | 'descending' | null }): void {
if (!payload.prop || !payload.order) {
sortBy.value = 'created_at'
sortOrder.value = 'desc'
page.value = 1
fetchList()
return
}
sortBy.value = payload.prop
sortOrder.value = payload.order === 'ascending' ? 'asc' : 'desc'
page.value = 1
fetchList()
}
function openCreate(): void {
editingId.value = null
Object.assign(form, { nickname: '', email: '', phone: '', password: '', force_password_change: false, role_ids: [] })
dialogVisible.value = true
}
function openEdit(row: any): void {
editingId.value = row.id
Object.assign(form, {
nickname: row.nickname,
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
}
function openPermissionDialog(row: any): void {
permissionTargetUserId.value = row.id
selectedPermissionNodes.value = (row.permissions || []).map((item: any) => item.id)
permissionDialogVisible.value = true
}
async function submit(): Promise<void> {
saving.value = true
try {
const payload: any = { ...form }
if (!payload.password) {
delete payload.password
}
if (editingId.value) {
await usersApi.update(editingId.value, payload)
ElMessage.success('更新成功')
} else {
await usersApi.create(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await fetchList()
} catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '提交失败')
} finally {
saving.value = false
}
}
async function submitPermissions(): Promise<void> {
if (!permissionTargetUserId.value) {
return
}
savingPermissions.value = true
try {
const permissionIds = extractPermissionIds(selectedPermissionNodes.value)
await usersApi.syncPermissions(permissionTargetUserId.value, permissionIds)
ElMessage.success('权限更新成功')
permissionDialogVisible.value = false
await fetchList()
} finally {
savingPermissions.value = false
}
}
async function removeRow(row: any): Promise<void> {
await ElMessageBox.confirm(`确认删除用户 ${row.nickname} 吗?`, '提示', { type: 'warning' })
await usersApi.remove(row.id)
ElMessage.success('删除成功')
await fetchList()
}
function openImportDialog(): void {
importFile.value = null
importResult.value = null
importDialogVisible.value = true
}
function openBatchAssignDialog(): void {
if (selectedUserIds.value.length === 0) {
ElMessage.warning('请先勾选用户')
return
}
batchAssignForm.role_ids = []
batchAssignPermissionNodes.value = []
batchAssignDialogVisible.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('下载模板失败')
}
}
function handleSelectionChange(selection: any[]): void {
selectedUserIds.value = selection
.map((item: any) => Number(item.id || 0))
.filter((id: number) => id > 0)
}
async function submitBatchAssign(): Promise<void> {
if (selectedUserIds.value.length === 0) {
ElMessage.warning('请先勾选用户')
return
}
batchAssigning.value = true
try {
await usersApi.syncBatchAssignments({
user_ids: selectedUserIds.value,
role_ids: batchAssignForm.role_ids,
permission_ids: extractPermissionIds(batchAssignPermissionNodes.value),
})
ElMessage.success('批量设置成功')
batchAssignDialogVisible.value = false
await fetchList()
} catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
ElMessage.error(first || error?.message || '批量设置失败')
} finally {
batchAssigning.value = false
}
}
onMounted(async () => {
await Promise.all([fetchOptions(), fetchList()])
})
</script>
<style scoped>
: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>