refactor(签署工作台): 按原型精简筛选条为日期/状态/搜索并改为输入即筛

This commit is contained in:
yelan
2026-09-27 09:27:26 +08:00
parent 3cb174ce0c
commit cc1ef72320
3 changed files with 126 additions and 143 deletions
@@ -1,18 +1,11 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { WorkbenchCampus, VisitType } from '@/api/workbench/types'
import { nextTick, onBeforeUnmount, reactive, watch } from 'vue'
import type { SigningFilterForm, SigningFilterOption } from '../types'
const props = defineProps<{
filter: SigningFilterForm
campuses: SigningFilterOption<WorkbenchCampus | 'all'>[]
statuses: SigningFilterOption<SigningFilterForm['status']>[]
methods: SigningFilterOption<SigningFilterForm['method']>[]
departments: SigningFilterOption[]
documents: SigningFilterOption[]
visitTypes: SigningFilterOption<VisitType | 'all'>[]
canCreate: boolean
}>()
@@ -21,23 +14,85 @@ const emit = defineEmits<{
add: []
}>()
/**
* 关键词的防抖时长。
*
* 原型 V10.2 整条筛选条是「输入即筛」(`#fKw` 的 `oninput` + 各 select 的 `onchange`),
* 仓库按同一口径改:下拉是离散取值,改完立刻查;关键词是逐字输入的,
* 不防抖会一个字发一次请求,所以只给它加这一档延迟。
*/
const KEYWORD_DEBOUNCE_MS = 300
const draft = reactive<SigningFilterForm>({ ...props.filter })
let keywordTimer: ReturnType<typeof setTimeout> | null = null
/**
* 父级推下来的筛选(带 query 直达、院区联动)不应再回抛一次 search。
*
* 父级改完 filter 已经自己查过一次了,这里再抛一次会让同一次变更连发两次列表请求。
*/
let syncingFromProps = false
function cancelKeywordTimer() {
if (keywordTimer !== null) {
clearTimeout(keywordTimer)
keywordTimer = null
}
}
/** 立即查询(下拉改动、点「查询」、输入框回车都走这里) */
function searchNow() {
cancelKeywordTimer()
emit('search', { ...draft })
}
function searchKeywordLater() {
cancelKeywordTimer()
keywordTimer = setTimeout(() => {
keywordTimer = null
emit('search', { ...draft })
}, KEYWORD_DEBOUNCE_MS)
}
watch(
() => props.filter,
(value) => {
syncingFromProps = true
Object.assign(draft, value)
// draft 的 watcher 与本回调在同一轮 flush 里排队,会先于 nextTick 执行 → 标记有效
void nextTick(() => {
syncingFromProps = false
})
},
{ deep: true },
)
function submit() {
emit('search', { ...draft })
}
/** 下拉类筛选:原型 `onchange="renderTasks()"`,改完即查 */
watch(
() => [draft.dateRange, draft.status],
() => {
if (!syncingFromProps) {
searchNow()
}
},
)
/** 关键词:原型 `oninput="renderTasks()"`,防抖后查 */
watch(
() => draft.keyword,
() => {
if (!syncingFromProps) {
searchKeywordLater()
}
},
)
onBeforeUnmount(cancelKeywordTimer)
</script>
<template>
<form class="filter-card" @submit.prevent="submit">
<form class="filter-card" @submit.prevent="searchNow">
<label class="filter-field">
<span>日期</span>
<select v-model="draft.dateRange" aria-label="筛选日期">
@@ -58,54 +113,14 @@ function submit() {
</select>
</label>
<label class="filter-field">
<span>院区</span>
<select v-model="draft.campus" aria-label="筛选院区">
<option v-for="option in campuses" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>方式</span>
<select v-model="draft.method" aria-label="筛选签署方式">
<option v-for="option in methods" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>科室</span>
<select v-model="draft.department" aria-label="筛选科室">
<option v-for="option in departments" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>就诊类型</span>
<select v-model="draft.visitType" aria-label="筛选就诊类型">
<option v-for="option in visitTypes" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field filter-field--document">
<span>文档</span>
<select v-model="draft.documentId" aria-label="筛选文档">
<option v-for="option in documents" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<!-- 原型 V10.2 工作台搜索框:无可见标签,🔍 内嵌在输入框里 -->
<label class="filter-field filter-field--keyword">
<span>关键词</span>
<input v-model="draft.keyword" placeholder="患者姓名 / ID / 文档名" aria-label="关键词" />
<span class="filter-field__icon" aria-hidden="true">🔍</span>
<input
v-model="draft.keyword"
placeholder="搜索:患者姓名 / ID / 文档名 / 任务号"
aria-label="搜索:患者姓名 / ID / 文档名 / 任务号"
/>
</label>
<span class="filter-spacer" />
@@ -155,12 +170,24 @@ function submit() {
border-color: var(--brand);
}
.filter-field--document select {
max-width: 150px;
/* 原型 V10.2:搜索框 250px 宽、左侧留 30px 给内嵌的 🔍 图标 */
.filter-field--keyword {
position: relative;
}
.filter-field--keyword input {
width: 190px;
width: 250px;
padding-left: 30px;
}
.filter-field__icon {
position: absolute;
left: 9px;
top: 50%;
z-index: 1;
font-size: 13px;
transform: translateY(-50%);
pointer-events: none;
}
.filter-spacer {
@@ -33,19 +33,13 @@ import type {
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
WorkbenchCampus,
} from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import {
formatSigningMethodTitle,
readSigningMethod,
toggleSigningMethod,
WORKBENCH_METHOD_FILTERS,
} from '@/utils/signing-method'
import { formatSigningMethodTitle, readSigningMethod, toggleSigningMethod } from '@/utils/signing-method'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
@@ -61,15 +55,6 @@ const route = useRoute()
const appStore = useAppStore()
const loginStore = useLoginStore()
const STATUS_VALUES: SigningTaskStatus[] = [
'pending',
'signing',
'confirming',
'signed',
'expired',
'void',
'failed',
]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
const statusLabels: Record<SigningTaskStatus, string> = {
@@ -86,11 +71,6 @@ const filter = reactive<SigningFilterForm>({
keyword: '',
status: 'all',
dateRange: 'all',
campus: 'all',
method: 'all',
department: '',
documentId: '',
visitType: 'all',
})
const tasks = ref<SigningTaskRecord[]>([])
@@ -162,46 +142,27 @@ const canViewEvents = computed(() => canRead('sign:task:event:query'))
const canViewArtifacts = computed(() => canRead('sign:artifact:query'))
const canDownloadArtifacts = computed(() => canRead('sign:artifact:download'))
const campusOptions = computed<SigningFilterOption<WorkbenchCampus | 'all'>[]>(() => [
{ label: '全部院区', value: 'all' },
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
])
/**
* 状态筛选的取值:原型 `#fStat` 只有 6 项
* (全部 / 待签署 / 待科室确认 / 已签署 / 已超时 / 已作废)。
*
* 仓库另有 `signing`(签署中)与 `failed`(处理失败)两个状态 —— 它们仍会正常渲染徽标,
* 只是原型没有对应筛选项,所以不进下拉;路由 query 里出现这两个值也按「全部」处理,
* 否则下拉会选中一个不存在的 option、显示成空白。
*/
const STATUS_FILTER_VALUES: SigningTaskStatus[] = [
'pending',
'confirming',
'signed',
'expired',
'void',
]
const statusOptions: SigningFilterOption<SigningFilterForm['status']>[] = [
{ label: '全部状态', value: 'all' },
...STATUS_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
{ label: '全部', value: 'all' },
...STATUS_FILTER_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
]
/** 原型 #qMethod 只有 3 项(手写板 / 短信线上 / Pad),没有公众号 */
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
{ label: '全部方式', value: 'all' },
...WORKBENCH_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
]
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
{ label: '全部类型', value: 'all' },
{ label: '门诊', value: '门诊' },
{ label: '住院', value: '住院' },
{ label: '体检', value: '体检' },
]
const departmentOptions = computed<SigningFilterOption[]>(() => {
const departments = new Set([
...templates.value.map((template) => template.department),
...tasks.value.map((task) => task.department),
])
return [
{ label: '全部科室', value: '' },
...[...departments].sort().map((department) => ({ label: department, value: department })),
]
})
const documentOptions = computed<SigningFilterOption[]>(() => [
{ label: '全部文档', value: '' },
...templates.value.map((template) => ({ label: template.name, value: template.id })),
])
const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus,
campusId: campusIds.value[appStore.selectedCampus],
@@ -216,25 +177,25 @@ function getQueryString(value: unknown) {
}
function isSigningStatus(value: string): value is SigningTaskStatus {
return STATUS_VALUES.includes(value as SigningTaskStatus)
return STATUS_FILTER_VALUES.includes(value as SigningTaskStatus)
}
function isSigningDateRange(value: string): value is SigningDateRange {
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
}
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
return appStore.campuses.includes(value)
}
/**
* 路由 query → 搜索条件。
*
* 只认 `status` 与 `range`:原型工作台顶部就这两个条件,
* `campus` 由右上角的全局院区选择器统一约束(见 `signingApiOptions`),不再从 query 取。
*/
function syncFilterFromRoute() {
const status = getQueryString(route.query.status)
const range = getQueryString(route.query.range)
const campus = getQueryString(route.query.campus)
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
filter.dateRange = isSigningDateRange(range) ? range : 'all'
filter.campus = isWorkbenchCampus(campus) ? campus : 'all'
}
async function loadTemplates() {
@@ -919,7 +880,7 @@ async function handleTaskCreated(task: SigningTaskRecord) {
}
watch(
() => [route.query.status, route.query.range, route.query.campus, route.query.taskId],
() => [route.query.status, route.query.range, route.query.taskId],
() => {
syncFilterFromRoute()
page.value = 1
@@ -928,12 +889,10 @@ watch(
},
)
/** 切换全局院区:列表跟着换(院区不再是筛选条里的一个下拉,见 syncFilterFromRoute) */
watch(
() => appStore.selectedCampus,
(campus) => {
if (filter.campus !== 'all') {
filter.campus = campus
}
() => {
page.value = 1
void loadTasks()
},
@@ -959,12 +918,7 @@ onMounted(() => {
<SigningFilterBar
:filter="filter"
:campuses="campusOptions"
:statuses="statusOptions"
:methods="methodOptions"
:departments="departmentOptions"
:documents="documentOptions"
:visit-types="visitTypeOptions"
:can-create="canCreateTask"
@add="newDialogVisible = true"
@search="handleSearch"
@@ -7,21 +7,23 @@ import type {
SigningTaskRecord,
SigningTaskStatus,
SigningTemplate,
VisitType,
WorkbenchCampus,
} from '@/api/workbench/types'
export type SigningFilterStatus = 'all' | SigningTaskStatus
/**
* 工作台顶部的搜索条件。
*
* **只有三项**:原型 V10.2 的 `#sc-wb` 顶部 `.bar` 就是「日期 + 状态 + 搜索框」。
* 仓库曾多出 院区 / 方式 / 科室 / 就诊类型 / 文档 五个下拉(第一版实现留下的),
* 2026-09-27 按原型去掉 —— 院区由右上角的全局院区选择器统一约束,
* 其余维度在「签署记录查询」页按各自的筛选条检索。
* API 层 `SigningTaskQuery` 仍保留这些字段(记录查询页要用),这里只是不再从工作台传入。
*/
export interface SigningFilterForm {
keyword: string
status: SigningFilterStatus
dateRange: SigningDateRange
campus: WorkbenchCampus | 'all'
method: SigningMethod | 'all'
department: string
documentId: string
visitType: VisitType | 'all'
}
export type SigningTableRow = SigningTaskRecord