fix(web): 修正组织停用行为并默认筛选启用中文档

This commit is contained in:
xy
2026-10-07 21:21:20 +08:00
parent 891b59d328
commit c415c29b5e
5 changed files with 217 additions and 97 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
## 当前状态
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台、签署记录查询、文档库管理页面和「报表分析」四个菜单(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台、签署记录查询、文档库管理页面和「报表分析」四个菜单(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。院区与科室的 `DELETE` 接口表示停用当前节点,不级联停用或删除下级;组织树默认筛选“启用中”,可切换为“已停用”或“全部状态”;停用节点在组织树中标记“已停用”,可通过编辑重新启用。操作成功但列表刷新失败时会提示刷新失败,保留已成功的停用状态。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
@@ -154,7 +154,8 @@ export async function updateCampus(
return mapCampus(unwrapApiResponse(response))
}
export async function deleteCampus(id: string): Promise<void> {
/** DELETE 接口将院区停用,保留记录及下属科室。 */
export async function disableCampus(id: string): Promise<void> {
if (useMockData) {
throw new Error('Mock 模式不执行院区写操作')
}
@@ -192,7 +193,8 @@ export async function updateDepartment(
return mapDepartment(unwrapApiResponse(response))
}
export async function deleteDepartment(id: string): Promise<void> {
/** DELETE 接口将当前科室停用,保留记录及下级科室。 */
export async function disableDepartment(id: string): Promise<void> {
if (useMockData) {
throw new Error('Mock 模式不执行科室写操作')
}
@@ -121,7 +121,7 @@ const libraryCampusId = computed(() => (allCampusesSelected.value ? '' : appStor
const filters = reactive<DocumentFilterForm>({
keyword: '',
department: null,
status: 'all',
status: '启用中',
})
/** 预览弹窗 */
@@ -1190,8 +1190,8 @@ async function resetMockData() {
function clearFilters() {
filters.keyword = ''
filters.department = null
// status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」
filters.status = 'all'
// 清除筛选后恢复默认的启用中状态。
filters.status = '启用中'
// 院区不在这里重置:它是页面的取数口径(文书库按院区向服务端取数),
// 不是「筛掉一部分已加载数据」的筛选条件,清掉它得重新拉一遍文库
}
@@ -1302,7 +1302,7 @@ onMounted(() => {
<template v-else>
<p>没有符合条件的文书</p>
<button
v-if="filters.keyword || filters.department || filters.status !== 'all'"
v-if="filters.keyword || filters.department || filters.status !== '启用中'"
type="button"
class="reset-button"
@click="clearFilters"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
@@ -11,6 +11,7 @@ const props = defineProps<{
collapsed: boolean
canManageCampuses: boolean
canManageDepartments: boolean
disabling: boolean
}>()
const emit = defineEmits<{
@@ -20,13 +21,29 @@ const emit = defineEmits<{
addDepartment: []
editCampus: [campus: CampusRecord]
editDepartment: [department: DepartmentRecord]
deleteCampus: [campus: CampusRecord]
deleteDepartment: [department: DepartmentRecord]
disableCampus: [campus: CampusRecord]
disableDepartment: [department: DepartmentRecord]
}>()
const statusFilter = ref<'ENABLED' | 'DISABLED' | 'all'>('ENABLED')
const visibleDepartments = computed(() =>
props.departments.filter(
(department) => statusFilter.value === 'all' || department.status === statusFilter.value,
),
)
const visibleCampuses = computed(() =>
props.campuses.filter(
(campus) =>
statusFilter.value === 'all' ||
campus.status === statusFilter.value ||
(statusFilter.value === 'DISABLED' &&
visibleDepartments.value.some((department) => department.campusId === campus.id)),
),
)
const institutionGroups = computed(() => {
const grouped = new Map<string, CampusRecord[]>()
for (const campus of props.campuses) {
for (const campus of visibleCampuses.value) {
const institutionName = campus.institutionName?.trim() || '新华院区'
const group = grouped.get(institutionName) ?? []
group.push(campus)
@@ -36,7 +53,7 @@ const institutionGroups = computed(() => {
})
function departmentsForCampus(campusId: string) {
return props.departments
return visibleDepartments.value
.filter((department) => department.campusId === campusId)
.sort(
(left, right) => left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
@@ -45,13 +62,16 @@ function departmentsForCampus(campusId: string) {
function departmentCountForInstitution(campuses: CampusRecord[]) {
const campusIds = new Set(campuses.map((campus) => campus.id))
return props.departments.filter((department) => campusIds.has(department.campusId)).length
return visibleDepartments.value.filter((department) => campusIds.has(department.campusId)).length
}
function childrenFor(parentId: string | null | undefined, campusId: string) {
return departmentsForCampus(campusId).filter(
(department) => (department.parentId ?? null) === (parentId ?? null),
)
const departments = departmentsForCampus(campusId)
return departments.filter((department) => {
const visibleParent = departments.some((item) => item.id === department.parentId)
const effectiveParentId = visibleParent ? department.parentId : null
return (effectiveParentId ?? null) === (parentId ?? null)
})
}
function isDescendantSelected(department: DepartmentRecord): boolean {
@@ -82,6 +102,15 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
</button>
</header>
<label class="org-status-filter">
<span>状态</span>
<select v-model="statusFilter" aria-label="组织节点状态">
<option value="ENABLED">启用中</option>
<option value="DISABLED">已停用</option>
<option value="all">全部状态</option>
</select>
</label>
<div class="org-tree">
<div
v-for="institution in institutionGroups"
@@ -100,7 +129,10 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
<div v-for="campus in institution.campuses" :key="campus.id" class="org-tree__campus-group">
<div
class="org-tree__node org-tree__campus"
:class="{ 'is-selected': selectedCampusId === campus.id && !selectedDepartmentId }"
:class="{
'is-selected': selectedCampusId === campus.id && !selectedDepartmentId,
'is-disabled': campus.status === 'DISABLED',
}"
>
<button
type="button"
@@ -115,6 +147,7 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
>
<span class="org-tree__icon">🏥</span>
<span class="org-tree__name">{{ campus.name }}</span>
<span v-if="campus.status === 'DISABLED'" class="org-tree__status">已停用</span>
<span class="org-tree__count">{{ departmentsForCampus(campus.id).length }} 科室</span>
</button>
<div v-if="canManageCampuses" class="org-tree__actions">
@@ -127,12 +160,14 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
编辑
</button>
<button
v-if="campus.status !== 'DISABLED'"
type="button"
class="org-tree__remove"
:aria-label="`删除院区${campus.name}`"
@click="emit('deleteCampus', campus)"
class="org-tree__disable"
:disabled="disabling"
:aria-label="`停用院区${campus.name}`"
@click="emit('disableCampus', campus)"
>
删除
停用
</button>
</div>
</div>
@@ -144,7 +179,10 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
<template v-for="department in childrenFor(null, campus.id)" :key="department.id">
<div
class="org-tree__node org-tree__department"
:class="{ 'is-selected': isDescendantSelected(department) }"
:class="{
'is-selected': isDescendantSelected(department),
'is-disabled': department.status === 'DISABLED',
}"
>
<button
type="button"
@@ -152,6 +190,9 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
@click="emit('selectDepartment', department)"
>
<span class="org-tree__name">🗂 {{ department.name }}</span>
<span v-if="department.status === 'DISABLED'" class="org-tree__status"
>已停用</span
>
</button>
<div v-if="canManageDepartments" class="org-tree__actions">
<button
@@ -163,12 +204,14 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
编辑
</button>
<button
v-if="department.status !== 'DISABLED'"
type="button"
class="org-tree__remove"
:aria-label="`删除科室${department.name}`"
@click="emit('deleteDepartment', department)"
class="org-tree__disable"
:disabled="disabling"
:aria-label="`停用科室${department.name}`"
@click="emit('disableDepartment', department)"
>
删除
停用
</button>
</div>
</div>
@@ -176,7 +219,10 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
v-for="child in childrenFor(department.id, campus.id)"
:key="child.id"
class="org-tree__node org-tree__child-department"
:class="{ 'is-selected': isDescendantSelected(child) }"
:class="{
'is-selected': isDescendantSelected(child),
'is-disabled': child.status === 'DISABLED',
}"
>
<button
type="button"
@@ -184,6 +230,7 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
@click="emit('selectDepartment', child)"
>
<span class="org-tree__name">{{ child.name }}</span>
<span v-if="child.status === 'DISABLED'" class="org-tree__status">已停用</span>
</button>
<div v-if="canManageDepartments" class="org-tree__actions">
<button
@@ -195,24 +242,26 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
编辑
</button>
<button
v-if="child.status !== 'DISABLED'"
type="button"
class="org-tree__remove"
:aria-label="`删除科室${child.name}`"
@click="emit('deleteDepartment', child)"
class="org-tree__disable"
:disabled="disabling"
:aria-label="`停用科室${child.name}`"
@click="emit('disableDepartment', child)"
>
删除
停用
</button>
</div>
</div>
</template>
<p v-if="!childrenFor(null, campus.id).length" class="org-tree__empty">
该院区还没有维护科室
当前状态下暂无科室
</p>
</div>
</div>
</div>
<p v-if="!campuses.length" class="org-tree__empty">暂无可用院区</p>
<p v-if="!visibleCampuses.length" class="org-tree__empty">当前状态下暂无院区</p>
</div>
</section>
</template>
@@ -274,6 +323,24 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
border-color: #d3e7ee;
}
.org-status-filter {
display: flex;
gap: 8px;
align-items: center;
margin: 10px 0;
color: var(--mut);
font-size: 12px;
}
.org-status-filter select {
flex: 1;
padding: 5px 8px;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.org-tree {
display: grid;
gap: 4px;
@@ -408,7 +475,7 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
}
.org-tree__edit,
.org-tree__remove {
.org-tree__disable {
padding: 3px 7px;
font-size: 10.5px;
background: #fff;
@@ -421,19 +488,39 @@ function isDescendantSelected(department: DepartmentRecord): boolean {
border: 1px solid #d3e7ee;
}
.org-tree__remove {
.org-tree__disable {
margin-right: 6px;
color: var(--err);
border: 1px solid #f0cdcd;
}
.org-tree__node:hover .org-tree__edit,
.org-tree__node:hover .org-tree__remove,
.org-tree__node:hover .org-tree__disable,
.org-tree__node:focus-within .org-tree__edit,
.org-tree__node:focus-within .org-tree__remove {
.org-tree__node:focus-within .org-tree__disable {
opacity: 1;
}
.org-tree__node.is-disabled {
color: var(--mut);
}
.org-tree__status {
flex: 0 0 auto;
padding: 2px 5px;
color: var(--mut);
font-size: 10px;
font-weight: 400;
white-space: nowrap;
background: #f0f2f4;
border: 1px solid var(--line);
border-radius: 4px;
}
.org-tree__disable:disabled {
cursor: not-allowed;
}
.org-tree__empty {
padding: 10px;
margin: 0;
@@ -5,8 +5,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
createCampus,
createDepartment,
deleteCampus,
deleteDepartment,
disableCampus,
disableDepartment,
getAllDepartments,
getCampuses,
updateCampus,
@@ -68,6 +68,7 @@ const organizationDialogVisible = ref(false)
const organizationDialogKind = ref<'campus' | 'department'>('campus')
const organizationDialogInitial = ref<CampusRecord | DepartmentRecord | null>(null)
const organizationSaving = ref(false)
const organizationDisabling = ref(false)
let userRequestId = 0
const canEditUsers = computed(() => isUserMockEnabled || loginStore.hasPermission('system:org:emp'))
@@ -302,7 +303,7 @@ function mapMockUsers() {
userTotal.value = userRows.value.length
}
async function loadUsers() {
async function loadUsers(notifyOnError = true): Promise<boolean> {
const requestId = ++userRequestId
loading.value = true
try {
@@ -321,7 +322,7 @@ async function loadUsers() {
return matchesCampus && matchesDepartment && matchesKeyword
})
userTotal.value = filtered.length
return
return true
}
const result = await getUsers({
@@ -332,7 +333,7 @@ async function loadUsers() {
campusId: selectedCampusId.value || undefined,
departmentId: selectedDepartmentId.value ?? undefined,
})
if (requestId !== userRequestId) return
if (requestId !== userRequestId) return false
const campusNames = new Map(campuses.value.map((campus) => [campus.id, campus.name]))
const departmentNames = new Map(
@@ -340,17 +341,20 @@ async function loadUsers() {
)
userRows.value = result.records.map((user) => mapUserRow(user, campusNames, departmentNames))
userTotal.value = result.total
return true
} catch {
if (requestId !== userRequestId) return
if (requestId !== userRequestId) return false
userRows.value = []
userTotal.value = 0
ElMessage.error('员工列表加载失败,请稍后重试')
if (notifyOnError) ElMessage.error('员工列表加载失败,请稍后重试')
return false
} finally {
if (requestId === userRequestId) loading.value = false
}
}
async function loadOrganization() {
async function loadOrganization(notifyOnError = true): Promise<boolean> {
let complete = true
loading.value = true
try {
if (isUserMockEnabled) {
@@ -363,15 +367,16 @@ async function loadOrganization() {
])
if (campusResult.status === 'rejected') throw campusResult.reason
campuses.value = campusResult.value
departments.value = departmentResult.status === 'fulfilled' ? departmentResult.value : []
if (departmentResult.status === 'fulfilled') departments.value = departmentResult.value
else complete = false
roleOptions.value = rolesResult.status === 'fulfilled' ? rolesResult.value : []
roleColorMap.value = Object.fromEntries(
roleOptions.value.map((role) => [role.name, role.color ?? '#8a9aa3']),
)
if (canAssignRoles.value && rolesResult.status === 'rejected') {
if (canAssignRoles.value && rolesResult.status === 'rejected' && notifyOnError) {
ElMessage.warning('员工角色选项加载失败,角色绑定暂不可用')
}
if (departmentResult.status === 'rejected') {
if (departmentResult.status === 'rejected' && notifyOnError) {
ElMessage.warning('科室架构加载失败,员工科室筛选暂不可用')
}
}
@@ -390,10 +395,12 @@ async function loadOrganization() {
campusCollapsed.value = false
if (preferredCampus) appStore.selectCampus(preferredCampus)
userPage.value = 1
await loadUsers()
const usersLoaded = await loadUsers(notifyOnError)
return complete && usersLoaded
} catch {
loading.value = false
ElMessage.error('组织架构加载失败,请稍后重试')
if (notifyOnError) ElMessage.error('组织架构加载失败,请稍后重试')
return false
}
}
@@ -647,7 +654,6 @@ async function saveOrganizationNode(form: OrganizationNodeSave) {
const campus = form.id
? await updateCampus(form.id, form.payload)
: await createCampus(form.payload)
await appStore.loadCampuses(true)
if (!form.id) selectedCampusId.value = campus.id
} else {
const department = form.id
@@ -657,8 +663,9 @@ async function saveOrganizationNode(form: OrganizationNodeSave) {
if (!form.id) selectedDepartmentId.value = department.id
}
organizationDialogVisible.value = false
await loadOrganization()
ElMessage.success(form.id ? '组织信息已更新' : '组织节点已创建')
const refreshed = await refreshOrganizationAfterMutation(form.kind)
if (refreshed) ElMessage.success(form.id ? '组织信息已更新' : '组织节点已创建')
else ElMessage.warning('组织信息已保存,但页面刷新失败,请刷新页面重试')
} catch {
ElMessage.error('组织信息保存失败,请检查编码是否重复或所属院区是否有效')
} finally {
@@ -666,54 +673,77 @@ async function saveOrganizationNode(form: OrganizationNodeSave) {
}
}
async function removeCampus(campus: CampusRecord) {
if (!canManageCampuses.value) return
const departmentCount = departments.value.filter(
(department) => department.campusId === campus.id,
).length
async function refreshOrganizationAfterMutation(kind: 'campus' | 'department'): Promise<boolean> {
try {
if (kind === 'campus') await appStore.loadCampuses(true)
const refreshed = await loadOrganization(false)
return refreshed && (kind !== 'campus' || !appStore.campusLoadFailed)
} catch {
return false
}
}
async function handleDisableCampus(campus: CampusRecord) {
if (!canManageCampuses.value || campus.status === 'DISABLED' || organizationDisabling.value)
return
organizationDisabling.value = true
try {
const confirmed = await ElMessageBox.confirm(
departmentCount
? `删除院区“${campus.name}”会同时移除其下 ${departmentCount} 个科室,且无法恢复,是否继续?`
: `删除院区“${campus.name}”后无法恢复,是否继续?`,
'删除院区',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
`停用院区“${campus.name}”只修改该院区的状态,不会停用或删除下属科室。组织记录和历史数据保留,可通过编辑重新启用。是否继续?`,
'停用院区',
{ type: 'warning', confirmButtonText: '停用', cancelButtonText: '取消' },
)
.then(() => true)
.catch(() => false)
if (!confirmed) return
try {
await deleteCampus(campus.id)
await disableCampus(campus.id)
} catch (error) {
ElMessage.error(toUserMessage(error, '院区停用失败,请稍后重试'))
return
}
campus.status = 'DISABLED'
if (selectedCampusId.value === campus.id) selectedDepartmentId.value = null
await appStore.loadCampuses(true)
await loadOrganization()
ElMessage.success('院区已删除')
} catch (error) {
ElMessage.error(toUserMessage(error, '院区删除失败,请稍后重试'))
const refreshed = await refreshOrganizationAfterMutation('campus')
if (refreshed) ElMessage.success('院区已停用')
else ElMessage.warning('院区已停用,但页面刷新失败,请刷新页面重试')
} finally {
organizationDisabling.value = false
}
}
async function removeDepartment(department: DepartmentRecord) {
if (!canManageDepartments.value) return
const childCount = departments.value.filter((item) => item.parentId === department.id).length
async function handleDisableDepartment(department: DepartmentRecord) {
if (
!canManageDepartments.value ||
department.status === 'DISABLED' ||
organizationDisabling.value
)
return
organizationDisabling.value = true
try {
const confirmed = await ElMessageBox.confirm(
childCount
? `删除科室“${department.name}”会同时移除其下 ${childCount} 个下级科室,且无法恢复,是否继续?`
: `删除科室“${department.name}”后无法恢复,是否继续?`,
'删除科室',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
`停用科室“${department.name}”只修改当前科室的状态,不会停用或删除下级科室。组织记录和历史数据保留,可通过编辑重新启用。是否继续?`,
'停用科室',
{ type: 'warning', confirmButtonText: '停用', cancelButtonText: '取消' },
)
.then(() => true)
.catch(() => false)
if (!confirmed) return
try {
await deleteDepartment(department.id)
if (selectedDepartmentId.value === department.id) selectedDepartmentId.value = null
await loadOrganization()
ElMessage.success('科室已删除')
await disableDepartment(department.id)
} catch (error) {
ElMessage.error(toUserMessage(error, '科室删除失败,请稍后重试'))
ElMessage.error(toUserMessage(error, '科室停用失败,请稍后重试'))
return
}
department.status = 'DISABLED'
if (selectedDepartmentId.value === department.id) selectedDepartmentId.value = null
const refreshed = await refreshOrganizationAfterMutation('department')
if (refreshed) ElMessage.success('科室已停用')
else ElMessage.warning('科室已停用,但页面刷新失败,请刷新页面重试')
} finally {
organizationDisabling.value = false
}
}
@@ -771,14 +801,15 @@ onMounted(loadOrganization)
:collapsed="campusCollapsed"
:can-manage-campuses="canManageCampuses"
:can-manage-departments="canManageDepartments"
:disabling="organizationDisabling"
@select-campus="selectCampus"
@select-department="selectDepartment"
@add-campus="openAddCampus"
@add-department="openAddDepartment"
@edit-campus="editCampus"
@edit-department="editDepartment"
@delete-campus="removeCampus"
@delete-department="removeDepartment"
@disable-campus="handleDisableCampus"
@disable-department="handleDisableDepartment"
/>
<section class="employee-card">