feat(clinical-web): 新增签署任务弹窗与文档库管理页面

This commit is contained in:
yelan
2026-08-28 15:35:20 +08:00
parent 2de75ed993
commit 1e2ff79b0c
5 changed files with 395 additions and 245 deletions

View File

@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
## 当前状态
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页和医护端整体布局。当前所有业务 View 暂时保留文字占位,登录使用演示认证逻辑,尚未接入真实后端。
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台第一版 Mock 和文档库管理页面。报表、用户权限、文档权限和系统设置仍保留页面骨架;登录和签署流程使用演示数据,尚未接入真实后端。
## 当前路由结构
@@ -108,6 +108,11 @@ ClinicalLayout
页面目录按业务域组织:`auth` 保留登录页;工作台页面位于 `views/workbench`;管理页面位于 `views/management`。每个具体页面目录都有 `index.vue` 入口和 `components` 目录,用于继续拆分当前页面组件。
跨页面复用的业务组件和 composable 不放在具体 View 目录中:
- `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用;
- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态。
## API 与类型约定
API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 下的页面文件,管理页面使用 `api/management` 下的直接文件:

View File

@@ -1,29 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createSigningTask, getPatientProfile, getSigningTemplates } from '@/api/workbench/signing'
import type {
PatientProfile,
PatientVisit,
SigningMethod,
SigningTaskRecord,
SigningTemplate,
} from '@/api/workbench/types'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
import { useAppStore } from '@/stores/app'
interface TemplateCategoryGroup {
name: string
templates: SigningTemplate[]
}
interface TemplateDepartmentGroup {
name: string
categories: TemplateCategoryGroup[]
}
const props = defineProps<{
visible: boolean
initialTemplate?: SigningTemplate | null
}>()
const emit = defineEmits<{
@@ -33,231 +18,53 @@ const emit = defineEmits<{
const appStore = useAppStore()
const patientInput = ref<HTMLInputElement | null>(null)
const patientId = ref('')
const patient = ref<PatientProfile | null>(null)
const visits = ref<PatientVisit[]>([])
const templates = ref<SigningTemplate[]>([])
const documentSearch = ref('')
const selectedTemplateId = ref('')
const selectedVisitId = ref('')
const method = ref<SigningMethod>('pad')
const smsPhone = ref('')
const patientPhoneRevealed = ref(false)
const loadingTemplates = ref(false)
const locating = ref(false)
const submitting = ref(false)
const initialTemplate = computed(() => props.initialTemplate ?? null)
const errors = reactive({
patient: '',
visit: '',
document: '',
phone: '',
const {
documentSearch,
errors,
filteredTemplateGroups,
loadingTemplates,
loadTemplates,
locatePatient,
locating,
maskPhone,
method,
patient,
patientId,
patientPhoneRevealed,
resetForNextTask,
resetForm,
selectMethod,
selectTemplate,
selectVisit,
selectedTemplate,
selectedTemplateId,
selectedVisit,
selectedVisitId,
smsPhone,
submit: submitForm,
submitting,
togglePhone,
visits,
} = useSigningTaskForm({
campus: computed(() => appStore.selectedCampus),
initialTemplate,
})
const selectedVisit = computed(
() => visits.value.find((visit) => visit.id === selectedVisitId.value) ?? null,
)
async function submitTask(keepOpen: boolean) {
try {
const task = await submitForm()
const selectedTemplate = computed(
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
)
const filteredTemplateGroups = computed<TemplateDepartmentGroup[]>(() => {
const keyword = documentSearch.value.trim().toLowerCase()
const grouped = new Map<string, Map<string, SigningTemplate[]>>()
templates.value.forEach((template) => {
const matches =
!keyword ||
[template.name, template.code, template.department, template.category]
.join(' ')
.toLowerCase()
.includes(keyword)
if (!matches) {
if (!task) {
return
}
const categories = grouped.get(template.department) ?? new Map<string, SigningTemplate[]>()
const categoryTemplates = categories.get(template.category) ?? []
categoryTemplates.push(template)
categories.set(template.category, categoryTemplates)
grouped.set(template.department, categories)
})
return [...grouped.entries()].map(([department, categories]) => ({
name: department,
categories: [...categories.entries()].map(([category, categoryTemplates]) => ({
name: category,
templates: categoryTemplates,
})),
}))
})
function clearErrors() {
errors.patient = ''
errors.visit = ''
errors.document = ''
errors.phone = ''
}
function resetForm() {
patientId.value = ''
patient.value = null
visits.value = []
documentSearch.value = ''
selectedTemplateId.value = ''
selectedVisitId.value = ''
method.value = 'pad'
smsPhone.value = ''
patientPhoneRevealed.value = false
clearErrors()
}
async function loadTemplates() {
if (templates.value.length) {
return
}
loadingTemplates.value = true
try {
templates.value = await getSigningTemplates()
} catch {
ElMessage.error('文档模板加载失败,请稍后重试')
} finally {
loadingTemplates.value = false
}
}
async function locatePatient() {
const keyword = patientId.value.trim()
errors.patient = ''
errors.visit = ''
patient.value = null
visits.value = []
selectedVisitId.value = ''
selectedTemplateId.value = ''
if (!keyword) {
errors.patient = '请输入患者 ID、门诊号或住院号'
return
}
locating.value = true
try {
const profile = await getPatientProfile(keyword)
if (!profile) {
errors.patient = '未找到该患者,请核对输入内容'
smsPhone.value = ''
return
}
patient.value = profile
visits.value = profile.visits
selectedVisitId.value =
profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? ''
smsPhone.value = profile.phone
} catch {
errors.patient = '患者信息查询失败,请稍后重试'
} finally {
locating.value = false
}
}
function selectVisit(visit: PatientVisit) {
selectedVisitId.value = visit.id
errors.visit = ''
}
function selectTemplate(template: SigningTemplate) {
selectedTemplateId.value = template.id
errors.document = ''
if (!template.supportedMethods.includes(method.value)) {
method.value = template.supportedMethods[0] ?? 'pad'
}
}
function selectMethod(nextMethod: SigningMethod) {
if (selectedTemplate.value && !selectedTemplate.value.supportedMethods.includes(nextMethod)) {
return
}
method.value = nextMethod
errors.phone = ''
}
function maskPhone(value: string) {
if (value.length < 7) {
return value
}
return `${value.slice(0, 3)}****${value.slice(-4)}`
}
function togglePhone() {
patientPhoneRevealed.value = !patientPhoneRevealed.value
ElMessage.info('明文查看已记录审计日志')
}
function validate() {
clearErrors()
let valid = true
if (!patient.value) {
errors.patient = '请先定位患者'
valid = false
}
if (!selectedVisit.value) {
errors.visit = '请选择关联就诊'
valid = false
}
if (!selectedTemplate.value) {
errors.document = '请至少选择一份文档'
valid = false
}
if (method.value === 'sms' && !smsPhone.value.trim()) {
errors.phone = '请确认短信接收手机号'
valid = false
}
return valid
}
async function submit(keepOpen: boolean) {
if (!validate() || !patient.value || !selectedVisit.value || !selectedTemplate.value) {
return
}
submitting.value = true
try {
const task = await createSigningTask({
campus: appStore.selectedCampus,
patientId: patient.value.id,
visitId: selectedVisit.value.id,
documentId: selectedTemplate.value.id,
documentName: selectedTemplate.value.name,
method: method.value,
visitType: selectedVisit.value.type,
visitNo: selectedVisit.value.visitNo,
department: selectedVisit.value.department,
phone: method.value === 'sms' ? smsPhone.value.trim() : undefined,
})
emit('created', task)
ElMessage.success(`已创建 1 个签署任务${method.value === 'sms' ? ',短信已发送' : ''}`)
ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',短信已发送' : ''}`)
if (keepOpen) {
selectedTemplateId.value = ''
documentSearch.value = ''
errors.document = ''
resetForNextTask()
await nextTick()
patientInput.value?.focus()
return
@@ -266,8 +73,6 @@ async function submit(keepOpen: boolean) {
emit('close')
} catch {
ElMessage.error('签署任务创建失败,请稍后重试')
} finally {
submitting.value = false
}
}
@@ -509,11 +314,16 @@ watch(
type="button"
class="action-button action-button--ghost"
:disabled="submitting"
@click="submit(true)"
@click="submitTask(true)"
>
提交并继续新增
</button>
<button type="button" class="action-button" :disabled="submitting" @click="submit(false)">
<button
type="button"
class="action-button"
:disabled="submitting"
@click="submitTask(false)"
>
{{ submitting ? '提交中…' : '提交任务' }}
</button>
</footer>

View File

@@ -0,0 +1,307 @@
import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { ElMessage } from 'element-plus'
import { createSigningTask, getPatientProfile, getSigningTemplates } from '@/api/workbench/signing'
import type {
PatientProfile,
PatientVisit,
SigningMethod,
SigningTaskRecord,
SigningTemplate,
WorkbenchCampus,
} from '@/api/workbench/types'
export interface UseSigningTaskFormOptions {
campus: MaybeRefOrGetter<WorkbenchCampus>
initialTemplate?: MaybeRefOrGetter<SigningTemplate | null | undefined>
}
interface TemplateCategoryGroup {
name: string
templates: SigningTemplate[]
}
interface TemplateDepartmentGroup {
name: string
categories: TemplateCategoryGroup[]
}
export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
const patientId = ref('')
const patient = ref<PatientProfile | null>(null)
const visits = ref<PatientVisit[]>([])
const templates = ref<SigningTemplate[]>([])
const documentSearch = ref('')
const selectedTemplateId = ref('')
const selectedVisitId = ref('')
const method = ref<SigningMethod>('pad')
const smsPhone = ref('')
const patientPhoneRevealed = ref(false)
const loadingTemplates = ref(false)
const locating = ref(false)
const submitting = ref(false)
const errors = reactive({
patient: '',
visit: '',
document: '',
phone: '',
})
const selectedVisit = computed(
() => visits.value.find((visit) => visit.id === selectedVisitId.value) ?? null,
)
const selectedTemplate = computed(
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
)
const filteredTemplateGroups = computed<TemplateDepartmentGroup[]>(() => {
const keyword = documentSearch.value.trim().toLowerCase()
const grouped = new Map<string, Map<string, SigningTemplate[]>>()
templates.value.forEach((template) => {
const matches =
!keyword ||
[template.name, template.code, template.department, template.category]
.join(' ')
.toLowerCase()
.includes(keyword)
if (!matches) {
return
}
const categories = grouped.get(template.department) ?? new Map<string, SigningTemplate[]>()
const categoryTemplates = categories.get(template.category) ?? []
categoryTemplates.push(template)
categories.set(template.category, categoryTemplates)
grouped.set(template.department, categories)
})
return [...grouped.entries()].map(([department, categories]) => ({
name: department,
categories: [...categories.entries()].map(([category, categoryTemplates]) => ({
name: category,
templates: categoryTemplates,
})),
}))
})
function getInitialTemplate() {
return options.initialTemplate ? (toValue(options.initialTemplate) ?? null) : null
}
function includeInitialTemplate() {
const initialTemplate = getInitialTemplate()
if (
initialTemplate &&
!templates.value.some((template) => template.id === initialTemplate.id)
) {
templates.value = [initialTemplate, ...templates.value]
}
}
function clearErrors() {
errors.patient = ''
errors.visit = ''
errors.document = ''
errors.phone = ''
}
function resetForm() {
const initialTemplate = getInitialTemplate()
patientId.value = ''
patient.value = null
visits.value = []
documentSearch.value = ''
selectedTemplateId.value = initialTemplate?.id ?? ''
selectedVisitId.value = ''
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
smsPhone.value = ''
patientPhoneRevealed.value = false
clearErrors()
}
function resetForNextTask() {
const initialTemplate = getInitialTemplate()
documentSearch.value = ''
selectedTemplateId.value = initialTemplate?.id ?? ''
method.value = initialTemplate?.supportedMethods[0] ?? method.value
errors.document = ''
}
async function loadTemplates() {
if (!templates.value.length) {
loadingTemplates.value = true
try {
templates.value = await getSigningTemplates()
} catch {
ElMessage.error('文档模板加载失败,请稍后重试')
} finally {
loadingTemplates.value = false
}
}
includeInitialTemplate()
}
async function locatePatient() {
const keyword = patientId.value.trim()
errors.patient = ''
errors.visit = ''
patient.value = null
visits.value = []
selectedVisitId.value = ''
selectedTemplateId.value = getInitialTemplate()?.id ?? ''
if (!keyword) {
errors.patient = '请输入患者 ID、门诊号或住院号'
return
}
locating.value = true
try {
const profile = await getPatientProfile(keyword)
if (!profile) {
errors.patient = '未找到该患者,请核对输入内容'
smsPhone.value = ''
return
}
patient.value = profile
visits.value = profile.visits
selectedVisitId.value =
profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? ''
smsPhone.value = profile.phone
} catch {
errors.patient = '患者信息查询失败,请稍后重试'
} finally {
locating.value = false
}
}
function selectVisit(visit: PatientVisit) {
selectedVisitId.value = visit.id
errors.visit = ''
}
function selectTemplate(template: SigningTemplate) {
selectedTemplateId.value = template.id
errors.document = ''
if (!template.supportedMethods.includes(method.value)) {
method.value = template.supportedMethods[0] ?? 'pad'
}
}
function selectMethod(nextMethod: SigningMethod) {
if (selectedTemplate.value && !selectedTemplate.value.supportedMethods.includes(nextMethod)) {
return
}
method.value = nextMethod
errors.phone = ''
}
function maskPhone(value: string) {
if (value.length < 7) {
return value
}
return `${value.slice(0, 3)}****${value.slice(-4)}`
}
function togglePhone() {
patientPhoneRevealed.value = !patientPhoneRevealed.value
ElMessage.info('明文查看已记录审计日志')
}
function validate() {
clearErrors()
let valid = true
if (!patient.value) {
errors.patient = '请先定位患者'
valid = false
}
if (!selectedVisit.value) {
errors.visit = '请选择关联就诊'
valid = false
}
if (!selectedTemplate.value) {
errors.document = '请至少选择一份文档'
valid = false
}
if (method.value === 'sms' && !smsPhone.value.trim()) {
errors.phone = '请确认短信接收手机号'
valid = false
}
return valid
}
async function submit(): Promise<SigningTaskRecord | null> {
if (!validate() || !patient.value || !selectedVisit.value || !selectedTemplate.value) {
return null
}
submitting.value = true
try {
return await createSigningTask({
campus: toValue(options.campus),
patientId: patient.value.id,
visitId: selectedVisit.value.id,
documentId: selectedTemplate.value.id,
documentName: selectedTemplate.value.name,
method: method.value,
visitType: selectedVisit.value.type,
visitNo: selectedVisit.value.visitNo,
department: selectedVisit.value.department,
phone: method.value === 'sms' ? smsPhone.value.trim() : undefined,
})
} finally {
submitting.value = false
}
}
return {
documentSearch,
errors,
filteredTemplateGroups,
loadingTemplates,
loadTemplates,
locatePatient,
locating,
maskPhone,
method,
patient,
patientId,
patientPhoneRevealed,
resetForNextTask,
resetForm,
selectMethod,
selectTemplate,
selectVisit,
selectedTemplate,
selectedTemplateId,
selectedVisit,
selectedVisitId,
smsPhone,
submit,
submitting,
templates,
togglePhone,
visits,
}
}

