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