refactor(签署工作台): 按原型精简筛选条为日期/状态/搜索并改为输入即筛
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user