434 lines
16 KiB
Vue
434 lines
16 KiB
Vue
<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>
|