1016 lines
28 KiB
Vue
1016 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||
import { getPermissions } from '@/api/management/permissions'
|
||
import {
|
||
getRolePermissions,
|
||
getRoleSummaries,
|
||
getRoles,
|
||
updateRolePermissions,
|
||
} from '@/api/management/roles'
|
||
import {
|
||
commitUserImport,
|
||
createUser,
|
||
deleteUser,
|
||
getUserDetail,
|
||
getUserImportJob,
|
||
getUsers,
|
||
isUserMockEnabled,
|
||
previewUserImport,
|
||
resetUserPassword,
|
||
updateUserManagement,
|
||
} from '@/api/management/users'
|
||
import type {
|
||
BackendDataScope,
|
||
BackendUserStatus,
|
||
ApiPermissionRecord,
|
||
CampusRecord,
|
||
DepartmentRecord,
|
||
RoleRecord,
|
||
UserImportJobResponseDto,
|
||
UserImportPreviewResponseDto,
|
||
UserRecord as ApiUserRecord,
|
||
} from '@/api/management/types'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { useLoginStore } from '@/stores/login'
|
||
|
||
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
||
import UserTable from './components/UserTable.vue'
|
||
import UserEditorDialog from './components/UserEditorDialog.vue'
|
||
import UserImportDialog from './components/UserImportDialog.vue'
|
||
import UserPasswordResetDialog from './components/UserPasswordResetDialog.vue'
|
||
import RoleApiPermissionPanel from './components/RoleApiPermissionPanel.vue'
|
||
import { roleColors, roleSummaries, users } from './mock'
|
||
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
|
||
|
||
const appStore = useAppStore()
|
||
const loginStore = useLoginStore()
|
||
const loading = ref(true)
|
||
const keyword = ref('')
|
||
const roleRows = ref<RoleSummary[]>([])
|
||
const roleOptions = ref<RoleRecord[]>([])
|
||
const userRows = ref<UserTableRow[]>([])
|
||
const roleColorMap = ref<Record<string, string>>(roleColors)
|
||
const userPage = ref(1)
|
||
const userPageSize = ref(20)
|
||
const userTotal = ref(0)
|
||
const campuses = ref<CampusRecord[]>([])
|
||
const departments = ref<DepartmentRecord[]>([])
|
||
const editorVisible = ref(false)
|
||
const editorInitial = ref<UserEditorForm | null>(null)
|
||
const editorSaving = ref(false)
|
||
const disablingUserId = ref<string | null>(null)
|
||
const passwordResetVisible = ref(false)
|
||
const passwordResetTarget = ref<UserTableRow | null>(null)
|
||
const passwordResetSaving = ref(false)
|
||
const importVisible = ref(false)
|
||
const importLoading = ref(false)
|
||
const importPreview = ref<UserImportPreviewResponseDto | null>(null)
|
||
const importJob = ref<UserImportJobResponseDto | null>(null)
|
||
const apiPermissions = ref<ApiPermissionRecord[]>([])
|
||
const selectedRoleId = ref('')
|
||
const selectedRolePermissionIds = ref<string[]>([])
|
||
const rolePermissionVersion = ref<number | undefined>()
|
||
const apiPermissionLoading = ref(false)
|
||
const apiPermissionSaving = ref(false)
|
||
const apiPermissionError = ref('')
|
||
const rolePermissionDrawerVisible = ref(false)
|
||
let userRequestId = 0
|
||
|
||
const canEditUsers = computed(
|
||
() => isUserMockEnabled || loginStore.hasPermission('system:user:update'),
|
||
)
|
||
const canCreateUsers = computed(
|
||
() => isUserMockEnabled || loginStore.hasPermission('system:user:create'),
|
||
)
|
||
const canDeactivateUsers = computed(
|
||
() => isUserMockEnabled || loginStore.hasPermission('system:user:disable'),
|
||
)
|
||
const canResetPasswords = computed(
|
||
() => isUserMockEnabled || loginStore.hasPermission('system:user:password:reset'),
|
||
)
|
||
const canImportUsers = computed(
|
||
() => isUserMockEnabled || loginStore.hasPermission('system:user:import'),
|
||
)
|
||
const canViewRoleApiPermissions = computed(
|
||
() =>
|
||
!isUserMockEnabled &&
|
||
loginStore.hasPermission(['system:permission:query', 'system:role:permission:query']),
|
||
)
|
||
const canEditRoleApiPermissions = computed(
|
||
() => !isUserMockEnabled && loginStore.hasPermission('system:role:permission:update'),
|
||
)
|
||
|
||
const mockCampuses: CampusRecord[] = [
|
||
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
||
{ id: 'mock-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
|
||
]
|
||
|
||
const mockDepartments: DepartmentRecord[] = [
|
||
'医务处',
|
||
'放射科',
|
||
'心内科',
|
||
'骨科',
|
||
'健康管理中心',
|
||
'信息科',
|
||
'纪检监察室',
|
||
].map((name, index) => ({
|
||
id: `mock-department-${index + 1}`,
|
||
campusId: index === 3 || index === 4 ? 'mock-east' : 'mock-main',
|
||
code: `MOCK-${index + 1}`,
|
||
name,
|
||
status: 'ENABLED',
|
||
sortNo: index + 1,
|
||
}))
|
||
|
||
const visibleUsers = computed(() => {
|
||
if (!isUserMockEnabled) {
|
||
return userRows.value
|
||
}
|
||
|
||
return userRows.value.filter((user) => {
|
||
const matchesCampus = user.campus === appStore.selectedCampus
|
||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||
const matchesKeyword =
|
||
!normalizedKeyword ||
|
||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
|
||
|
||
return matchesCampus && matchesKeyword
|
||
})
|
||
})
|
||
|
||
async function loadUsers() {
|
||
const requestId = ++userRequestId
|
||
loading.value = true
|
||
try {
|
||
if (isUserMockEnabled) {
|
||
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
|
||
departments.value = mockDepartments.map((department) => ({ ...department }))
|
||
roleRows.value = roleSummaries.map((role) => ({ ...role }))
|
||
roleOptions.value = roleSummaries.map((role) => ({
|
||
id: role.id,
|
||
code: role.id,
|
||
name: role.name,
|
||
description: role.description,
|
||
color: role.color,
|
||
userCount: role.userCount ?? undefined,
|
||
status: 'ENABLED' as const,
|
||
}))
|
||
userRows.value = users.map((user) => ({ ...user }))
|
||
roleColorMap.value = roleColors
|
||
userTotal.value = userRows.value.length
|
||
return
|
||
}
|
||
|
||
const [rolesResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||
getRoles({ page: 1, pageSize: 200 }),
|
||
getCampuses(),
|
||
getDepartments(),
|
||
])
|
||
|
||
const campusRecords = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||
const selectedCampusId = campusRecords.find(
|
||
(campus) => campus.name === appStore.selectedCampus,
|
||
)?.id
|
||
const [usersResult, roleSummaryResult] = await Promise.allSettled([
|
||
getUsers({
|
||
page: userPage.value,
|
||
pageSize: userPageSize.value,
|
||
keyword: keyword.value.trim() || undefined,
|
||
status: 'all',
|
||
campusId: selectedCampusId,
|
||
}),
|
||
getRoleSummaries(selectedCampusId),
|
||
])
|
||
|
||
if (usersResult.status === 'rejected') {
|
||
throw usersResult.reason
|
||
}
|
||
|
||
if (requestId !== userRequestId) {
|
||
return
|
||
}
|
||
|
||
const campusNames = new Map(
|
||
campusesResult.status === 'fulfilled'
|
||
? campusesResult.value.map((campus) => [campus.id, campus.name])
|
||
: [],
|
||
)
|
||
const departmentNames = new Map(
|
||
departmentsResult.status === 'fulfilled'
|
||
? departmentsResult.value.map((department) => [department.id, department.name])
|
||
: [],
|
||
)
|
||
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
|
||
const apiUsers = usersResult.value.records
|
||
|
||
userRows.value = apiUsers.map((user) => mapUserRow(user, campusNames, departmentNames))
|
||
userTotal.value = usersResult.value.total
|
||
|
||
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
|
||
const summaryRoles = roleSummaryResult.status === 'fulfilled' ? roleSummaryResult.value : roles
|
||
roleOptions.value = roles.length ? roles : summaryRoles
|
||
roleRows.value = summaryRoles.map((role) => mapRoleSummary(role))
|
||
roleColorMap.value = Object.fromEntries(
|
||
roleOptions.value.map((role) => [role.name, role.color ?? '#8a9aa3']),
|
||
)
|
||
|
||
if (rolesResult.status === 'rejected' || roleSummaryResult.status === 'rejected') {
|
||
ElMessage.warning('角色列表或人数统计加载不完整,部分角色信息可能暂不可用')
|
||
}
|
||
|
||
if (!apiPermissions.value.length) {
|
||
void loadApiPermissionCatalog()
|
||
}
|
||
} catch {
|
||
if (requestId !== userRequestId) {
|
||
return
|
||
}
|
||
|
||
roleRows.value = []
|
||
roleOptions.value = []
|
||
userRows.value = []
|
||
userTotal.value = 0
|
||
ElMessage.error('用户与角色加载失败,请稍后重试')
|
||
} finally {
|
||
if (requestId === userRequestId) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadSelectedRolePermissions() {
|
||
if (!selectedRoleId.value) {
|
||
selectedRolePermissionIds.value = []
|
||
rolePermissionVersion.value = undefined
|
||
return
|
||
}
|
||
|
||
const response = await getRolePermissions(selectedRoleId.value)
|
||
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
|
||
rolePermissionVersion.value = response.version
|
||
}
|
||
|
||
async function loadApiPermissionCatalog() {
|
||
if (isUserMockEnabled || !canViewRoleApiPermissions.value || apiPermissionLoading.value) {
|
||
return
|
||
}
|
||
|
||
apiPermissionLoading.value = true
|
||
apiPermissionError.value = ''
|
||
|
||
try {
|
||
const result = await getPermissions({ page: 1, pageSize: 200 })
|
||
apiPermissions.value = result.records
|
||
|
||
if (!roleOptions.value.some((role) => role.id === selectedRoleId.value)) {
|
||
selectedRoleId.value = roleOptions.value[0]?.id ?? ''
|
||
}
|
||
|
||
await loadSelectedRolePermissions()
|
||
} catch {
|
||
apiPermissionError.value = 'API 权限目录或角色权限加载失败,请检查当前账号权限'
|
||
selectedRolePermissionIds.value = []
|
||
rolePermissionVersion.value = undefined
|
||
} finally {
|
||
apiPermissionLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function selectRoleForApiPermissions(roleId: string) {
|
||
if (!canViewRoleApiPermissions.value) {
|
||
return
|
||
}
|
||
|
||
selectedRoleId.value = roleId
|
||
apiPermissionLoading.value = true
|
||
apiPermissionError.value = ''
|
||
|
||
try {
|
||
await loadSelectedRolePermissions()
|
||
} catch {
|
||
selectedRolePermissionIds.value = []
|
||
rolePermissionVersion.value = undefined
|
||
apiPermissionError.value = '该角色的 API 权限加载失败,请稍后重试'
|
||
} finally {
|
||
apiPermissionLoading.value = false
|
||
}
|
||
}
|
||
|
||
function toggleApiPermission(permissionId: string) {
|
||
if (selectedRolePermissionIds.value.includes(permissionId)) {
|
||
selectedRolePermissionIds.value = selectedRolePermissionIds.value.filter(
|
||
(id) => id !== permissionId,
|
||
)
|
||
return
|
||
}
|
||
|
||
selectedRolePermissionIds.value = [...selectedRolePermissionIds.value, permissionId]
|
||
}
|
||
|
||
async function saveRoleApiPermissions() {
|
||
if (!canEditRoleApiPermissions.value || !selectedRoleId.value || apiPermissionSaving.value) {
|
||
return
|
||
}
|
||
|
||
const disabledPermission = apiPermissions.value.find(
|
||
(permission) =>
|
||
selectedRolePermissionIds.value.includes(permission.id) && permission.status === 'DISABLED',
|
||
)
|
||
|
||
if (disabledPermission) {
|
||
ElMessage.warning(`权限“${disabledPermission.name}”已停用,请取消勾选后再保存`)
|
||
return
|
||
}
|
||
|
||
apiPermissionSaving.value = true
|
||
|
||
try {
|
||
const response = await updateRolePermissions(selectedRoleId.value, {
|
||
permissionIds: selectedRolePermissionIds.value,
|
||
expectedVersion: rolePermissionVersion.value,
|
||
})
|
||
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
|
||
rolePermissionVersion.value = response.version
|
||
ElMessage.success('角色 API 权限已保存')
|
||
} catch {
|
||
ElMessage.error('角色 API 权限保存失败,请稍后重试')
|
||
} finally {
|
||
apiPermissionSaving.value = false
|
||
}
|
||
}
|
||
|
||
function mapUserRow(
|
||
user: ApiUserRecord,
|
||
campusNames: Map<string, string>,
|
||
departmentNames: Map<string, string>,
|
||
): UserTableRow {
|
||
const dataScopeLabels: Record<string, string> = {
|
||
ALL: '全院',
|
||
CAMPUS: '院区',
|
||
DEPARTMENT: '本科室',
|
||
READ_ONLY_ALL: '全院只读',
|
||
}
|
||
|
||
return {
|
||
id: user.id,
|
||
name: user.name,
|
||
account: user.account ?? '',
|
||
employeeNo: user.employeeNo ?? '—',
|
||
phone: user.phone ?? '',
|
||
email: user.email ?? '',
|
||
campus:
|
||
campusNames.get(user.campusId ?? '') ??
|
||
user.campusName ??
|
||
(user.campusId ? '未知院区' : '未指定院区'),
|
||
campusId: user.campusId,
|
||
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
|
||
departmentId: user.departmentId,
|
||
role: user.role,
|
||
roleNames: user.roleNames,
|
||
roleIds: user.roleIds,
|
||
dataScope: dataScopeLabels[user.dataScope ?? ''] ?? user.dataScope ?? '—',
|
||
status: user.status,
|
||
version: user.version,
|
||
}
|
||
}
|
||
|
||
function isMaskedContactValue(value: string | null | undefined) {
|
||
return Boolean(value?.includes('*'))
|
||
}
|
||
|
||
function mapRoleSummary(role: RoleRecord): RoleSummary {
|
||
return {
|
||
id: role.id,
|
||
name: role.name,
|
||
color: role.color ?? '#8a9aa3',
|
||
description: role.description,
|
||
userCount: role.userCount ?? null,
|
||
}
|
||
}
|
||
|
||
function showAddUserMessage() {
|
||
if (!canCreateUsers.value) {
|
||
return
|
||
}
|
||
|
||
editorInitial.value = null
|
||
editorVisible.value = true
|
||
}
|
||
|
||
function showImportMessage() {
|
||
if (!canImportUsers.value) {
|
||
return
|
||
}
|
||
|
||
if (isUserMockEnabled) {
|
||
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
||
return
|
||
}
|
||
|
||
importPreview.value = null
|
||
importJob.value = null
|
||
importVisible.value = true
|
||
}
|
||
|
||
async function editUser(row: UserTableRow) {
|
||
if (!canEditUsers.value) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const user = isUserMockEnabled
|
||
? userRows.value.find((item) => item.id === row.id)
|
||
: await getUserDetail(row.id)
|
||
|
||
if (!user) {
|
||
ElMessage.error('用户不存在或已被删除')
|
||
return
|
||
}
|
||
|
||
editorInitial.value = {
|
||
id: user.id,
|
||
username: user.account ?? '',
|
||
password: '',
|
||
displayName: user.name,
|
||
employeeNo: user.employeeNo ?? '',
|
||
phone: user.phone ?? '',
|
||
phoneMasked: isMaskedContactValue(user.phone),
|
||
email: user.email ?? '',
|
||
emailMasked: isMaskedContactValue(user.email),
|
||
campusId:
|
||
user.campusId ?? campuses.value.find((campus) => campus.name === row.campus)?.id ?? '',
|
||
departmentId:
|
||
user.departmentId ??
|
||
departments.value.find((department) => department.name === row.department)?.id ??
|
||
'',
|
||
status: user.status,
|
||
dataScope: toBackendDataScope(user.dataScope),
|
||
roleIds: user.roleIds ?? [],
|
||
clearPhone: false,
|
||
clearEmail: false,
|
||
version: user.version,
|
||
}
|
||
editorVisible.value = true
|
||
} catch {
|
||
ElMessage.error('用户详情加载失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
function resetPassword(user: UserTableRow) {
|
||
if (!canResetPasswords.value) {
|
||
return
|
||
}
|
||
|
||
if (isUserMockEnabled) {
|
||
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
||
return
|
||
}
|
||
|
||
passwordResetTarget.value = user
|
||
passwordResetVisible.value = true
|
||
}
|
||
|
||
async function handlePasswordReset(payload: {
|
||
newPassword: string
|
||
forceChangeOnNextLogin: boolean
|
||
}) {
|
||
const target = passwordResetTarget.value
|
||
|
||
if (!target) {
|
||
return
|
||
}
|
||
|
||
passwordResetSaving.value = true
|
||
|
||
try {
|
||
await resetUserPassword(target.id, payload)
|
||
passwordResetVisible.value = false
|
||
passwordResetTarget.value = null
|
||
ElMessage.success(`用户“${target.name}”的密码已重置`)
|
||
} catch {
|
||
ElMessage.error('密码重置失败,请稍后重试')
|
||
} finally {
|
||
passwordResetSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function handleImportPreview(payload: { file: File; campusId?: string }) {
|
||
importLoading.value = true
|
||
importPreview.value = null
|
||
importJob.value = null
|
||
|
||
try {
|
||
importPreview.value = await previewUserImport(payload.file, payload.campusId)
|
||
} catch {
|
||
ElMessage.error('用户文件校验失败,请检查文件格式和内容')
|
||
} finally {
|
||
importLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleImportCommit() {
|
||
const preview = importPreview.value
|
||
|
||
if (!preview || preview.invalidRows > 0) {
|
||
return
|
||
}
|
||
|
||
importLoading.value = true
|
||
|
||
try {
|
||
const job = await commitUserImport({ importId: preview.importId })
|
||
importJob.value = job
|
||
|
||
try {
|
||
importJob.value = await getUserImportJob(job.jobId)
|
||
} catch {
|
||
// 提交已经成功,任务状态查询失败不影响本次导入结果。
|
||
}
|
||
|
||
ElMessage.success('用户导入任务已提交')
|
||
await loadUsers()
|
||
} catch {
|
||
ElMessage.error('用户导入提交失败,请稍后重试')
|
||
} finally {
|
||
importLoading.value = false
|
||
}
|
||
}
|
||
|
||
function searchUsers() {
|
||
if (isUserMockEnabled) {
|
||
return
|
||
}
|
||
|
||
userPage.value = 1
|
||
void loadUsers()
|
||
}
|
||
|
||
function changeUserPage(nextPage: number) {
|
||
const pageCount = Math.max(Math.ceil(userTotal.value / userPageSize.value), 1)
|
||
|
||
if (isUserMockEnabled || nextPage < 1 || nextPage > pageCount || nextPage === userPage.value) {
|
||
return
|
||
}
|
||
|
||
userPage.value = nextPage
|
||
void loadUsers()
|
||
}
|
||
|
||
async function deactivateUser(row: UserTableRow) {
|
||
if (
|
||
!canDeactivateUsers.value ||
|
||
row.status !== 'enabled' ||
|
||
!window.confirm(`确定停用用户“${row.name}”吗?`)
|
||
) {
|
||
return
|
||
}
|
||
|
||
disablingUserId.value = row.id
|
||
|
||
try {
|
||
if (isUserMockEnabled) {
|
||
const target = userRows.value.find((user) => user.id === row.id)
|
||
if (target) {
|
||
target.status = 'disabled'
|
||
}
|
||
} else {
|
||
await deleteUser(row.id)
|
||
await loadUsers()
|
||
}
|
||
|
||
ElMessage.success(`用户“${row.name}”已停用`)
|
||
} catch {
|
||
ElMessage.error('用户停用失败,请稍后重试')
|
||
} finally {
|
||
disablingUserId.value = null
|
||
}
|
||
}
|
||
|
||
function toBackendDataScope(value: string | undefined) {
|
||
const map: Record<string, string> = {
|
||
全院: 'ALL',
|
||
院区: 'CAMPUS',
|
||
本科室: 'DEPARTMENT',
|
||
全院只读: 'READ_ONLY_ALL',
|
||
}
|
||
|
||
return (map[value ?? ''] ?? value ?? 'DEPARTMENT') as BackendDataScope
|
||
}
|
||
|
||
function toBackendUserStatus(value: UserTableRow['status']): BackendUserStatus {
|
||
const map: Record<UserTableRow['status'], BackendUserStatus> = {
|
||
enabled: 'ENABLED',
|
||
disabled: 'DISABLED',
|
||
locked: 'LOCKED',
|
||
}
|
||
|
||
return map[value]
|
||
}
|
||
|
||
function getRoleNames(roleIds: string[]) {
|
||
return roleIds
|
||
.map((roleId) => roleOptions.value.find((role) => role.id === roleId)?.name)
|
||
.filter((name): name is string => Boolean(name))
|
||
}
|
||
|
||
function getCampusName(id: string) {
|
||
return campuses.value.find((campus) => campus.id === id)?.name ?? '未指定院区'
|
||
}
|
||
|
||
function getDepartmentName(id: string) {
|
||
return departments.value.find((department) => department.id === id)?.name ?? '未指定科室'
|
||
}
|
||
|
||
async function saveUser(form: UserEditorForm) {
|
||
editorSaving.value = true
|
||
|
||
try {
|
||
if (isUserMockEnabled) {
|
||
const row: UserTableRow = {
|
||
id: form.id ?? `mock-user-${Date.now()}`,
|
||
name: form.displayName,
|
||
account: form.username,
|
||
employeeNo: form.employeeNo || '—',
|
||
phone: form.phone,
|
||
email: form.email,
|
||
campus: getCampusName(form.campusId),
|
||
campusId: form.campusId,
|
||
department: getDepartmentName(form.departmentId),
|
||
departmentId: form.departmentId || null,
|
||
role: getRoleNames(form.roleIds).join('、') || '未分配角色',
|
||
roleNames: getRoleNames(form.roleIds),
|
||
roleIds: [...form.roleIds],
|
||
dataScope: form.dataScope,
|
||
status: form.status,
|
||
version: form.version,
|
||
}
|
||
const index = userRows.value.findIndex((item) => item.id === row.id)
|
||
if (index === -1) {
|
||
userRows.value.unshift(row)
|
||
} else {
|
||
userRows.value[index] = row
|
||
}
|
||
} else if (form.id) {
|
||
await updateUserManagement(form.id, {
|
||
displayName: form.displayName,
|
||
employeeNo: form.employeeNo || undefined,
|
||
...(form.phoneMasked
|
||
? {}
|
||
: form.clearPhone
|
||
? { clearPhone: true }
|
||
: { phone: form.phone || undefined }),
|
||
...(form.emailMasked
|
||
? {}
|
||
: form.clearEmail
|
||
? { clearEmail: true }
|
||
: { email: form.email || undefined }),
|
||
campusId: form.campusId,
|
||
departmentId: form.departmentId || null,
|
||
status: toBackendUserStatus(form.status),
|
||
dataScope: form.dataScope,
|
||
roleIds: form.roleIds,
|
||
expectedVersion: form.version,
|
||
})
|
||
await loadUsers()
|
||
} else {
|
||
await createUser({
|
||
username: form.username,
|
||
password: form.password,
|
||
displayName: form.displayName,
|
||
employeeNo: form.employeeNo || undefined,
|
||
phone: form.phone || undefined,
|
||
email: form.email || undefined,
|
||
campusId: form.campusId,
|
||
departmentId: form.departmentId || null,
|
||
status: toBackendUserStatus(form.status),
|
||
dataScope: form.dataScope,
|
||
roleIds: form.roleIds,
|
||
})
|
||
await loadUsers()
|
||
}
|
||
|
||
editorVisible.value = false
|
||
ElMessage.success(form.id ? '用户已更新' : '用户已创建')
|
||
} catch {
|
||
ElMessage.error(form.id ? '用户更新失败,请稍后重试' : '用户创建失败,请稍后重试')
|
||
} finally {
|
||
editorSaving.value = false
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => appStore.selectedCampus,
|
||
() => {
|
||
if (!isUserMockEnabled) {
|
||
userPage.value = 1
|
||
void loadUsers()
|
||
}
|
||
},
|
||
)
|
||
|
||
onMounted(loadUsers)
|
||
|
||
function openRolePermissionDrawer(role: RoleSummary) {
|
||
if (!canViewRoleApiPermissions.value) {
|
||
return
|
||
}
|
||
|
||
rolePermissionDrawerVisible.value = true
|
||
void selectRoleForApiPermissions(role.id)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="users-page">
|
||
<div v-if="loading" class="loading-card" aria-label="正在加载用户与角色">
|
||
<span v-for="index in 4" :key="index" />
|
||
</div>
|
||
|
||
<template v-else>
|
||
<RoleSummaryCards
|
||
:rows="roleRows"
|
||
:interactive="canViewRoleApiPermissions"
|
||
:selected-role-id="selectedRoleId"
|
||
@select="openRolePermissionDrawer"
|
||
/>
|
||
|
||
<section class="page-card">
|
||
<div class="card-heading">
|
||
<h2>用户列表</h2>
|
||
<span class="heading-spacer" />
|
||
<input
|
||
v-model="keyword"
|
||
class="user-search"
|
||
type="search"
|
||
placeholder="搜索姓名 / 工号"
|
||
aria-label="搜索姓名或工号"
|
||
@keydown.enter.prevent="searchUsers"
|
||
/>
|
||
<button type="button" class="table-button" @click="searchUsers">查询</button>
|
||
<button
|
||
v-if="canCreateUsers"
|
||
type="button"
|
||
class="table-button"
|
||
@click="showAddUserMessage"
|
||
>
|
||
+新增用户
|
||
</button>
|
||
<button
|
||
v-if="canImportUsers"
|
||
type="button"
|
||
class="table-button"
|
||
@click="showImportMessage"
|
||
>
|
||
批量导入
|
||
</button>
|
||
</div>
|
||
<UserTable
|
||
:rows="visibleUsers"
|
||
:role-colors="roleColorMap"
|
||
:disabling-id="disablingUserId"
|
||
:can-edit="canEditUsers"
|
||
:can-reset-password="canResetPasswords"
|
||
:can-deactivate="canDeactivateUsers"
|
||
@edit="editUser"
|
||
@reset-password="resetPassword"
|
||
@deactivate="deactivateUser"
|
||
/>
|
||
<div v-if="!isUserMockEnabled && userTotal" class="table-pagination">
|
||
<span>共 {{ userTotal }} 条</span>
|
||
<button
|
||
type="button"
|
||
class="table-button"
|
||
:disabled="userPage <= 1 || loading"
|
||
@click="changeUserPage(userPage - 1)"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span>第 {{ userPage }} / {{ Math.max(Math.ceil(userTotal / userPageSize), 1) }} 页</span>
|
||
<button
|
||
type="button"
|
||
class="table-button"
|
||
:disabled="userPage >= Math.ceil(userTotal / userPageSize) || loading"
|
||
@click="changeUserPage(userPage + 1)"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="rolePermissionDrawerVisible && canViewRoleApiPermissions"
|
||
class="role-permission-mask"
|
||
@click.self="rolePermissionDrawerVisible = false"
|
||
>
|
||
<aside class="role-permission-drawer" role="dialog" aria-modal="true">
|
||
<RoleApiPermissionPanel
|
||
:roles="roleOptions"
|
||
:permissions="apiPermissions"
|
||
:selected-role-id="selectedRoleId"
|
||
:selected-permission-ids="selectedRolePermissionIds"
|
||
:loading="apiPermissionLoading"
|
||
:saving="apiPermissionSaving"
|
||
:error="apiPermissionError"
|
||
:can-edit="canEditRoleApiPermissions"
|
||
drawer
|
||
@select-role="selectRoleForApiPermissions"
|
||
@toggle-permission="toggleApiPermission"
|
||
@save="saveRoleApiPermissions"
|
||
@close="rolePermissionDrawerVisible = false"
|
||
/>
|
||
</aside>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<UserEditorDialog
|
||
:visible="editorVisible"
|
||
:initial="editorInitial"
|
||
:campuses="campuses"
|
||
:departments="departments"
|
||
:roles="roleOptions"
|
||
:saving="editorSaving"
|
||
@close="editorVisible = false"
|
||
@save="saveUser"
|
||
/>
|
||
<UserPasswordResetDialog
|
||
:visible="passwordResetVisible"
|
||
:user-name="passwordResetTarget?.name ?? ''"
|
||
:saving="passwordResetSaving"
|
||
@close="passwordResetVisible = false"
|
||
@reset="handlePasswordReset"
|
||
/>
|
||
<UserImportDialog
|
||
:visible="importVisible"
|
||
:campuses="campuses"
|
||
:default-campus-id="campuses.find((campus) => campus.name === appStore.selectedCampus)?.id"
|
||
:preview="importPreview"
|
||
:job="importJob"
|
||
:loading="importLoading"
|
||
@close="importVisible = false"
|
||
@preview="handleImportPreview"
|
||
@commit="handleImportCommit"
|
||
/>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.users-page {
|
||
width: 100%;
|
||
max-width: 1440px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-card {
|
||
padding: 16px 18px;
|
||
background: var(--card);
|
||
border-radius: var(--r);
|
||
box-shadow: var(--sh);
|
||
}
|
||
|
||
.card-heading {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.card-heading h2 {
|
||
margin: 0;
|
||
color: var(--ink);
|
||
font-size: 14.5px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.heading-spacer {
|
||
flex: 1;
|
||
}
|
||
|
||
.user-search {
|
||
width: 180px;
|
||
padding: 6px 10px;
|
||
color: var(--ink);
|
||
font-size: 12.5px;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.user-search:focus {
|
||
outline: none;
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
.table-button {
|
||
padding: 4px 10px;
|
||
color: var(--brand);
|
||
font-size: 12px;
|
||
background: #fff;
|
||
border: 1px solid var(--brand);
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.table-button:hover {
|
||
background: var(--brand-l);
|
||
}
|
||
|
||
.table-pagination {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
padding-top: 14px;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.table-pagination .table-button:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.role-permission-mask {
|
||
position: fixed;
|
||
z-index: 50;
|
||
inset: 0;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
background: rgb(9 40 52 / 45%);
|
||
}
|
||
|
||
.role-permission-drawer {
|
||
width: min(760px, 96vw);
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: var(--card);
|
||
box-shadow: var(--sh-modal);
|
||
animation: slide-in 0.18s ease-out;
|
||
}
|
||
|
||
.loading-card {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.loading-card span {
|
||
display: block;
|
||
height: 116px;
|
||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||
background-size: 400% 100%;
|
||
border-radius: 10px;
|
||
box-shadow: var(--sh);
|
||
animation: skeleton-shimmer 1.4s ease infinite;
|
||
}
|
||
|
||
@keyframes skeleton-shimmer {
|
||
0% {
|
||
background-position: 100% 0;
|
||
}
|
||
|
||
100% {
|
||
background-position: -100% 0;
|
||
}
|
||
}
|
||
|
||
@keyframes slide-in {
|
||
from {
|
||
transform: translateX(12px);
|
||
opacity: 0;
|
||
}
|
||
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.card-heading {
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.heading-spacer {
|
||
display: none;
|
||
}
|
||
|
||
.user-search {
|
||
flex: 1;
|
||
min-width: 180px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.loading-card {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|