View File

@@ -2,6 +2,9 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import type { SigningTemplate } from '@/api/workbench/types'
import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
@@ -18,6 +21,8 @@ import type {
const loading = ref(true)
const library = ref<DocumentDepartment[]>([])
const selectedTemplate = ref<DocumentTemplate | null>(null)
const signingTemplate = ref<SigningTemplate | null>(null)
const signingDialogVisible = ref(false)
const filters = reactive<DocumentFilterForm>({
keyword: '',
@@ -96,8 +101,27 @@ function showEditMessage(template: DocumentTemplate) {
ElMessage.info('原型演示:编辑模板《' + template.name + '》')
}
function showInitiateMessage(template: DocumentTemplate) {
ElMessage.info('已选择《' + template.name + '》,签署工作台暂未实现')
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
return {
id: template.id,
name: template.name,
code: template.code,
department: template.department,
category: template.category,
description: template.description,
supportedMethods: ['pad', 'sms'],
}
}
function openSigningDialog(template: DocumentTemplate) {
closePreview()
signingTemplate.value = toSigningTemplate(template)
signingDialogVisible.value = true
}
function closeSigningDialog() {
signingDialogVisible.value = false
signingTemplate.value = null
}
function clearFilters() {
@@ -106,8 +130,7 @@ function clearFilters() {
}
function useTemplate(template: DocumentTemplate) {
closePreview()
ElMessage.info('原型演示:从模板《' + template.name + '》发起签署')
openSigningDialog(template)
}
onMounted(loadLibrary)
@@ -146,7 +169,7 @@ onMounted(loadLibrary)
:template="template"
@preview="handlePreview"
@edit="showEditMessage"
@initiate="showInitiateMessage"
@initiate="openSigningDialog"
/>
</div>
@@ -169,6 +192,11 @@ onMounted(loadLibrary)
@close="closePreview"
@use="useTemplate"
/>
<NewSigningTaskDialog
:visible="signingDialogVisible"
:initial-template="signingTemplate"
@close="closeSigningDialog"
/>
</section>
</template>

View File

@@ -22,9 +22,9 @@ import type {
VisitType,
WorkbenchCampus,
} from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import NewSigningTaskDialog from './components/NewSigningTaskDialog.vue'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
import SigningFilterBar from './components/SigningFilterBar.vue'