feat(documents): 文书库筛选条按院区-科室-状态三级重构
This commit is contained in:
@@ -1,23 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文书库的两级筛选条(原型 V10.2 的 #libTabs / #libStatus)。
|
||||
* 文书库的分级筛选条:院区 → 科室 → 状态。
|
||||
*
|
||||
* 类名刻意沿用原型的 lib-tabs / lib-tab / lib-status / lib-st:这套样式在原型里
|
||||
* 是文档库页面独有的筛选语言(胶囊 + 虚线分隔),与通用 Tabs 组件的观感不同,
|
||||
* 换成通用组件会把「全部(8)」这类计数排版搞丢。
|
||||
* 原型 V10.2 只有「科室 + 状态」两行(#libTabs / #libStatus)。这里把科室那一级按
|
||||
* 「组织架构」的口径拆成两级:先选院区(同一份院区字典、同一个 sortNo 顺序,
|
||||
* 与架构树的院区顺序一致),再在该院区的科室里筛。院区不做「全部院区」——
|
||||
* 架构树里也始终落在某一个院区上,两处口径保持一致。
|
||||
*
|
||||
* 类名沿用原型的 lib-tabs / lib-tab / lib-status / lib-st:这套样式是文档库页面独有的
|
||||
* 筛选语言(胶囊 + 虚线分隔),与通用 Tabs 组件的观感不同,换成通用组件会把
|
||||
* 「全部(8)」这类计数排版搞丢。
|
||||
*
|
||||
* 用 <button> 而不是原型的 <span onclick>:保留键盘可达与 aria-selected。
|
||||
*/
|
||||
import type { DepartmentTab, DocumentFilterStatus, StatusTab } from '../types'
|
||||
import type { CampusTab, DepartmentTab, DocumentFilterStatus, StatusTab } from '../types'
|
||||
|
||||
defineProps<{
|
||||
/** 当前院区 id。院区是页面级的取数口径,由页面持有(见 index.vue) */
|
||||
campus: string
|
||||
department: string | null
|
||||
status: DocumentFilterStatus
|
||||
/** 演示文库没有院区维度,页面会传空数组,这一级整行不渲染 */
|
||||
campuses: CampusTab[]
|
||||
departments: DepartmentTab[]
|
||||
statuses: StatusTab[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-campus': [value: string]
|
||||
'update:department': [value: string | null]
|
||||
'update:status': [value: DocumentFilterStatus]
|
||||
}>()
|
||||
@@ -25,40 +35,65 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<div class="lib-filter">
|
||||
<div class="lib-tabs" role="tablist" aria-label="按科室筛选">
|
||||
<button
|
||||
v-for="tab in departments"
|
||||
:key="tab.key ?? 'all'"
|
||||
type="button"
|
||||
class="lib-tab"
|
||||
:class="{ on: department === tab.key }"
|
||||
:aria-selected="department === tab.key"
|
||||
role="tab"
|
||||
@click="emit('update:department', tab.key)"
|
||||
>
|
||||
{{ tab.label }}({{ tab.count }})
|
||||
</button>
|
||||
<div v-if="campuses.length" class="lib-row">
|
||||
<span class="lib-row__label">院区</span>
|
||||
<div class="lib-tabs" role="tablist" aria-label="按院区筛选">
|
||||
<button
|
||||
v-for="tab in campuses"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="lib-tab"
|
||||
:class="{ on: campus === tab.key }"
|
||||
:aria-selected="campus === tab.key"
|
||||
role="tab"
|
||||
@click="emit('select-campus', tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 class="lib-row" :class="{ 'lib-row--sub': campuses.length }">
|
||||
<span class="lib-row__label">科室</span>
|
||||
<div class="lib-tabs" role="tablist" aria-label="按科室筛选">
|
||||
<button
|
||||
v-for="tab in departments"
|
||||
:key="tab.key ?? 'all'"
|
||||
type="button"
|
||||
class="lib-tab"
|
||||
:class="{ on: department === tab.key }"
|
||||
:aria-selected="department === tab.key"
|
||||
role="tab"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 原型把两条页签包在 .card 里(padding:12px 16px; margin-bottom:12px),照抄 */
|
||||
/* 原型把筛选条包在 .card 里(padding:12px 16px; margin-bottom:12px),照抄 */
|
||||
.lib-filter {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
@@ -67,12 +102,40 @@ const emit = defineEmits<{
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
/* 原型 .lib-tabs:flex-wrap + gap 8,块间距靠 margin-bottom 交给外层 */
|
||||
.lib-tabs {
|
||||
/*
|
||||
* 每一级筛选占一行:行首一个小标签写明「这一行筛的是什么」。
|
||||
* 三行胶囊外观相近,不标出来就分不清哪行是院区、哪行是科室。
|
||||
*/
|
||||
.lib-row {
|
||||
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;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lib-tab {
|
||||
@@ -99,15 +162,7 @@ const emit = defineEmits<{
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -149,8 +149,6 @@ const togglePermission = computed(() =>
|
||||
历史记录
|
||||
</button>
|
||||
|
||||
<span class="ft-spacer" />
|
||||
|
||||
<button
|
||||
v-if="toggleAction"
|
||||
v-permission="togglePermission"
|
||||
@@ -326,21 +324,18 @@ const togglePermission = computed(() =>
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/* 按钮组不做左右两侧分栏:从左到右依次排布(预览 / 编辑属性 / 编辑正文 / 历史记录 / 停用 / 发起) */
|
||||
.tpl-ft {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-top: 10px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* 把状态动作与发起推到右侧,左侧四个入口(预览/属性/正文/历史)成一组 */
|
||||
.ft-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
padding: 4px 9px;
|
||||
color: var(--ink);
|
||||
@@ -366,10 +361,4 @@ const togglePermission = computed(() =>
|
||||
background: var(--brand-d);
|
||||
border-color: var(--brand-d);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.ft-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { loadDocumentLibrary, saveDocumentLibrary } from './mock'
|
||||
import { DOC_TEMPLATE_PERMISSIONS, workflowPermission } from './permissions'
|
||||
import type {
|
||||
CampusTab,
|
||||
DepartmentTab,
|
||||
DocumentContentForm,
|
||||
DocumentDepartment,
|
||||
@@ -76,6 +77,9 @@ const loginStore = useLoginStore()
|
||||
|
||||
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 },
|
||||
...library.value.map((department) => ({
|
||||
key: department.key,
|
||||
label: department.name,
|
||||
count: allTemplates.value.filter((template) => template.departmentKey === department.key)
|
||||
.length,
|
||||
})),
|
||||
])
|
||||
/**
|
||||
* 院区页签:与「组织架构」共用同一份院区字典和同一个排序(sortNo),
|
||||
* 因此这一行的顺序与架构树里的院区顺序一致。
|
||||
*
|
||||
* 演示文库没有院区维度(loadLibrary 直接读本地数据、不看院区),
|
||||
* 渲染一行点了不生效的院区页签比不渲染更糟,所以演示模式返回空数组、整行隐藏。
|
||||
*/
|
||||
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) => {
|
||||
const departmentKey = record.departmentId ?? '__all__'
|
||||
const departmentKey = record.departmentId ?? GENERAL_GROUP_KEY
|
||||
const departmentName =
|
||||
record.departmentName ??
|
||||
(record.departmentId ? (departmentNames.get(record.departmentId) ?? '未知科室') : '全院通用')
|
||||
@@ -256,11 +312,11 @@ function groupDocuments(
|
||||
|
||||
// 全院通用的模板排最前,其余按科室名排序,保证每次进页面分组顺序稳定
|
||||
return [...grouped.values()].sort((left, right) => {
|
||||
if (left.key === '__all__') {
|
||||
if (left.key === GENERAL_GROUP_KEY) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (right.key === '__all__') {
|
||||
if (right.key === GENERAL_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -500,7 +556,7 @@ function addLocalTemplate(form: DocumentPropsForm, departmentId: string | null):
|
||||
const department = departments.value.find((item) => item.id === departmentId)
|
||||
const departmentName = department?.name ?? '全院通用'
|
||||
// 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义
|
||||
const departmentKey = department?.id ?? '__all__'
|
||||
const departmentKey = department?.id ?? GENERAL_GROUP_KEY
|
||||
let departmentGroup = library.value.find((item) => item.key === departmentKey)
|
||||
|
||||
if (!departmentGroup) {
|
||||
@@ -558,8 +614,8 @@ function addLocalTemplate(form: DocumentPropsForm, departmentId: string | null):
|
||||
function updateLocalProps(form: DocumentPropsForm, departmentId: string | null) {
|
||||
const target = departments.value.find((item) => item.id === departmentId)
|
||||
const targetName = target?.name ?? '全院通用'
|
||||
// 与 addLocalTemplate 同一套分组键口径:演示模式没有 UUID 时落 __all__
|
||||
const targetKey = target?.id ?? '__all__'
|
||||
// 与 addLocalTemplate 同一套分组键口径:演示模式没有 UUID 时落 GENERAL_GROUP_KEY
|
||||
const targetKey = target?.id ?? GENERAL_GROUP_KEY
|
||||
const located = locateLocalDocument(form.id ?? '')
|
||||
|
||||
if (!located) {
|
||||
@@ -1077,6 +1133,23 @@ function clearFilters() {
|
||||
filters.department = null
|
||||
// status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」
|
||||
filters.status = 'all'
|
||||
// 院区不在这里重置:它是页面的取数口径(文书库按院区向服务端取数),
|
||||
// 不是「筛掉一部分已加载数据」的筛选条件,清掉它得重新拉一遍文库
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换院区。
|
||||
*
|
||||
* 走全局院区状态而不是页面自己存一份:右上角的院区选择器读的就是这个值,
|
||||
* 两处共用一份状态,就不会出现「右上角写着本部院区、筛选条却选中东院区」。
|
||||
* 重新取数由下面的 watch 负责,这里只改状态。
|
||||
*/
|
||||
function handleCampusSelect(campusId: string) {
|
||||
if (campusId === appStore.selectedCampusId) {
|
||||
return
|
||||
}
|
||||
|
||||
appStore.selectCampus(campusId)
|
||||
}
|
||||
|
||||
function useTemplate(template: DocumentTemplate) {
|
||||
@@ -1097,8 +1170,16 @@ async function initializePage() {
|
||||
watch(
|
||||
() => appStore.selectedCampusId,
|
||||
(campusId, previousCampusId) => {
|
||||
// 演示模式没有院区维度,切换院区不需要重新拉文库
|
||||
if (useMockLibrary || !campusId || !previousCampusId || campusId === previousCampusId) {
|
||||
if (!campusId || campusId === previousCampusId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 院区变了,原先选中的科室不一定属于新院区,清掉以免筛出一片空结果
|
||||
filters.department = null
|
||||
|
||||
// 演示模式没有院区维度;首次进页面时院区从空值变成第一个院区,
|
||||
// 那一次由 initializePage 负责取数,这里跳过,避免重复拉一遍文库
|
||||
if (useMockLibrary || !previousCampusId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1125,8 +1206,11 @@ onMounted(() => {
|
||||
<DocumentFilterTabs
|
||||
v-model:department="filters.department"
|
||||
v-model:status="filters.status"
|
||||
:campus="appStore.selectedCampusId"
|
||||
:campuses="campusTabs"
|
||||
:departments="departmentTabs"
|
||||
:statuses="statusTabs"
|
||||
@select-campus="handleCampusSelect"
|
||||
/>
|
||||
|
||||
<p v-if="libraryLoading" class="library-state">正在加载文书库…</p>
|
||||
@@ -1153,7 +1237,8 @@ onMounted(() => {
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon" aria-hidden="true">⌕</div>
|
||||
<template v-if="allTemplates.length === 0">
|
||||
<p>文书库为空,点击右上角「新增文档」创建第一份文书</p>
|
||||
<!-- 文库按院区取数,空的是「当前院区」而不是整个文书库,别让科室以为全院都没文书 -->
|
||||
<p>当前院区暂无文书,点击右上角「新增文档」创建第一份文书</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>没有符合条件的文书</p>
|
||||
|
||||
@@ -96,6 +96,18 @@ export interface DepartmentTab {
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 院区筛选页签。
|
||||
*
|
||||
* 与科室页签不同,院区**不带计数**:文书库是按院区向服务端取数的,手上只有当前院区的
|
||||
* 文书,给别的院区标一个数字必然是假的。院区也不提供「全部院区」——
|
||||
* 口径与「组织架构」一致,任何时刻都落在某一个院区上。
|
||||
*/
|
||||
export interface CampusTab {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史记录的一行(原型 openDocHistory 的表格:版本 / 操作人 / 时间 / 操作 / 说明)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user