1514 lines
38 KiB
Vue
1514 lines
38 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import { isSigningMockEnabled } from '@/api/workbench/signing'
|
||
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
||
import DocumentPreviewDialog from '@/components/document/DocumentPreviewDialog.vue'
|
||
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
|
||
import { useAppStore } from '@/stores/app'
|
||
import type { SigningDocumentData } from '@/utils/signing-document'
|
||
import { SIGNING_METHODS } from '@/utils/signing-method'
|
||
|
||
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 {
|
||
closeSuggestions,
|
||
departmentRows,
|
||
documentList,
|
||
documentSearch,
|
||
errors,
|
||
isMethodAllowed,
|
||
loadingTemplates,
|
||
loadTemplates,
|
||
locatePatient,
|
||
locating,
|
||
maskPhone,
|
||
method,
|
||
openSuggestions,
|
||
patient,
|
||
patientId,
|
||
patientSuggestions,
|
||
phonePlain,
|
||
pickPatient,
|
||
prescription,
|
||
resetForNextTask,
|
||
resetForm,
|
||
selectMethod,
|
||
selectTemplate,
|
||
selectVisit,
|
||
selectedTemplate,
|
||
selectedTemplateId,
|
||
selectedVisit,
|
||
selectedVisitId,
|
||
smsDestination,
|
||
staffAnswers,
|
||
manualFormFields,
|
||
signerType,
|
||
signerName,
|
||
signerRelation,
|
||
staffAnswerHasOption,
|
||
toggleStaffAnswerOption,
|
||
submit: submitForm,
|
||
submitting,
|
||
suggestionOpen,
|
||
toggleCategory,
|
||
toggleDepartment,
|
||
togglePhonePlain,
|
||
visits,
|
||
} = useSigningTaskForm({
|
||
campus: computed(() => appStore.selectedCampus),
|
||
campusId: computed(() => appStore.selectedCampusId),
|
||
initialTemplate,
|
||
})
|
||
|
||
/**
|
||
* 候选患者下拉要在失焦后稍等再关:行上的动作走 mousedown(先于 blur 触发),
|
||
* 立刻关闭会让点击落空——原型同样用 160ms 延迟。
|
||
*/
|
||
function handlePatientBlur() {
|
||
window.setTimeout(closeSuggestions, 160)
|
||
}
|
||
|
||
/** 输入即展开候选并清掉上一次的定位报错 */
|
||
function handlePatientInput() {
|
||
openSuggestions()
|
||
errors.patient = ''
|
||
}
|
||
|
||
/**
|
||
* 演示用 Pad 设备(原型 #ntIpadDev 的三个选项)。
|
||
* 设备列表没有接口,真实模式下不编造,改为提示由设备适配器选择。
|
||
*/
|
||
const IPAD_DEVICES = [
|
||
'Pad Pro 11″(儿科急诊 · 移动签署车01)',
|
||
'Pad Pro 11″(儿科急诊 · 诊间02)',
|
||
'Pad Air(核医学科 · PET护士台)',
|
||
]
|
||
const ipadDevice = ref(IPAD_DEVICES[0])
|
||
|
||
/* ---------------- 文档签署预览(原型 ntPreviewDoc) ---------------- */
|
||
|
||
/**
|
||
* 预览用空快照:原型预览的是**空白模板版式**(占位符留成下划线),
|
||
* 不预填某位患者的信息——那是签署时才有的事,提前填上会让人误以为已带出数据。
|
||
*/
|
||
const PREVIEW_SNAPSHOT: SigningDocumentData = {
|
||
patientName: '',
|
||
sex: '',
|
||
age: '',
|
||
visitNo: '',
|
||
visitDate: '',
|
||
phone: '',
|
||
signedAt: '',
|
||
}
|
||
|
||
const previewTemplate = ref<SigningTemplate | null>(null)
|
||
|
||
function openPreview(template: SigningTemplate) {
|
||
previewTemplate.value = template
|
||
}
|
||
|
||
function closePreview() {
|
||
previewTemplate.value = null
|
||
}
|
||
|
||
/* ---------------- 提交 ---------------- */
|
||
|
||
async function submitTask(keepOpen: boolean) {
|
||
try {
|
||
const task = await submitForm()
|
||
|
||
if (!task) {
|
||
return
|
||
}
|
||
|
||
emit('created', task)
|
||
const smsMessage =
|
||
task.method === 'sms'
|
||
? isSigningMockEnabled
|
||
? ',已生成短信投递意图'
|
||
: ',短信投递已排队'
|
||
: ''
|
||
ElMessage.success(`已创建 1 个签署任务${smsMessage}`)
|
||
|
||
if (keepOpen) {
|
||
// 原型:保留当前患者与就诊,只清掉文书与签署方式,方便连着发起多份
|
||
resetForNextTask()
|
||
await nextTick()
|
||
ElMessage.info('保留当前患者与就诊,可继续发起')
|
||
return
|
||
}
|
||
|
||
emit('close')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '签署任务创建失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.visible,
|
||
async (visible) => {
|
||
if (!visible) {
|
||
previewTemplate.value = null
|
||
return
|
||
}
|
||
|
||
await appStore.loadCampuses()
|
||
if (!appStore.selectedCampusId) {
|
||
ElMessage.error(
|
||
appStore.campusLoadFailed ? '院区加载失败,无法加载文书模板' : '当前没有可用院区',
|
||
)
|
||
return
|
||
}
|
||
|
||
resetForm()
|
||
await loadTemplates()
|
||
await nextTick()
|
||
// 自动聚焦只是让人可以直接开始打字;**下拉不跟着弹**。
|
||
// 展开入口挂在 @click / @input 上,不挂 @focus —— 原型是 onfocus 即展开
|
||
// (openM 里还会 ntPid.focus()),这里按使用要求有意偏离:
|
||
// 打开弹窗就盖住 B/C/D 三区,什么还没做就得先把它关掉。
|
||
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">
|
||
<!-- A 患者定位 -->
|
||
<section class="form-section">
|
||
<header class="section-header">
|
||
<span class="section-index">A</span>
|
||
<strong>患者定位</strong>
|
||
</header>
|
||
<div class="section-body">
|
||
<div class="patient-search">
|
||
<input
|
||
ref="patientInput"
|
||
v-model="patientId"
|
||
placeholder="如:P202610001 或 患者姓名"
|
||
aria-label="患者 ID"
|
||
@click="openSuggestions"
|
||
@input="handlePatientInput"
|
||
@blur="handlePatientBlur"
|
||
@keydown.enter.prevent="locatePatient"
|
||
/>
|
||
<div v-if="suggestionOpen" class="nt-sug">
|
||
<div class="shd">
|
||
{{
|
||
patientId.trim()
|
||
? `匹配到 ${patientSuggestions.length} 位患者`
|
||
: '候选患者 · 点击直接定位'
|
||
}}
|
||
</div>
|
||
<template v-if="patientSuggestions.length">
|
||
<div
|
||
v-for="candidate in patientSuggestions"
|
||
:key="candidate.id"
|
||
class="srow"
|
||
@mousedown.prevent="pickPatient(candidate)"
|
||
>
|
||
<b>{{ candidate.name }}</b>
|
||
<span class="mut">{{ candidate.sex }} · {{ candidate.age }}岁</span>
|
||
<span class="pid">{{ candidate.id }}</span>
|
||
<span class="mut">{{ candidate.visits[0]?.department ?? '—' }}</span>
|
||
</div>
|
||
</template>
|
||
<div v-else class="empty">未找到匹配患者,请更换 ID / 姓名 / 就诊号关键字</div>
|
||
</div>
|
||
<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>
|
||
<span class="patient-name">{{ patient.name }}</span>
|
||
<span class="patient-summary"> {{ patient.sex }} · {{ patient.age }}岁 </span>
|
||
<span v-if="selectedVisit?.isCurrent" class="in-hospital">在院</span>
|
||
<span class="patient-field"><small>身份证号</small>{{ patient.idCard }}</span>
|
||
<span class="patient-field">
|
||
<small>手机号</small>
|
||
{{ phonePlain ? (patient.phoneFull ?? patient.phone) : maskPhone(patient.phone) }}
|
||
<button
|
||
v-if="patient.phoneFull"
|
||
type="button"
|
||
class="action-button action-button--ghost action-button--mini"
|
||
@click="togglePhonePlain"
|
||
>
|
||
{{ phonePlain ? '隐藏明文' : '显示明文' }}
|
||
</button>
|
||
</span>
|
||
<span class="patient-field"><small>患者 ID</small>{{ patient.id }}</span>
|
||
</div>
|
||
|
||
<p v-if="errors.patient" class="field-error">{{ errors.patient }}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- B 就诊关联 -->
|
||
<section class="form-section">
|
||
<header class="section-header">
|
||
<span class="section-index">B</span>
|
||
<strong>关联就诊</strong>
|
||
</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 class="visit-title">{{ visit.type }} · {{ visit.department }}</strong>
|
||
<span
|
||
class="visit-number"
|
||
:class="{ 'visit-number--hospital': visit.type === '住院' }"
|
||
>
|
||
{{ 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] && !visits.some((item) => item.isCurrent)"
|
||
class="visit-current visit-current--recent"
|
||
>最近一次</span
|
||
>
|
||
</button>
|
||
<!-- 原型 V6.4:选中就诊自动带出关联处方(仅演示数据,无处方接口) -->
|
||
<div v-if="prescription" class="visit-prescription">
|
||
<span class="visit-prescription__icon" aria-hidden="true">💊</span>
|
||
<strong>关联处方 · {{ prescription.no }}</strong>
|
||
<span class="visit-prescription__text">{{ prescription.description }}</span>
|
||
<span class="tag tag--done">已自动关联</span>
|
||
</div>
|
||
</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">
|
||
<!-- C 选择文档 -->
|
||
<section class="form-section document-section">
|
||
<header class="section-header">
|
||
<span class="section-index">C</span>
|
||
<strong>选择文档</strong>
|
||
<input
|
||
v-model="documentSearch"
|
||
class="nt-search"
|
||
placeholder="🔍 搜索文档名称…"
|
||
aria-label="搜索文档"
|
||
/>
|
||
</header>
|
||
<div class="section-body">
|
||
<div v-if="loadingTemplates" class="tree-empty">正在加载文档模板…</div>
|
||
<div v-else class="template-tree">
|
||
<!-- 左列:科室 → 分类 -->
|
||
<div class="nt-tree-left">
|
||
<template v-if="departmentRows.length">
|
||
<template v-for="row in departmentRows" :key="row.name">
|
||
<button
|
||
type="button"
|
||
class="nt-dept"
|
||
:class="{ 'nt-dept--active': row.active }"
|
||
:title="row.name"
|
||
@click="toggleDepartment(row.name)"
|
||
>
|
||
<span class="nt-arrow">{{ row.open ? '▾' : '▸' }}</span>
|
||
<span class="nt-dept__name">📁 {{ row.name }}</span>
|
||
<span class="nt-count">{{ row.count }}</span>
|
||
</button>
|
||
<template v-if="row.open">
|
||
<button
|
||
v-for="category in row.categories"
|
||
:key="category.name"
|
||
type="button"
|
||
class="nt-cat"
|
||
:class="{ 'nt-cat--active': category.active }"
|
||
:title="category.name"
|
||
@click.stop="toggleCategory(row.name, category.name)"
|
||
>
|
||
<span class="nt-cat__name">🏷 {{ category.name }}</span>
|
||
<span class="nt-count">{{ category.count }}</span>
|
||
</button>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
<div v-else class="tree-empty">无匹配文档</div>
|
||
</div>
|
||
|
||
<!-- 右列:文档标题(单选) -->
|
||
<div class="nt-tree-right">
|
||
<div v-if="documentList.label" class="nt-list-label">
|
||
{{ documentList.label }} · {{ documentList.rows.length }} 份
|
||
</div>
|
||
<template v-if="documentList.rows.length">
|
||
<template v-for="row in documentList.rows" :key="row.template.id">
|
||
<div v-if="row.breadcrumb" class="nt-list-breadcrumb">
|
||
{{ row.breadcrumb }}
|
||
</div>
|
||
<div
|
||
class="doc-option"
|
||
:class="{ 'doc-option--active': selectedTemplateId === row.template.id }"
|
||
:title="row.template.name"
|
||
@click="selectTemplate(row.template)"
|
||
>
|
||
<span class="doc-option__mark">●</span>
|
||
<span class="doc-option__name">{{ row.template.name }}</span>
|
||
<button
|
||
type="button"
|
||
class="nt-preview"
|
||
title="签署预览"
|
||
@click.stop="openPreview(row.template)"
|
||
>
|
||
预览
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
<div v-else class="tree-empty">在左侧选择科室或分类,此处展示对应文档</div>
|
||
</div>
|
||
</div>
|
||
<p v-if="errors.document" class="field-error">{{ errors.document }}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- D 签署方式 -->
|
||
<section class="form-section method-section">
|
||
<header class="section-header">
|
||
<span class="section-index">D</span>
|
||
<strong>签署方式</strong>
|
||
</header>
|
||
<div class="section-body">
|
||
<div class="method-list">
|
||
<button
|
||
v-for="option in SIGNING_METHODS"
|
||
:key="option.key"
|
||
type="button"
|
||
class="method-option"
|
||
:class="{ 'method-option--active': method === option.key }"
|
||
:disabled="
|
||
!isMethodAllowed(option.key) ||
|
||
(!isSigningMockEnabled && (option.key === 'wx' || option.key === 'ipad'))
|
||
"
|
||
@click="selectMethod(option.key)"
|
||
>
|
||
<span class="method-icon">{{ option.icon }}</span>
|
||
<strong>{{ option.title }}</strong>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="method === 'sms'" class="sms-confirm">
|
||
<div class="sms-row">
|
||
<span class="sms-icon">📱</span>
|
||
<strong>短信将发送至</strong>
|
||
<input
|
||
v-model="smsDestination"
|
||
inputmode="tel"
|
||
autocomplete="tel"
|
||
placeholder="请确认手机号"
|
||
aria-label="短信接收手机号"
|
||
@input="errors.sms = ''"
|
||
/>
|
||
<span v-if="isSigningMockEnabled && patient" class="phone-source">
|
||
来源:HIS 带出
|
||
</span>
|
||
</div>
|
||
<p v-if="errors.sms" class="field-error">{{ errors.sms }}</p>
|
||
</div>
|
||
|
||
<div v-if="method === 'ipad'" class="sms-confirm">
|
||
<div class="sms-row">
|
||
<span class="sms-icon">📲</span>
|
||
<strong>签署设备</strong>
|
||
<select v-if="isSigningMockEnabled" v-model="ipadDevice" class="device-select">
|
||
<option v-for="device in IPAD_DEVICES" :key="device" :value="device">
|
||
{{ device }}
|
||
</option>
|
||
</select>
|
||
<span v-else class="action-hint">
|
||
Pad 设备列表接口待接入,真实环境由设备适配器选择
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<p
|
||
v-if="!isSigningMockEnabled && (method === 'wx' || method === 'ipad')"
|
||
class="field-error"
|
||
>
|
||
当前后端尚未接入该渠道的投递或设备适配,请选择短信或已配置的现场手写板。
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section v-if="selectedTemplate" class="form-section signing-context-section">
|
||
<header class="section-header">
|
||
<span class="section-index">E</span>
|
||
<strong>签署人和文书填写项</strong>
|
||
<span class="section-hint">按已选模板采集,提交后固化到签署任务</span>
|
||
</header>
|
||
<div class="section-body signing-context-grid">
|
||
<fieldset class="signer-choice">
|
||
<legend>实际签署人</legend>
|
||
<label>
|
||
<input v-model="signerType" type="radio" value="SELF" />
|
||
患者本人
|
||
</label>
|
||
<label>
|
||
<input v-model="signerType" type="radio" value="FAMILY" />
|
||
家属代签
|
||
</label>
|
||
<template v-if="signerType === 'FAMILY'">
|
||
<input v-model="signerName" maxlength="255" placeholder="代签人姓名" />
|
||
<input v-model="signerRelation" maxlength="64" placeholder="与患者关系" />
|
||
</template>
|
||
<p v-if="errors.signer" class="field-error">{{ errors.signer }}</p>
|
||
</fieldset>
|
||
|
||
<div v-if="manualFormFields.length" class="staff-answer-fields">
|
||
<label v-for="field in manualFormFields" :key="field.id" class="staff-answer-field">
|
||
<span>{{ field.label }}<b v-if="field.required"> *</b></span>
|
||
<select
|
||
v-if="field.kind === 'choice'"
|
||
v-model="staffAnswers[field.id]"
|
||
:required="field.required"
|
||
>
|
||
<option value="">请选择</option>
|
||
<option v-for="option in field.options ?? []" :key="option" :value="option">
|
||
{{ option }}
|
||
</option>
|
||
</select>
|
||
<span
|
||
v-else-if="field.kind === 'checkbox' && field.options?.length"
|
||
class="staff-answer-options"
|
||
>
|
||
<label v-for="option in field.options" :key="option">
|
||
<input
|
||
type="checkbox"
|
||
:checked="staffAnswerHasOption(field.id, option)"
|
||
@change="
|
||
toggleStaffAnswerOption(
|
||
field.id,
|
||
option,
|
||
($event.target as HTMLInputElement).checked,
|
||
)
|
||
"
|
||
/>
|
||
{{ option }}
|
||
</label>
|
||
</span>
|
||
<input
|
||
v-else
|
||
v-model="staffAnswers[field.id]"
|
||
:type="field.kind === 'date' ? 'date' : 'text'"
|
||
:required="field.required"
|
||
maxlength="4000"
|
||
:placeholder="`填写${field.label}`"
|
||
/>
|
||
</label>
|
||
<p v-if="errors.staffAnswers" class="field-error">{{ errors.staffAnswers }}</p>
|
||
</div>
|
||
<p v-else class="section-empty">
|
||
当前模板没有需要医护端填写的正文项;患者签名和签署日期将在签署环节生成。
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<footer class="dialog-footer">
|
||
<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>
|
||
|
||
<!-- 文档签署预览(原型 ntPreviewDoc → mdPdfView,只读):
|
||
渲染与外壳都走统一的文书预览组件,别在这里再写一份纸样 -->
|
||
<DocumentPreviewDialog
|
||
:visible="Boolean(previewTemplate)"
|
||
:title="`${previewTemplate?.name ?? ''}.pdf · 签署预览`"
|
||
:content-html="previewTemplate?.contentHtml ?? ''"
|
||
:data="PREVIEW_SNAPSHOT"
|
||
:stacked="true"
|
||
empty-text="该文档暂无结构化模板正文,发起签署后将按标准文书生成。"
|
||
@close="closePreview"
|
||
/>
|
||
</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;
|
||
width: 28px;
|
||
height: 28px;
|
||
padding: 0;
|
||
margin-left: auto;
|
||
color: var(--mut);
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
background: transparent;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.dialog-close:hover {
|
||
color: var(--ink);
|
||
background: #eef4f6;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/**
|
||
* A 区里的候选患者下拉是绝对定位、要溢出到下一个分区之上,被 .form-section 的
|
||
* overflow:hidden 裁掉就只剩一条缝(原型同样踩过,用 .nt-sec:has(#ntSug) 放行)。
|
||
* 放行 overflow 后表头方角会顶出圆角边框,单独把上方两角补回来。
|
||
*/
|
||
.form-section:has(.nt-sug) {
|
||
overflow: visible;
|
||
}
|
||
|
||
.form-section:has(.nt-sug) > .section-header {
|
||
border-radius: 7px 7px 0 0;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 6px 12px;
|
||
font-size: 12.5px;
|
||
background: #f5f9fa;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* A 区:输入框与候选下拉同一个定位容器,下拉才能贴着输入框下沿 */
|
||
|
||
.patient-search {
|
||
position: relative;
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.patient-search input {
|
||
min-width: 0;
|
||
flex: 1;
|
||
padding: 7px 10px;
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
letter-spacing: 1px;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.patient-search input:focus,
|
||
.sms-row input:focus,
|
||
.nt-search:focus,
|
||
.device-select:focus {
|
||
outline: none;
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
.nt-sug {
|
||
position: absolute;
|
||
z-index: 30;
|
||
top: calc(100% + 4px);
|
||
right: 104px;
|
||
left: 0;
|
||
max-height: 238px;
|
||
overflow: auto;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
box-shadow: 0 10px 28px rgb(15 60 80 / 14%);
|
||
}
|
||
|
||
.nt-sug .shd {
|
||
padding: 7px 12px 3px;
|
||
color: var(--mut);
|
||
font-size: 11px;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.nt-sug .srow {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 7px 12px;
|
||
color: var(--ink);
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.nt-sug .srow:hover {
|
||
background: var(--brand-l);
|
||
}
|
||
|
||
.nt-sug .srow b {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.nt-sug .srow .pid {
|
||
color: var(--brand);
|
||
font-size: 12px;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.nt-sug .srow .mut {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.nt-sug .srow .mut:last-child {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.nt-sug .empty {
|
||
padding: 10px 12px;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.patient-card {
|
||
display: flex;
|
||
gap: 14px;
|
||
align-items: center;
|
||
padding: 7px 12px;
|
||
margin-top: 8px;
|
||
background: var(--brand-l);
|
||
border: 1px solid var(--brand);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.patient-avatar {
|
||
flex-shrink: 0;
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.patient-name {
|
||
color: var(--brand-d);
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.patient-summary {
|
||
color: var(--ink);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.in-hospital {
|
||
flex-shrink: 0;
|
||
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;
|
||
}
|
||
|
||
/* B 区 */
|
||
|
||
.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);
|
||
font-size: 12px;
|
||
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-title {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.visit-number {
|
||
padding: 0 7px;
|
||
color: var(--mut);
|
||
font-size: 10.5px;
|
||
background: #eef2f4;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.visit-number--hospital {
|
||
color: var(--err);
|
||
background: var(--err-l);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.visit-prescription {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
background: #f4f9f6;
|
||
border: 1px solid #bfe3cf;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.visit-prescription__icon {
|
||
flex: none;
|
||
}
|
||
|
||
.visit-prescription strong {
|
||
color: var(--ok);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.visit-prescription__text {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
color: var(--mut);
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.tag {
|
||
display: inline-block;
|
||
flex: none;
|
||
padding: 2px 10px;
|
||
font-size: 11.5px;
|
||
font-weight: 700;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.tag--done {
|
||
color: var(--ok);
|
||
background: var(--ok-l);
|
||
}
|
||
|
||
.section-empty,
|
||
.tree-empty {
|
||
padding: 4px 0;
|
||
margin: 0;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.section-hint {
|
||
margin-left: auto;
|
||
color: var(--mut);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.signing-context-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.3fr);
|
||
gap: 14px;
|
||
}
|
||
|
||
.signer-choice {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-content: start;
|
||
padding: 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.signer-choice legend,
|
||
.staff-answer-field > span:first-child {
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.signer-choice label,
|
||
.staff-answer-options label {
|
||
display: inline-flex;
|
||
gap: 5px;
|
||
align-items: center;
|
||
color: var(--ink);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.signer-choice > input,
|
||
.staff-answer-field > input,
|
||
.staff-answer-field > select {
|
||
min-width: 0;
|
||
padding: 7px 9px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.staff-answer-fields {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.staff-answer-field {
|
||
display: grid;
|
||
gap: 5px;
|
||
min-width: 0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.staff-answer-field b {
|
||
color: var(--err);
|
||
}
|
||
|
||
.staff-answer-options {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 9px;
|
||
}
|
||
|
||
.signing-context-grid > .section-empty,
|
||
.staff-answer-fields > .field-error {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
/* C + D 并排 */
|
||
|
||
.document-method-grid {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
/**
|
||
* C 区(选择文档)高度**固定为最大值**,不随树内容伸缩。
|
||
*
|
||
* 树原来是 `max-height: 340px` 的自适应:科室下只有一个分类、或右栏只命中一份文档时,
|
||
* 整块会缩到一百多像素;展开内容多了再长回去 —— 于是整个弹窗一会儿高一会儿低,
|
||
* 点一下分类就跳一次。这里把树的可用高度钉死在「满高」340px。
|
||
*
|
||
* 固定的是 **section-body 的内容区**(356 = 树 340 + 上下内边距 16),不是整块 C 区:
|
||
* C 区表头里有一个搜索框,比其他分区的表头高一截,写死整块高度会把树压矮
|
||
* (第一版写 `height: 388px` 就压成了 330,因为算漏了这个搜索框)。
|
||
*
|
||
* 校验错误出现时是**挤掉树的高度**,而不是把 C 区顶高 —— 否则提交失败那一下弹窗仍然会跳。
|
||
*/
|
||
.document-section {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.method-section {
|
||
display: flex;
|
||
flex: 0 0 360px;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.document-section,
|
||
.method-section {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.document-section .section-body {
|
||
display: flex;
|
||
height: 356px;
|
||
flex: none;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* D 区靠 .document-method-grid 的 align-items:stretch 与 C 区等高;内容少就留白 */
|
||
.method-section .section-body {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.nt-search {
|
||
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;
|
||
}
|
||
|
||
/* 两栏:左 196px 科室→分类,右 1fr 文档标题。
|
||
树撑满 C 区剩余高度(不再自己定高),两栏各自滚动 —— 高度由 .document-section 钉住 */
|
||
|
||
.template-tree {
|
||
display: grid;
|
||
flex: 1;
|
||
min-height: 0;
|
||
grid-template-columns: 196px minmax(0, 1fr);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.nt-tree-left {
|
||
min-height: 0;
|
||
padding: 2px 6px 2px 2px;
|
||
overflow-y: auto;
|
||
border-right: 1px solid var(--line);
|
||
}
|
||
|
||
.nt-tree-right {
|
||
min-height: 0;
|
||
padding: 6px 4px 6px 10px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.nt-dept,
|
||
.nt-cat {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
width: 100%;
|
||
color: var(--ink);
|
||
text-align: left;
|
||
background: transparent;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.nt-dept {
|
||
padding: 5px 6px;
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
.nt-cat {
|
||
padding: 4px 6px 4px 28px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.nt-dept:hover,
|
||
.nt-cat:hover {
|
||
background: var(--brand-l);
|
||
}
|
||
|
||
.nt-dept--active {
|
||
color: var(--brand);
|
||
font-weight: 700;
|
||
background: #eef7fb;
|
||
}
|
||
|
||
.nt-cat--active {
|
||
color: var(--brand);
|
||
font-weight: 700;
|
||
background: #eef7fb;
|
||
}
|
||
|
||
.nt-arrow {
|
||
flex: none;
|
||
width: 10px;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.nt-dept__name,
|
||
.nt-cat__name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.nt-count {
|
||
color: var(--mut);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.nt-list-label {
|
||
margin: 0 0 6px;
|
||
color: var(--mut);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.nt-list-breadcrumb {
|
||
margin: 5px 0 1px;
|
||
color: var(--mut);
|
||
font-size: 10.5px;
|
||
}
|
||
|
||
.doc-option {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 5px 8px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
user-select: none;
|
||
}
|
||
|
||
.doc-option:hover {
|
||
background: #f0f6f8;
|
||
}
|
||
|
||
.doc-option--active {
|
||
background: var(--brand-l);
|
||
}
|
||
|
||
.doc-option__mark {
|
||
display: flex;
|
||
width: 15px;
|
||
height: 15px;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
border: 1.5px solid #b8cbd4;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.doc-option--active .doc-option__mark {
|
||
background: var(--brand);
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
.doc-option__name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.nt-preview {
|
||
flex: 0 0 auto;
|
||
padding: 2px 9px;
|
||
color: var(--brand);
|
||
font-size: 11px;
|
||
background: #fff;
|
||
border: 1px solid #cfe4ec;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.nt-preview:hover {
|
||
background: var(--brand-l);
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
/* D 区 */
|
||
|
||
.method-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.method-option {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
width: 100%;
|
||
padding: 7px 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 {
|
||
flex-shrink: 0;
|
||
font-size: 19px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.method-option strong {
|
||
display: block;
|
||
min-width: 0;
|
||
flex: 1;
|
||
margin-bottom: 1px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.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-icon {
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.sms-row strong {
|
||
flex-shrink: 0;
|
||
color: var(--brand-d);
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
.sms-row input {
|
||
width: 170px;
|
||
min-width: 0;
|
||
padding: 7px 10px;
|
||
color: var(--ink);
|
||
font-size: 13px;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.device-select {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 7px 10px;
|
||
color: var(--ink);
|
||
font-size: 12.5px;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.phone-source {
|
||
display: inline-flex;
|
||
flex: none;
|
||
align-items: center;
|
||
padding: 2px 8px;
|
||
color: var(--info);
|
||
font-size: 11px;
|
||
background: var(--info-l);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.action-hint {
|
||
color: var(--mut);
|
||
font-size: 11.5px;
|
||
}
|
||
|
||
.sms-confirm .field-error {
|
||
margin-top: 6px;
|
||
padding-left: 28px;
|
||
}
|
||
|
||
/* 底部 */
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 按钮 */
|
||
|
||
.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);
|
||
}
|
||
|
||
.action-button--mini {
|
||
padding: 2px 8px;
|
||
font-size: 11px;
|
||
font-weight: 400;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
/* 预览弹窗的样式已全部收进 components/document/DocumentPreviewDialog.vue ——
|
||
纸的排版、外框、页脚按钮都在那里,这里不再保留任何预览样式。 */
|
||
|
||
@keyframes dialog-pop {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.96) translateY(8px);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.signing-context-grid,
|
||
.staff-answer-fields {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.document-method-grid {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.method-section {
|
||
flex: 1 1 auto;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 680px) {
|
||
.patient-card {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.template-tree {
|
||
grid-template-columns: 1fr;
|
||
/* 单栏后两行要显式分高:左栏按内容(上限 200px),右栏吃掉剩余并自己滚动 */
|
||
grid-template-rows: auto minmax(0, 1fr);
|
||
}
|
||
|
||
.nt-tree-left {
|
||
max-height: 200px;
|
||
border-right: none;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.nt-sug {
|
||
right: 0;
|
||
}
|
||
|
||
.dialog-footer {
|
||
align-items: flex-end;
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
</style>
|