feat(sign): 补齐医护端与患者端签署流程

This commit is contained in:
xy
2026-09-28 19:57:57 +08:00
parent a25716c861
commit 8024075439
22 changed files with 1082 additions and 163 deletions
@@ -55,6 +55,7 @@ interface EditorPaneHandle {
}
const valueHtml = ref('<p><br></p>')
const needConfirm = ref(false)
const docxInput = ref<HTMLInputElement | null>(null)
const importing = ref(false)
const paneRef = ref<EditorPaneHandle | null>(null)
@@ -128,17 +129,19 @@ const fieldDraft = reactive({
options: [] as string[],
multi: false,
risk: '',
pdfPage: '',
pdfX: '',
pdfY: '',
pdfWidth: '',
pdfHeight: '',
pdfCoordinateSystem: 'TOP_LEFT' as 'TOP_LEFT' | 'PDF_BOTTOM_LEFT',
})
/** 类型下拉的取值(原型 fpKind 的 5 个选项) */
const KIND_OPTIONS = Object.entries(SIGNING_FIELD_KIND_LABELS) as Array<
[SigningFieldKind, string]
>
const KIND_OPTIONS = Object.entries(SIGNING_FIELD_KIND_LABELS) as Array<[SigningFieldKind, string]>
/** 选项行与高危值行只在选项类出现(原型 docFpKind) */
const isOptionKind = computed(
() => fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox',
)
const isOptionKind = computed(() => fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox')
function syncDraft(field: SigningFieldDef | null) {
fieldDraft.label = field?.label ?? ''
@@ -148,6 +151,12 @@ function syncDraft(field: SigningFieldDef | null) {
// 缺省口径与 signing-fields 的 SigningFieldDef.multi 一致:勾选类默认可多选
fieldDraft.multi = field?.multi ?? field?.kind === 'checkbox'
fieldDraft.risk = field?.risk ?? ''
fieldDraft.pdfPage = field?.pdfPosition ? String(field.pdfPosition.page) : ''
fieldDraft.pdfX = field?.pdfPosition ? String(field.pdfPosition.x) : ''
fieldDraft.pdfY = field?.pdfPosition ? String(field.pdfPosition.y) : ''
fieldDraft.pdfWidth = field?.pdfPosition ? String(field.pdfPosition.width) : ''
fieldDraft.pdfHeight = field?.pdfPosition ? String(field.pdfPosition.height) : ''
fieldDraft.pdfCoordinateSystem = field?.pdfPosition?.coordinateSystem ?? 'TOP_LEFT'
}
/**
@@ -167,7 +176,11 @@ watch(
watch(
() => fieldDraft.kind,
(kind) => {
if ((kind === 'choice' || kind === 'checkbox') && !fieldDraft.options.length && kind === 'choice') {
if (
(kind === 'choice' || kind === 'checkbox') &&
!fieldDraft.options.length &&
kind === 'choice'
) {
fieldDraft.options = ['否', '是']
}
},
@@ -186,6 +199,7 @@ watch(
// 升级沿用同一套解析口径,字段键前后完全一致,历史 fieldAnswers 不受影响;
// 正文里没有 { 时这一步原样返回,不会白白做一次 DOM 往返。
valueHtml.value = upgradeFieldTokensToStructured(rawContent)
needConfirm.value = Boolean(props.template?.needConfirm)
message.text = ''
message.tone = ''
selectedFieldId.value = ''
@@ -238,6 +252,33 @@ function applyFieldDraft() {
const options = fieldDraft.options.map((option) => option.trim()).filter(Boolean)
let pdfPosition: SigningFieldDef['pdfPosition']
if (field.id === 'signature' && fieldDraft.kind === 'signature') {
const page = Number(fieldDraft.pdfPage)
const x = Number(fieldDraft.pdfX)
const y = Number(fieldDraft.pdfY)
const width = Number(fieldDraft.pdfWidth)
const height = Number(fieldDraft.pdfHeight)
if (
!fieldDraft.pdfPage.trim() ||
!fieldDraft.pdfX.trim() ||
!fieldDraft.pdfY.trim() ||
!fieldDraft.pdfWidth.trim() ||
!fieldDraft.pdfHeight.trim() ||
![page, x, y, width, height].every(Number.isFinite) ||
!Number.isInteger(page) ||
page < 1 ||
x < 0 ||
y < 0 ||
width <= 0 ||
height <= 0
) {
setMessage('患者签名位置须填写有效页码及 PDF point 坐标', 'warn')
return
}
pdfPosition = { page, x, y, width, height, coordinateSystem: fieldDraft.pdfCoordinateSystem }
}
if (isOptionKind.value && new Set(options).size !== options.length) {
setMessage('存在重复选项,请修改后重试', 'warn')
return
@@ -261,6 +302,7 @@ function applyFieldDraft() {
options,
multi: options.length ? fieldDraft.multi : undefined,
risk: fieldDraft.risk.trim() || undefined,
pdfPosition,
})
if (!changed) {
@@ -307,17 +349,20 @@ const stats = computed(() => {
* 只挡"确定是错的":选项类少于两个选项、同名占位符写重复。
* 不挡"没有签名位"这类业务判断——那属于提示,不该拦住保存。
*/
function validate(): string | null {
function validate(requireSignaturePosition = true): string | null {
const broken = detectedFields.value.find(
(field) =>
(field.kind === 'choice' || field.kind === 'checkbox') &&
field.options.length === 1,
(field) => (field.kind === 'choice' || field.kind === 'checkbox') && field.options.length === 1,
)
if (broken) {
return `「${broken.label}」只配了一个选项,勾选类至少要两个(或删掉选项改成单个勾选框)`
}
const patientSignature = detectedFields.value.find((field) => field.id === 'signature')
if (requireSignaturePosition && !patientSignature?.pdfPosition) {
return '请选中“患者签名”占位符,配置其 PDF 页码和签名位置后再保存'
}
return null
}
@@ -342,7 +387,7 @@ const previewing = ref(false)
function togglePreview() {
if (!previewing.value) {
const error = validate()
const error = validate(false)
if (error) {
setMessage(error, 'warn')
@@ -418,7 +463,11 @@ function save() {
return
}
emit('save', { id: props.template.id, contentHtml: valueHtml.value })
emit('save', {
id: props.template.id,
contentHtml: valueHtml.value,
needConfirm: needConfirm.value,
})
}
/** 关闭前先退出预览态:预览里正文是只读渲染,直接关掉会让人以为编辑内容没了 */
@@ -431,7 +480,12 @@ function requestClose() {
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" role="presentation" @click.self="requestClose">
<section class="word-editor" role="dialog" aria-modal="true" aria-labelledby="word-editor-title">
<section
class="word-editor"
role="dialog"
aria-modal="true"
aria-labelledby="word-editor-title"
>
<header class="dialog-header">
<div class="dialog-title">
<h2 id="word-editor-title">编辑 Word 文书</h2>
@@ -464,13 +518,23 @@ function requestClose() {
<button type="button" class="action-button" @click="togglePreview">
{{ previewing ? '✏️ 返回编辑' : '👁 签署预览' }}
</button>
<span class="editor-stats">正文 {{ stats.chars }} 字 · {{ stats.fields }} 个填写项</span>
<span class="editor-stats"
>正文 {{ stats.chars }} 字 · {{ stats.fields }} 个填写项</span
>
<label class="confirm-setting">
<input v-model="needConfirm" type="checkbox" />
患者签署后需科室确认
</label>
<span class="editor-tip">
勾选项支持配置多个选项(可多选);导入 Word 后表格「勾选」列自动转为交互勾选项
</span>
</div>
<p v-if="message.text" class="editor-message" :class="message.tone ? `is-${message.tone}` : ''">
<p
v-if="message.text"
class="editor-message"
:class="message.tone ? `is-${message.tone}` : ''"
>
{{ message.text }}
</p>
@@ -525,9 +589,44 @@ function requestClose() {
</label>
</div>
<div
v-if="selectedField.id === 'signature' && fieldDraft.kind === 'signature'"
class="fp-row signature-position-row"
>
<b class="fp-title">患者签名 PDF 位置</b>
<span class="fp-hint">PDF point;页码从 1 开始</span>
<label class="fp-field"
><span>页码</span><input v-model="fieldDraft.pdfPage" inputmode="numeric"
/></label>
<label class="fp-field"
><span>X</span><input v-model="fieldDraft.pdfX" inputmode="decimal"
/></label>
<label class="fp-field"
><span>Y</span><input v-model="fieldDraft.pdfY" inputmode="decimal"
/></label>
<label class="fp-field"
><span>宽</span><input v-model="fieldDraft.pdfWidth" inputmode="decimal"
/></label>
<label class="fp-field"
><span>高</span><input v-model="fieldDraft.pdfHeight" inputmode="decimal"
/></label>
<label class="fp-field">
<span>坐标系</span>
<select v-model="fieldDraft.pdfCoordinateSystem">
<option value="TOP_LEFT">左上为原点</option>
<option value="PDF_BOTTOM_LEFT">左下为原点</option>
</select>
</label>
</div>
<div v-if="isOptionKind" class="fp-row">
<span v-for="(_, index) in fieldDraft.options" :key="index" class="option-item">
<input v-model="fieldDraft.options[index]" type="text" maxlength="40" placeholder="选项内容" />
<input
v-model="fieldDraft.options[index]"
type="text"
maxlength="40"
placeholder="选项内容"
/>
<button
type="button"
class="option-remove"
@@ -183,8 +183,7 @@ const filteredTemplates = computed(() => {
return allTemplates.value.filter((template) => {
const matchesDepartment = !filters.department || template.departmentKey === filters.department
const matchesStatus =
filters.status === 'all' || readLibraryStatus(template) === filters.status
const matchesStatus = filters.status === 'all' || readLibraryStatus(template) === filters.status
// 搜索范围照原型:模板名 / 编号 / 科室 / 分类,外加检查类型与关联项目编码。
// 后两者是卡片上看得见的字段,搜不到会让人以为这份文书不存在。
const matchesKeyword =
@@ -710,6 +709,7 @@ function updateLocalContent(form: DocumentContentForm) {
}
located.document.contentHtml = form.contentHtml
located.document.needConfirm = form.needConfirm
located.document.updatedAt = new Date().toISOString()
}
@@ -739,7 +739,10 @@ async function handleContentSubmit(form: DocumentContentForm) {
// 新建的文书还没有任何版本:第一个版本用 createTemplateWithContent 的版本那一步等价路径,
// 这里统一走 createTemplateVersion(见 updateTemplateContent),
// 后端会把它当成该模板的第一个版本
await updateTemplateContent(form.id, { contentHtml: form.contentHtml })
await updateTemplateContent(form.id, {
contentHtml: form.contentHtml,
needConfirm: form.needConfirm,
})
closeContentDialog()
await loadLibrary()
ElMessage.success('正文已保存为新版本')
@@ -62,6 +62,7 @@ export interface DocumentContentForm {
id: string
/** 正文 HTML(含结构化占位符 <span data-field>) */
contentHtml: string
needConfirm: boolean
}
/**