feat(documents): 文书库筛选条按院区-科室-状态三级重构

This commit is contained in:
yelan
2026-10-07 11:34:15 +08:00
parent ee7e90ab01
commit 3ab71e4231
5 changed files with 235 additions and 76 deletions
@@ -287,12 +287,30 @@ export function getDemoDocumentLibrary(): LocalDepartment[] {
return demoLibraryCache return demoLibraryCache
} }
/**
* 演示文库的分组键归一。
*
* 种子里的分组键是 `demo-0` 这类占位值,与演示科室字典的 id 对不上。文档库的科室筛选条
* 现在按科室字典渲染(键 = 科室 id),不归一就会出现「点『麻醉科·精神科』筛出空结果」。
* 演示数据里科室名与字典一一对应,所以按**科室名**把分组键换成字典 id;
* 顺带把本地已保存的旧数据一并修正(保存时写回,下一次就正常了)。
*/
function normalizeDemoGroups(library: LocalDepartment[]): LocalDepartment[] {
return library.map((group) => {
const matched = demoDepartments.find((department) => department.name === group.name)
return matched && matched.id !== group.key ? { ...group, key: matched.id } : group
})
}
/** /**
* 读取演示文书库:优先用本地保存的(科室改过、建过的),没有则用种子。 * 读取演示文书库:优先用本地保存的(科室改过、建过的),没有则用种子。
* 调用方负责再克隆一份使用,避免直接改到种子数据。 * 调用方负责再克隆一份使用,避免直接改到种子数据。
*/ */
export function readDocumentLibrary(): LocalDepartment[] { export function readDocumentLibrary(): LocalDepartment[] {
return readMockState<LocalDepartment[]>(MOCK_STORAGE_KEYS.documentLibrary) ?? getDemoDocumentLibrary() const stored = readMockState<LocalDepartment[]>(MOCK_STORAGE_KEYS.documentLibrary)
return normalizeDemoGroups(stored ?? getDemoDocumentLibrary())
} }
/** 保存演示文书库,刷新后仍可继续编辑、预览与发起签署 */ /** 保存演示文书库,刷新后仍可继续编辑、预览与发起签署 */
@@ -1,23 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 文书库的两级筛选条(原型 V10.2 的 #libTabs / #libStatus)。 * 文书库的分级筛选条:院区 → 科室 → 状态。
* *
* 类名刻意沿用原型的 lib-tabs / lib-tab / lib-status / lib-st:这套样式在原型里 * 原型 V10.2 只有「科室 + 状态」两行(#libTabs / #libStatus)。这里把科室那一级按
* 是文档库页面独有的筛选语言(胶囊 + 虚线分隔),与通用 Tabs 组件的观感不同, * 「组织架构」的口径拆成两级:先选院区(同一份院区字典、同一个 sortNo 顺序,
* 换成通用组件会把「全部(8)」这类计数排版搞丢。 * 与架构树的院区顺序一致),再在该院区的科室里筛。院区不做「全部院区」——
* 架构树里也始终落在某一个院区上,两处口径保持一致。
*
* 类名沿用原型的 lib-tabs / lib-tab / lib-status / lib-st:这套样式是文档库页面独有的
* 筛选语言(胶囊 + 虚线分隔),与通用 Tabs 组件的观感不同,换成通用组件会把
* 「全部(8)」这类计数排版搞丢。
* *
* 用 <button> 而不是原型的 <span onclick>:保留键盘可达与 aria-selected。 * 用 <button> 而不是原型的 <span onclick>:保留键盘可达与 aria-selected。
*/ */
import type { DepartmentTab, DocumentFilterStatus, StatusTab } from '../types' import type { CampusTab, DepartmentTab, DocumentFilterStatus, StatusTab } from '../types'
defineProps<{ defineProps<{
/** 当前院区 id。院区是页面级的取数口径,由页面持有(见 index.vue) */
campus: string
department: string | null department: string | null
status: DocumentFilterStatus status: DocumentFilterStatus
/** 演示文库没有院区维度,页面会传空数组,这一级整行不渲染 */
campuses: CampusTab[]
departments: DepartmentTab[] departments: DepartmentTab[]
statuses: StatusTab[] statuses: StatusTab[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'select-campus': [value: string]
'update:department': [value: string | null] 'update:department': [value: string | null]
'update:status': [value: DocumentFilterStatus] 'update:status': [value: DocumentFilterStatus]
}>() }>()
@@ -25,40 +35,65 @@ const emit = defineEmits<{
<template> <template>
<div class="lib-filter"> <div class="lib-filter">
<div class="lib-tabs" role="tablist" aria-label="按科室筛选"> <div v-if="campuses.length" class="lib-row">
<button <span class="lib-row__label">院区</span>
v-for="tab in departments" <div class="lib-tabs" role="tablist" aria-label="按院区筛选">
:key="tab.key ?? 'all'" <button
type="button" v-for="tab in campuses"
class="lib-tab" :key="tab.key"
:class="{ on: department === tab.key }" type="button"
:aria-selected="department === tab.key" class="lib-tab"
role="tab" :class="{ on: campus === tab.key }"
@click="emit('update:department', tab.key)" :aria-selected="campus === tab.key"
> role="tab"
{{ tab.label }}({{ tab.count }}) @click="emit('select-campus', tab.key)"
</button> >
{{ tab.label }}
</button>
</div>
</div> </div>
<div class="lib-status" role="tablist" aria-label="按状态筛选"> <!-- 没有院区这一级时(演示模式)科室行就是首行,不再加分隔线 -->
<button <div class="lib-row" :class="{ 'lib-row--sub': campuses.length }">
v-for="tab in statuses" <span class="lib-row__label">科室</span>
:key="tab.key" <div class="lib-tabs" role="tablist" aria-label="按科室筛选">
type="button" <button
class="lib-st" v-for="tab in departments"
:class="{ on: status === tab.key }" :key="tab.key ?? 'all'"
:aria-selected="status === tab.key" type="button"
role="tab" class="lib-tab"
@click="emit('update:status', tab.key)" :class="{ on: department === tab.key }"
> :aria-selected="department === tab.key"
{{ tab.label }}<template v-if="tab.count"> · {{ tab.count }}</template> role="tab"
</button> @click="emit('update:department', tab.key)"
>
{{ tab.label }}({{ tab.count }})
</button>
</div>
</div>
<div class="lib-row lib-row--sub">
<span class="lib-row__label">状态</span>
<div class="lib-status" role="tablist" aria-label="按状态筛选">
<button
v-for="tab in statuses"
:key="tab.key"
type="button"
class="lib-st"
:class="{ on: status === tab.key }"
:aria-selected="status === tab.key"
role="tab"
@click="emit('update:status', tab.key)"
>
{{ tab.label }}<template v-if="tab.count"> · {{ tab.count }}</template>
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* 原型把两条页签包在 .card 里(padding:12px 16px; margin-bottom:12px),照抄 */ /* 原型把筛选条包在 .card 里(padding:12px 16px; margin-bottom:12px),照抄 */
.lib-filter { .lib-filter {
padding: 12px 16px; padding: 12px 16px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -67,12 +102,40 @@ const emit = defineEmits<{
box-shadow: var(--sh); box-shadow: var(--sh);
} }
/* 原型 .lib-tabs:flex-wrap + gap 8,块间距靠 margin-bottom 交给外层 */ /*
.lib-tabs { * 每一级筛选占一行:行首一个小标签写明「这一行筛的是什么」。
* 三行胶囊外观相近,不标出来就分不清哪行是院区、哪行是科室。
*/
.lib-row {
display: flex; display: flex;
gap: 10px;
align-items: flex-start;
}
/* 下级与上级之间用一条虚线分开(原型里科室与状态之间就是这条线) */
.lib-row--sub {
padding-top: 10px;
margin-top: 10px;
border-top: 1px dashed var(--line);
}
/* 标签与第一行胶囊(约 29px 高)视觉居中 */
.lib-row__label {
flex: 0 0 auto;
padding-top: 6px;
color: var(--mut);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
.lib-tabs,
.lib-status {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-bottom: 10px; min-width: 0;
} }
.lib-tab { .lib-tab {
@@ -99,15 +162,7 @@ const emit = defineEmits<{
background: var(--brand); background: var(--brand);
} }
/* 原型 .lib-status:与科室行之间用一条虚线分开,表达「同一维度下的第二级筛选」 */ /* 状态是比科室更细的一档,用描边胶囊而不是实底,视觉上轻一级 */
.lib-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 10px;
border-top: 1px dashed var(--line);
}
.lib-st { .lib-st {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -149,8 +149,6 @@ const togglePermission = computed(() =>
历史记录 历史记录
</button> </button>
<span class="ft-spacer" />
<button <button
v-if="toggleAction" v-if="toggleAction"
v-permission="togglePermission" v-permission="togglePermission"
@@ -326,21 +324,18 @@ const togglePermission = computed(() =>
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
} }
/* 按钮组不做左右两侧分栏:从左到右依次排布(预览 / 编辑属性 / 编辑正文 / 历史记录 / 停用 / 发起) */
.tpl-ft { .tpl-ft {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
justify-content: flex-start;
padding-top: 10px; padding-top: 10px;
margin-top: 4px; margin-top: 4px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
/* 把状态动作与发起推到右侧,左侧四个入口(预览/属性/正文/历史)成一组 */
.ft-spacer {
flex: 1 1 auto;
}
.card-button { .card-button {
padding: 4px 9px; padding: 4px 9px;
color: var(--ink); color: var(--ink);
@@ -366,10 +361,4 @@ const togglePermission = computed(() =>
background: var(--brand-d); background: var(--brand-d);
border-color: var(--brand-d); border-color: var(--brand-d);
} }
@media (max-width: 680px) {
.ft-spacer {
display: none;
}
}
</style> </style>
@@ -50,6 +50,7 @@ import {
import { loadDocumentLibrary, saveDocumentLibrary } from './mock' import { loadDocumentLibrary, saveDocumentLibrary } from './mock'
import { DOC_TEMPLATE_PERMISSIONS, workflowPermission } from './permissions' import { DOC_TEMPLATE_PERMISSIONS, workflowPermission } from './permissions'
import type { import type {
CampusTab,
DepartmentTab, DepartmentTab,
DocumentContentForm, DocumentContentForm,
DocumentDepartment, DocumentDepartment,
@@ -76,6 +77,9 @@ const loginStore = useLoginStore()
const useMockLibrary = mockFlags.documents const useMockLibrary = mockFlags.documents
/** 没有绑定科室的文书归到这一组(全院通用),不属于任何一个科室页签 */
const GENERAL_GROUP_KEY = '__all__'
/** /**
* 页面操作的服务端校验兜底。 * 页面操作的服务端校验兜底。
* *
@@ -155,15 +159,67 @@ const allTemplates = computed<DocumentTemplate[]>(() =>
), ),
) )
const departmentTabs = computed<DepartmentTab[]>(() => [ /**
{ key: null, label: '全部', count: allTemplates.value.length }, * 院区页签:与「组织架构」共用同一份院区字典和同一个排序(sortNo),
...library.value.map((department) => ({ * 因此这一行的顺序与架构树里的院区顺序一致。
key: department.key, *
label: department.name, * 演示文库没有院区维度(loadLibrary 直接读本地数据、不看院区),
count: allTemplates.value.filter((template) => template.departmentKey === department.key) * 渲染一行点了不生效的院区页签比不渲染更糟,所以演示模式返回空数组、整行隐藏。
.length, */
})), const campusTabs = computed<CampusTab[]>(() =>
]) useMockLibrary
? []
: [...campuses.value]
.filter((campus) => campus.status !== 'DISABLED')
.sort(
(left, right) =>
left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
)
.map((campus) => ({ key: campus.id, label: campus.name })),
)
/**
* 科室页签:**与「组织架构」同一份字典** —— 当前院区下的全部科室,按 sortNo 排。
*
* 刻意不按「有没有文书」过滤:两个页面的科室清单必须一致。组织架构里有的科室在
* 文档库里查不到,科室会以为文书库漏了;反过来只显示有文书的科室,也看不出
* 「这个科室还没建过文书」。
*
* 「全院通用」**不是科室**(它不挂在任何院区下,是「没有归属科室」的文书),
* 因此不管当前选中哪个院区都必须有,固定排在「全部」之后。
*
* 字典里查不到的孤儿分组(科室被删掉、演示文库的占位键)补在最后 —— 不补的话
* 这些文书只能在「全部」里看到,点不到具体科室。
*/
const departmentTabs = computed<DepartmentTab[]>(() => {
const countOf = (key: string) =>
allTemplates.value.filter((template) => template.departmentKey === key).length
const campusId = appStore.selectedCampusId
const campusDepartments = [...departments.value]
.filter((department) => department.status !== 'DISABLED')
// 演示文库没有院区维度(科室字典的 campusId 是演示值,与全局院区对不上),不按院区过滤
.filter((department) => useMockLibrary || !campusId || department.campusId === campusId)
.sort(
(left, right) => left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
)
const knownKeys = new Set(campusDepartments.map((department) => department.id))
const orphans = library.value.filter(
(group) => group.key !== GENERAL_GROUP_KEY && !knownKeys.has(group.key),
)
return [
{ key: null, label: '全部', count: allTemplates.value.length },
{ key: GENERAL_GROUP_KEY, label: '全院通用', count: countOf(GENERAL_GROUP_KEY) },
...campusDepartments.map((department) => ({
key: department.id,
label: department.name,
count: countOf(department.id),
})),
...orphans.map((group) => ({ key: group.key, label: group.name, count: countOf(group.key) })),
]
})
/** /**
* 状态页签只有两组(启用中 / 停用),外加「全部」。卡片按需显示待发布等具体版本状态; * 状态页签只有两组(启用中 / 停用),外加「全部」。卡片按需显示待发布等具体版本状态;
@@ -231,7 +287,7 @@ function groupDocuments(
) )
records.forEach((record) => { records.forEach((record) => {
const departmentKey = record.departmentId ?? '__all__' const departmentKey = record.departmentId ?? GENERAL_GROUP_KEY
const departmentName = const departmentName =
record.departmentName ?? record.departmentName ??
(record.departmentId ? (departmentNames.get(record.departmentId) ?? '未知科室') : '全院通用') (record.departmentId ? (departmentNames.get(record.departmentId) ?? '未知科室') : '全院通用')
@@ -256,11 +312,11 @@ function groupDocuments(
// 全院通用的模板排最前,其余按科室名排序,保证每次进页面分组顺序稳定 // 全院通用的模板排最前,其余按科室名排序,保证每次进页面分组顺序稳定
return [...grouped.values()].sort((left, right) => { return [...grouped.values()].sort((left, right) => {
if (left.key === '__all__') { if (left.key === GENERAL_GROUP_KEY) {
return -1 return -1
} }
if (right.key === '__all__') { if (right.key === GENERAL_GROUP_KEY) {
return 1 return 1
} }
@@ -500,7 +556,7 @@ function addLocalTemplate(form: DocumentPropsForm, departmentId: string | null):
const department = departments.value.find((item) => item.id === departmentId) const department = departments.value.find((item) => item.id === departmentId)
const departmentName = department?.name ?? '全院通用' const departmentName = department?.name ?? '全院通用'
// 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义 // 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义
const departmentKey = department?.id ?? '__all__' const departmentKey = department?.id ?? GENERAL_GROUP_KEY
let departmentGroup = library.value.find((item) => item.key === departmentKey) let departmentGroup = library.value.find((item) => item.key === departmentKey)
if (!departmentGroup) { if (!departmentGroup) {
@@ -558,8 +614,8 @@ function addLocalTemplate(form: DocumentPropsForm, departmentId: string | null):
function updateLocalProps(form: DocumentPropsForm, departmentId: string | null) { function updateLocalProps(form: DocumentPropsForm, departmentId: string | null) {
const target = departments.value.find((item) => item.id === departmentId) const target = departments.value.find((item) => item.id === departmentId)
const targetName = target?.name ?? '全院通用' const targetName = target?.name ?? '全院通用'
// 与 addLocalTemplate 同一套分组键口径:演示模式没有 UUID 时落 __all__ // 与 addLocalTemplate 同一套分组键口径:演示模式没有 UUID 时落 GENERAL_GROUP_KEY
const targetKey = target?.id ?? '__all__' const targetKey = target?.id ?? GENERAL_GROUP_KEY
const located = locateLocalDocument(form.id ?? '') const located = locateLocalDocument(form.id ?? '')
if (!located) { if (!located) {
@@ -1077,6 +1133,23 @@ function clearFilters() {
filters.department = null filters.department = null
// status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」 // status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」
filters.status = 'all' filters.status = 'all'
// 院区不在这里重置:它是页面的取数口径(文书库按院区向服务端取数),
// 不是「筛掉一部分已加载数据」的筛选条件,清掉它得重新拉一遍文库
}
/**
* 切换院区。
*
* 走全局院区状态而不是页面自己存一份:右上角的院区选择器读的就是这个值,
* 两处共用一份状态,就不会出现「右上角写着本部院区、筛选条却选中东院区」。
* 重新取数由下面的 watch 负责,这里只改状态。
*/
function handleCampusSelect(campusId: string) {
if (campusId === appStore.selectedCampusId) {
return
}
appStore.selectCampus(campusId)
} }
function useTemplate(template: DocumentTemplate) { function useTemplate(template: DocumentTemplate) {
@@ -1097,8 +1170,16 @@ async function initializePage() {
watch( watch(
() => appStore.selectedCampusId, () => appStore.selectedCampusId,
(campusId, previousCampusId) => { (campusId, previousCampusId) => {
// 演示模式没有院区维度,切换院区不需要重新拉文库 if (!campusId || campusId === previousCampusId) {
if (useMockLibrary || !campusId || !previousCampusId || campusId === previousCampusId) { return
}
// 院区变了,原先选中的科室不一定属于新院区,清掉以免筛出一片空结果
filters.department = null
// 演示模式没有院区维度;首次进页面时院区从空值变成第一个院区,
// 那一次由 initializePage 负责取数,这里跳过,避免重复拉一遍文库
if (useMockLibrary || !previousCampusId) {
return return
} }
@@ -1125,8 +1206,11 @@ onMounted(() => {
<DocumentFilterTabs <DocumentFilterTabs
v-model:department="filters.department" v-model:department="filters.department"
v-model:status="filters.status" v-model:status="filters.status"
:campus="appStore.selectedCampusId"
:campuses="campusTabs"
:departments="departmentTabs" :departments="departmentTabs"
:statuses="statusTabs" :statuses="statusTabs"
@select-campus="handleCampusSelect"
/> />
<p v-if="libraryLoading" class="library-state">正在加载文书库…</p> <p v-if="libraryLoading" class="library-state">正在加载文书库…</p>
@@ -1153,7 +1237,8 @@ onMounted(() => {
<div v-else class="empty-state"> <div v-else class="empty-state">
<div class="empty-icon" aria-hidden="true">⌕</div> <div class="empty-icon" aria-hidden="true">⌕</div>
<template v-if="allTemplates.length === 0"> <template v-if="allTemplates.length === 0">
<p>文书库为空,点击右上角「新增文档」创建第一份文书</p> <!-- 文库按院区取数,空的是「当前院区」而不是整个文书库,别让科室以为全院都没文书 -->
<p>当前院区暂无文书,点击右上角「新增文档」创建第一份文书</p>
</template> </template>
<template v-else> <template v-else>
<p>没有符合条件的文书</p> <p>没有符合条件的文书</p>
@@ -96,6 +96,18 @@ export interface DepartmentTab {
count: number count: number
} }
/**
* 院区筛选页签。
*
* 与科室页签不同,院区**不带计数**:文书库是按院区向服务端取数的,手上只有当前院区的
* 文书,给别的院区标一个数字必然是假的。院区也不提供「全部院区」——
* 口径与「组织架构」一致,任何时刻都落在某一个院区上。
*/
export interface CampusTab {
key: string
label: string
}
/** /**
* 历史记录的一行(原型 openDocHistory 的表格:版本 / 操作人 / 时间 / 操作 / 说明)。 * 历史记录的一行(原型 openDocHistory 的表格:版本 / 操作人 / 时间 / 操作 / 说明)。
* *