Files
xh-medical-sign-web/clinical-web/src/components/signing/NewSigningTaskDialog.vue
T

1514 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>