Files
xh-medical-sign-web/clinical-web/src/views/management/users/index.vue

457 lines
12 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.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getCampuses, getDepartments } from '@/api/management/organization'
import { getRoles } from '@/api/management/roles'
import { getUserDetail, getUsers, isUserMockEnabled } from '@/api/management/users'
import { createUser, updateUser } from '@/api/management/users'
import type {
CampusRecord,
DepartmentRecord,
RoleRecord,
UserRecord as ApiUserRecord,
} from '@/api/management/types'
import { useAppStore } from '@/stores/app'
import RoleSummaryCards from './components/RoleSummaryCards.vue'
import UserTable from './components/UserTable.vue'
import UserEditorDialog from './components/UserEditorDialog.vue'
import { roleColors, roleSummaries, users } from './mock'
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
const appStore = useAppStore()
const loading = ref(true)
const keyword = ref('')
const roleRows = ref<RoleSummary[]>([])
const userRows = ref<UserTableRow[]>([])
const roleColorMap = ref<Record<string, string>>(roleColors)
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
const editorVisible = ref(false)
const editorInitial = ref<UserEditorForm | null>(null)
const editorSaving = ref(false)
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(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
return userRows.value.filter((user) => {
const matchesCampus = user.campus === appStore.selectedCampus
const matchesKeyword =
!normalizedKeyword ||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
return matchesCampus && matchesKeyword
})
})
async function loadUsers() {
loading.value = true
try {
if (isUserMockEnabled) {
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
departments.value = mockDepartments.map((department) => ({ ...department }))
roleRows.value = roleSummaries.map((role) => ({ ...role }))
userRows.value = users.map((user) => ({ ...user }))
roleColorMap.value = roleColors
return
}
const [usersResult, rolesResult, campusesResult, departmentsResult] = await Promise.allSettled([
getUsers({ page: 1, pageSize: 200, status: 'all' }),
getRoles(),
getCampuses(),
getDepartments(),
])
if (usersResult.status === 'rejected') {
throw usersResult.reason
}
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))
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
roleRows.value = roles.map((role) => mapRoleSummary(role, apiUsers))
roleColorMap.value = Object.fromEntries(
roles.map((role) => [role.name, role.color ?? '#8a9aa3']),
)
} catch {
roleRows.value = []
userRows.value = []
ElMessage.error('用户与角色加载失败,请稍后重试')
} finally {
loading.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 ?? '') ?? '未指定院区',
campusId: user.campusId,
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
departmentId: user.departmentId,
role: user.role,
dataScope: dataScopeLabels[user.dataScope ?? ''] ?? user.dataScope ?? '—',
status: user.status,
}
}
function mapRoleSummary(role: RoleRecord, users: ApiUserRecord[]): RoleSummary {
const userCount =
role.userCount ??
users.filter((user) => user.roleNames?.some((roleName) => roleName === role.name)).length
return {
id: role.id,
name: role.name,
color: role.color ?? '#8a9aa3',
description: role.description,
userCount,
}
}
function showAddUserMessage() {
editorInitial.value = null
editorVisible.value = true
}
function showImportMessage() {
ElMessage.info('原型演示:批量导入')
}
async function editUser(row: UserTableRow) {
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 ?? '',
email: 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),
}
editorVisible.value = true
} catch {
ElMessage.error('用户详情加载失败,请稍后重试')
}
}
function resetPassword(user: UserTableRow) {
ElMessage.info(
isUserMockEnabled
? `原型演示:重置${user.name}的密码`
: '当前接口未提供重置密码能力,请由认证系统或管理员流程处理',
)
}
function toBackendDataScope(value: string | undefined) {
const map: Record<string, string> = {
全院: 'ALL',
院区: 'CAMPUS',
本科室: 'DEPARTMENT',
全院只读: 'READ_ONLY_ALL',
}
return map[value ?? ''] ?? value ?? 'DEPARTMENT'
}
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: form.id
? (userRows.value.find((item) => item.id === form.id)?.role ?? '未分配角色')
: '未分配角色',
dataScope: form.dataScope,
status: form.status,
}
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 updateUser(form.id, {
displayName: form.displayName,
employeeNo: form.employeeNo || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
campusId: form.campusId,
departmentId: form.departmentId || null,
status: form.status.toUpperCase(),
dataScope: form.dataScope,
})
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: form.status.toUpperCase(),
dataScope: form.dataScope,
})
await loadUsers()
}
editorVisible.value = false
ElMessage.success(form.id ? '用户已更新' : '用户已创建')
} catch {
ElMessage.error(form.id ? '用户更新失败,请稍后重试' : '用户创建失败,请稍后重试')
} finally {
editorSaving.value = false
}
}
onMounted(loadUsers)
</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" />
<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="搜索姓名或工号"
/>
<button type="button" class="table-button" @click="showAddUserMessage">新增用户</button>
<button type="button" class="table-button" @click="showImportMessage">批量导入</button>
</div>
<UserTable
:rows="visibleUsers"
:role-colors="roleColorMap"
@edit="editUser"
@reset-password="resetPassword"
/>
</section>
</template>
<UserEditorDialog
:visible="editorVisible"
:initial="editorInitial"
:campuses="campuses"
:departments="departments"
:saving="editorSaving"
@close="editorVisible = false"
@save="saveUser"
/>
</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);
}
.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;
}
}
@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>