feat(management): 接入文档用户权限与报表

This commit is contained in:
yelan
2026-09-01 10:13:11 +08:00
parent beeb98d18f
commit e467f03f23
12 changed files with 2361 additions and 41 deletions
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import type { UserEditorForm } from '../types'
const props = defineProps<{
visible: boolean
initial: UserEditorForm | null
campuses: CampusRecord[]
departments: DepartmentRecord[]
saving: boolean
}>()
const emit = defineEmits<{
close: []
save: [form: UserEditorForm]
}>()
const form = reactive<UserEditorForm>(createEmptyForm())
const error = reactive({ message: '' })
const isEditing = computed(() => Boolean(form.id))
const availableDepartments = computed(() =>
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
)
function createEmptyForm(): UserEditorForm {
return {
username: '',
password: '',
displayName: '',
employeeNo: '',
phone: '',
email: '',
campusId: props.campuses[0]?.id ?? '',
departmentId: '',
status: 'enabled',
dataScope: 'DEPARTMENT',
}
}
function resetForm() {
Object.assign(form, props.initial ? { ...props.initial } : createEmptyForm())
error.message = ''
}
function handleCampusChange() {
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
form.departmentId = ''
}
}
function submit() {
error.message = ''
if (!form.displayName.trim()) {
error.message = '请输入姓名'
return
}
if (!isEditing.value && !form.username.trim()) {
error.message = '请输入登录账号'
return
}
if (!isEditing.value && !form.password) {
error.message = '请输入初始密码'
return
}
if (!form.campusId) {
error.message = '请选择院区'
return
}
emit('save', {
...form,
username: form.username.trim(),
displayName: form.displayName.trim(),
employeeNo: form.employeeNo.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
})
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
watch(
() => props.initial,
() => {
if (props.visible) {
resetForm()
}
},
)
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
<section
class="dialog-card"
role="dialog"
aria-modal="true"
aria-labelledby="user-editor-title"
>
<header class="dialog-header">
<div>
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
<p>{{ isEditing ? '更新用户基础资料与数据范围' : '创建可登录医签通的医护用户' }}</p>
</div>
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<form class="dialog-body" @submit.prevent="submit">
<div class="form-grid">
<label v-if="!isEditing">
<span>登录账号 <em>*</em></span>
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
</label>
<label v-else>
<span>登录账号</span>
<input :value="form.username" disabled />
</label>
<label>
<span>姓名 <em>*</em></span>
<input v-model="form.displayName" autocomplete="name" placeholder="请输入姓名" />
</label>
<label v-if="!isEditing">
<span>初始密码 <em>*</em></span>
<input
v-model="form.password"
type="password"
autocomplete="new-password"
placeholder="请输入初始密码"
/>
</label>
<label>
<span>工号</span>
<input v-model="form.employeeNo" placeholder="请输入工号" />
</label>
<label>
<span>手机号</span>
<input
v-model="form.phone"
inputmode="tel"
autocomplete="tel"
placeholder="请输入手机号"
/>
</label>
<label>
<span>邮箱</span>
<input
v-model="form.email"
type="email"
autocomplete="email"
placeholder="请输入邮箱"
/>
</label>
<label>
<span>院区 <em>*</em></span>
<select v-model="form.campusId" @change="handleCampusChange">
<option value="" disabled>请选择院区</option>
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</label>
<label>
<span>科室</span>
<select v-model="form.departmentId">
<option value="">未指定科室</option>
<option
v-for="department in availableDepartments"
:key="department.id"
:value="department.id"
>
{{ department.name }}
</option>
</select>
</label>
<label>
<span>状态</span>
<select v-model="form.status">
<option value="enabled">启用</option>
<option value="disabled">停用</option>
</select>
</label>
<label>
<span>数据范围</span>
<select v-model="form.dataScope">
<option value="ALL">全院</option>
<option value="CAMPUS">院区</option>
<option value="DEPARTMENT">本科室</option>
<option value="READ_ONLY_ALL">全院只读</option>
</select>
</label>
</div>
<p v-if="error.message" class="form-error">{{ error.message }}</p>
<p class="form-tip">当前接口未提供用户角色绑定字段,角色分配暂保留在角色管理流程。</p>
</form>
<footer class="dialog-footer">
<span class="footer-spacer" />
<button type="button" class="button button--ghost" @click="emit('close')">取消</button>
<button type="button" class="button" :disabled="saving" @click="submit">
{{ saving ? '保存中…' : '保存' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
width: 680px;
max-width: 96vw;
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
}
.dialog-header,
.dialog-footer {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
}
.dialog-header {
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
color: var(--ink);
font-size: 15px;
}
.dialog-header p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12px;
}
.close-button {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
padding: 18px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
}
.form-grid label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.form-grid label > span {
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.form-grid em {
color: var(--err);
font-style: normal;
}
.form-grid input,
.form-grid select {
width: 100%;
padding: 8px 10px;
color: var(--ink);
font: inherit;
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.form-grid input:focus,
.form-grid select:focus {
outline: none;
border-color: var(--brand);
}
.form-grid input:disabled {
color: var(--mut);
background: #f5f8f9;
}
.form-error {
margin: 12px 0 0;
color: var(--err);
font-size: 12px;
}
.form-tip {
padding: 9px 10px;
margin: 14px 0 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.6;
background: #f7fafb;
border-radius: 6px;
}
.dialog-footer {
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.footer-spacer {
flex: 1;
}
.button {
padding: 8px 16px;
color: #fff;
font-size: 12.5px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
@media (max-width: 620px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+271 -11
View File
@@ -2,21 +2,64 @@
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, UserTableRow } from './types'
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 === '本部院区'
const matchesCampus = user.campus === appStore.selectedCampus
const matchesKeyword =
!normalizedKeyword ||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
@@ -27,26 +70,233 @@ const visibleUsers = computed(() => {
async function loadUsers() {
loading.value = true
await Promise.resolve()
roleRows.value = roleSummaries.map((role) => ({ ...role }))
userRows.value = users.map((user) => ({ ...user }))
loading.value = false
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() {
ElMessage.info('原型演示:新增用户')
editorInitial.value = null
editorVisible.value = true
}
function showImportMessage() {
ElMessage.info('原型演示:批量导入')
}
function editUser(user: UserTableRow) {
ElMessage.info(`原型演示:编辑用户《${user.name}》`)
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.success(`已重置${user.name}的密码`)
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)
@@ -77,12 +327,22 @@ onMounted(loadUsers)
</div>
<UserTable
:rows="visibleUsers"
:role-colors="roleColors"
: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>
@@ -11,10 +11,29 @@ export interface RoleSummary {
export interface UserTableRow {
id: string
name: string
account?: string
employeeNo: string
phone?: string
email?: string
campus: string
campusId?: string | null
department: string
departmentId?: string | null
role: string
dataScope: string
status: UserStatus
}
export interface UserEditorForm {
id?: string
username: string
password: string
displayName: string
employeeNo: string
phone: string
email: string
campusId: string
departmentId: string
status: UserStatus
dataScope: string
}