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
@@ -0,0 +1,923 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
import { useAppStore } from '@/stores/app'
const props = defineProps<{
visible: boolean
initialTemplate?: SigningTemplate | null
}>()
const emit = defineEmits<{
close: []
created: [task: SigningTaskRecord]
}>()
const appStore = useAppStore()
const patientInput = ref<HTMLInputElement | null>(null)
const initialTemplate = computed(() => props.initialTemplate ?? null)
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,
})
async function submitTask(keepOpen: boolean) {
try {
const task = await submitForm()
if (!task) {
return
}
emit('created', task)
ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',短信已发送' : ''}`)
if (keepOpen) {
resetForNextTask()
await nextTick()
patientInput.value?.focus()
return
}
emit('close')
} catch {
ElMessage.error('签署任务创建失败,请稍后重试')
}
}
watch(
() => props.visible,
async (visible) => {
if (!visible) {
return
}
resetForm()
await loadTemplates()
await nextTick()
patientInput.value?.focus()
},
)
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
<section
class="dialog-card new-task-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="new-task-title"
>
<header class="dialog-header">
<strong id="new-task-title">新增签署任务</strong>
<span>单页完成,无需分步</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<section class="form-section">
<header class="section-header">
<span class="section-index">A</span>
<strong>患者定位</strong>
<span>输入患者 ID / 门诊号 / 住院号,回车定位</span>
</header>
<div class="section-body">
<div class="patient-search">
<input
ref="patientInput"
v-model="patientId"
placeholder="如:P202610001"
aria-label="患者 ID"
@input="errors.patient = ''"
@keydown.enter.prevent="locatePatient"
/>
<button
type="button"
class="action-button"
:disabled="locating"
@click="locatePatient"
>
{{ locating ? '定位中…' : '定位患者' }}
</button>
</div>
<div v-if="patient" class="patient-card">
<span class="patient-avatar" aria-hidden="true">人</span>
<strong>{{ patient.name }}</strong>
<span>{{ patient.sex }} · {{ patient.age }}岁</span>
<span class="patient-field"><small>身份证号</small>{{ patient.idCard }}</span>
<span class="patient-field">
<small>手机号</small>
{{ patientPhoneRevealed ? patient.phone : maskPhone(patient.phone) }}
<button type="button" class="inline-button" @click="togglePhone">
{{ patientPhoneRevealed ? '隐藏明文' : '显示明文' }}
</button>
</span>
<span class="patient-field"><small>患者 ID</small>{{ patient.id }}</span>
<span v-if="selectedVisit?.isCurrent" class="in-hospital">在院</span>
</div>
<p v-if="errors.patient" class="field-error">{{ errors.patient }}</p>
</div>
</section>
<section class="form-section">
<header class="section-header">
<span class="section-index">B</span>
<strong>关联就诊</strong>
<span>默认最近一次,在院优先</span>
</header>
<div class="section-body">
<div v-if="visits.length" class="visit-list">
<button
v-for="visit in visits"
:key="visit.id"
type="button"
class="visit-option"
:class="{ 'visit-option--active': visit.id === selectedVisitId }"
@click="selectVisit(visit)"
>
<span class="radio-mark" />
<strong>{{ visit.type }} · {{ visit.department }}</strong>
<span class="visit-number">{{ visit.visitNo }}</span>
<span class="visit-date">{{ visit.visitDate }}</span>
<span v-if="visit.isCurrent" class="visit-current">● 在院</span>
<span v-else-if="visit === visits[0]" class="visit-current visit-current--recent"
>最近一次</span
>
</button>
</div>
<p v-else class="section-empty">请先定位患者…</p>
<p v-if="errors.visit" class="field-error">{{ errors.visit }}</p>
</div>
</section>
<div class="document-method-grid">
<section class="form-section document-section">
<header class="section-header">
<span class="section-index">C</span>
<strong>选择文档</strong>
<span>单选一份</span>
<input
v-model="documentSearch"
placeholder="⌕ 搜索文档名称…"
aria-label="搜索文档"
/>
</header>
<div class="section-body">
<div v-if="selectedTemplate" class="selected-template">
<span>已选:</span>
<strong>{{ selectedTemplate.name }}</strong>
<button type="button" aria-label="移除已选文档" @click="selectedTemplateId = ''">
×
</button>
</div>
<div v-if="loadingTemplates" class="tree-loading">正在加载文档模板…</div>
<div v-else-if="filteredTemplateGroups.length" class="template-tree">
<div
v-for="department in filteredTemplateGroups"
:key="department.name"
class="tree-department"
>
<div class="tree-label">
<span>▾</span><strong>{{ department.name }}</strong>
</div>
<div
v-for="category in department.categories"
:key="category.name"
class="tree-category"
>
<div class="tree-label tree-label--category">
<span>▾</span>{{ category.name
}}<small>{{ category.templates.length }}</small>
</div>
<button
v-for="template in category.templates"
:key="template.id"
type="button"
class="document-option"
:class="{ 'document-option--active': selectedTemplateId === template.id }"
@click="selectTemplate(template)"
>
<span class="radio-mark radio-mark--document">●</span>
<span>{{ template.name }}</span>
</button>
</div>
</div>
</div>
<div v-else class="tree-empty">没有匹配的文档模板</div>
<p v-if="errors.document" class="field-error">{{ errors.document }}</p>
</div>
</section>
<section class="form-section method-section">
<header class="section-header">
<span class="section-index">D</span>
<strong>签署方式</strong>
<span>二选一</span>
</header>
<div class="section-body">
<div class="method-list">
<button
type="button"
class="method-option"
:class="{ 'method-option--active': method === 'pad' }"
:disabled="
Boolean(
selectedTemplate && !selectedTemplate.supportedMethods.includes('pad'),
)
"
@click="selectMethod('pad')"
>
<span class="method-icon">✍</span>
<span
><strong>手写板签署(现场)</strong
><small>护士站手写板签署,即签即得</small></span
>
</button>
<button
type="button"
class="method-option"
:class="{ 'method-option--active': method === 'sms' }"
:disabled="
Boolean(
selectedTemplate && !selectedTemplate.supportedMethods.includes('sms'),
)
"
@click="selectMethod('sms')"
>
<span class="method-icon">📱</span>
<span
><strong>线上签署(短信链接)</strong
><small>短信发送链接,48 小时内有效</small></span
>
</button>
</div>
<div v-if="method === 'sms'" class="sms-confirm">
<div class="sms-row">
<span class="sms-icon">📱</span>
<strong>短信将发送至</strong>
<input
v-model="smsPhone"
placeholder="请确认手机号"
aria-label="短信接收手机号"
@input="errors.phone = ''"
/>
</div>
<p>可修改为患者其他号码,修改将记录留痕</p>
<p v-if="errors.phone" class="field-error">{{ errors.phone }}</p>
</div>
</div>
</section>
</div>
</div>
<footer class="dialog-footer">
<span>患者手写签名图片将合成至文档签名域,PDF 原件与签名原图双留存</span>
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
取消
</button>
<button
type="button"
class="action-button action-button--ghost"
:disabled="submitting"
@click="submitTask(true)"
>
提交并继续新增
</button>
<button
type="button"
class="action-button"
:disabled="submitting"
@click="submitTask(false)"
>
{{ submitting ? '提交中…' : '提交任务' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
width: 680px;
max-width: 96vw;
max-height: calc(100vh - 32px);
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
.new-task-dialog {
display: flex;
width: 1200px;
flex-direction: column;
overflow: hidden;
}
.dialog-header {
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
font-size: 15px;
}
.dialog-header > span {
color: var(--mut);
font-size: 12px;
}
.dialog-close {
flex-shrink: 0;
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
min-height: 0;
padding: 12px 18px;
overflow-y: auto;
}
.form-section {
margin-bottom: 8px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.section-header {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 12px;
font-size: 12.5px;
background: #f5f9fa;
}
.section-header > span:not(.section-index) {
margin-left: auto;
color: var(--mut);
font-size: 11px;
font-weight: 400;
}
.section-index {
display: grid;
width: 18px;
height: 18px;
flex-shrink: 0;
color: #fff;
font-size: 10.5px;
background: var(--brand);
border-radius: 50%;
place-items: center;
}
.section-body {
padding: 8px 12px;
}
.patient-search {
display: flex;
gap: 10px;
}
.patient-search input,
.sms-row input {
min-width: 0;
padding: 7px 10px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 6px;
}
.patient-search input {
flex: 1;
font-size: 14px;
letter-spacing: 1px;
}
.patient-search input:focus,
.sms-row input:focus,
.section-header input:focus {
outline: none;
border-color: var(--brand);
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button:hover:not(:disabled) {
background: var(--brand-d);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover:not(:disabled) {
background: var(--brand-l);
}
.patient-card {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
padding: 7px 12px;
margin-top: 8px;
background: var(--brand-l);
border: 1px solid var(--brand);
border-radius: 8px;
}
.patient-avatar {
display: grid;
width: 28px;
height: 28px;
color: #fff;
font-size: 13px;
background: var(--brand);
border-radius: 50%;
place-items: center;
}
.patient-card > strong {
color: var(--brand-d);
font-size: 15px;
}
.patient-card > span:not(.patient-field):not(.patient-avatar):not(.in-hospital) {
color: var(--ink);
font-size: 12px;
}
.patient-field {
display: inline-flex;
gap: 4px;
align-items: center;
color: var(--ink);
font-size: 12px;
white-space: nowrap;
}
.patient-field small {
color: var(--mut);
font-size: 11px;
}
.inline-button {
padding: 2px 5px;
color: var(--brand);
font-size: 11px;
background: transparent;
border: 1px solid var(--brand);
border-radius: 4px;
}
.in-hospital {
padding: 1px 7px;
color: #fff;
font-size: 10.5px;
background: var(--err);
border-radius: 20px;
}
.field-error {
margin: 6px 0 0;
color: var(--err);
font-size: 12px;
}
.visit-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.visit-option {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
padding: 5px 10px;
color: var(--ink);
text-align: left;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.visit-option:hover,
.visit-option--active {
border-color: var(--brand);
}
.visit-option--active {
background: var(--brand-l);
}
.radio-mark {
display: inline-block;
width: 13px;
height: 13px;
flex-shrink: 0;
border: 2px solid #b8cbd4;
border-radius: 50%;
}
.visit-option--active .radio-mark {
background: radial-gradient(circle, var(--brand) 42%, transparent 46%);
border-color: var(--brand);
}
.visit-option strong {
font-size: 12px;
}
.visit-number,
.visit-date {
color: var(--mut);
font-size: 11.5px;
}
.visit-current {
margin-left: auto;
color: var(--ok);
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.visit-current--recent {
color: var(--brand);
}
.section-empty,
.tree-empty,
.tree-loading {
padding: 4px 0;
margin: 0;
color: var(--mut);
font-size: 12px;
}
.document-method-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 12px;
align-items: stretch;
}
.document-section,
.method-section {
margin-bottom: 0;
}
.document-section .section-header input {
width: 240px;
padding: 4px 10px;
margin-left: auto;
color: var(--ink);
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.document-section .section-header > span:not(.section-index) {
margin-left: 4px;
}
.selected-template {
display: flex;
gap: 5px;
align-items: center;
padding: 5px 8px;
margin-bottom: 6px;
color: var(--brand-d);
font-size: 12px;
background: var(--brand-l);
border-radius: 6px;
}
.selected-template strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-template button {
padding: 0 3px;
margin-left: auto;
color: var(--mut);
font-size: 16px;
line-height: 1;
background: transparent;
}
.template-tree {
height: 252px;
padding: 6px;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.tree-label {
display: flex;
gap: 6px;
align-items: center;
padding: 5px 6px;
font-size: 13px;
}
.tree-label > span {
width: 12px;
color: var(--mut);
font-size: 10px;
}
.tree-category {
margin-left: 18px;
}
.tree-label--category {
color: var(--ink);
font-size: 12px;
}
.tree-label small {
margin-left: auto;
color: var(--mut);
font-size: 11px;
}
.document-option {
display: flex;
width: calc(100% - 10px);
gap: 8px;
align-items: center;
padding: 5px 6px;
margin-left: 10px;
color: var(--ink);
font-size: 12.5px;
text-align: left;
background: transparent;
border-radius: 6px;
}
.document-option:hover,
.document-option--active {
background: var(--brand-l);
}
.radio-mark--document {
display: grid;
width: 15px;
height: 15px;
color: transparent;
font-size: 10px;
border: 1.5px solid #b8cbd4;
place-items: center;
}
.document-option--active .radio-mark--document {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.method-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.method-option {
display: flex;
width: 100%;
gap: 10px;
align-items: flex-start;
padding: 8px 12px;
color: var(--ink);
text-align: left;
background: #fff;
border: 1.5px solid var(--line);
border-radius: 8px;
}
.method-option:hover:not(:disabled),
.method-option--active {
border-color: var(--brand);
}
.method-option--active {
background: var(--brand-l);
}
.method-option:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.method-icon {
font-size: 19px;
line-height: 1.3;
}
.method-option strong,
.method-option small {
display: block;
}
.method-option strong {
margin-bottom: 1px;
font-size: 13px;
}
.method-option small {
overflow: hidden;
color: var(--mut);
font-size: 11px;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
.sms-confirm {
padding: 9px 12px;
margin-top: 10px;
background: var(--brand-l);
border: 1px dashed var(--brand);
border-radius: 8px;
}
.sms-row {
display: flex;
gap: 8px;
align-items: center;
}
.sms-row strong {
flex-shrink: 0;
color: var(--brand-d);
font-size: 12.5px;
}
.sms-icon {
font-size: 16px;
}
.sms-row input {
width: 170px;
}
.sms-confirm p {
padding-left: 28px;
margin: 6px 0 0;
color: var(--mut);
font-size: 11.5px;
}
.dialog-footer {
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
padding: 10px 18px;
color: var(--mut);
font-size: 12px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.footer-spacer {
flex: 1;
}
@keyframes dialog-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 1000px) {
.document-method-grid {
grid-template-columns: 1fr;
}
.method-section {
margin-bottom: 8px;
}
}
@media (max-width: 680px) {
.patient-card {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
.section-header {
align-items: flex-start;
flex-wrap: wrap;
}
.section-header > span:not(.section-index) {
margin-left: 0;
}
.document-section .section-header input {
width: 100%;
margin-left: 26px;
}
.dialog-footer {
align-items: flex-end;
flex-wrap: wrap;
}
.dialog-footer > span:first-child {
width: 100%;
}
}
</style>