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
+25 -5
View File
@@ -136,19 +136,37 @@ export function readVersionContentHtml(dto: TemplateVersionResponseDto | null):
/**
* 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。
*
* 后端版本 DTO 的 signatureFields 字段承载「这份模板有哪些填写项」,
* 正文本身仍以 contentHtml 提交。不提交这个字段的话,后端拿不到字段口径,
* 也就无法在签署侧做必填校验与证据固化。
* 正文填写项和 PDF 签名几何是两个独立契约:formFields 负责字段语义,
* signatureFields 只负责患者签名图在服务端生成 PDF 中的位置。
*/
export function buildSignatureFieldsPayload(contentHtml: string): unknown[] {
const field = collectFieldDefs(contentHtml).find((item) => item.id === 'signature')
if (!field || field.kind !== 'signature') {
throw new Error('请在正文中插入“患者签名”占位符')
}
if (!field.pdfPosition) {
throw new Error('请先为“患者签名”配置 PDF 页码和签名位置')
}
return [
{
id: field.id,
page: field.pdfPosition.page,
x: field.pdfPosition.x,
y: field.pdfPosition.y,
width: field.pdfPosition.width,
height: field.pdfPosition.height,
coordinateSystem: field.pdfPosition.coordinateSystem,
},
]
}
export function buildFormFieldsPayload(contentHtml: string): unknown[] {
return collectFieldDefs(contentHtml).map((field) => ({
id: field.id,
label: field.label,
kind: field.kind,
required: field.required,
options: field.options,
// 单/多选必须一起上报:后端靠它渲染签署控件与做取值校验,
// 缺了这个字段就只能按 kind 猜,多选组会被当成单个勾选框
...(field.multi === undefined ? {} : { multi: field.multi }),
...(field.risk ? { risk: field.risk } : {}),
}))
@@ -519,6 +537,7 @@ export async function createTemplateWithContent(
await createTemplateVersion(template.id, {
...version,
formFields: buildFormFieldsPayload(version.contentHtml),
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
@@ -539,6 +558,7 @@ export async function updateTemplateContent(
): Promise<void> {
await createTemplateVersion(templateId, {
...version,
formFields: buildFormFieldsPayload(version.contentHtml),
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
}
+2
View File
@@ -149,6 +149,8 @@ export interface CreateTemplateVersionRequest {
versionNo?: string
contentHtml: string
signatureFields?: unknown[]
formFields?: unknown[]
needConfirm?: boolean
fileStorageKey?: string
fileRelativePath?: string
fileSizeBytes?: number
+20 -5
View File
@@ -10,8 +10,15 @@ import { mockFlags } from '@/utils/mock-flags'
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
import { request } from '@/utils/request'
import { ALL_SIGNING_METHODS, formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method'
import { resendSigningSms as resendSigningSmsDelivery } from './deliveries'
import {
ALL_SIGNING_METHODS,
formatSigningMethod,
toBackendSigningMethod,
} from '@/utils/signing-method'
import {
resendSigningSms as resendSigningSmsDelivery,
sendSigningSms as sendSigningSmsDelivery,
} from './deliveries'
import type {
ApiPageResponse,
@@ -666,6 +673,7 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing
supportedMethods: [...ALL_SIGNING_METHODS],
// 需科室确认随模板带出;后端没给就是 false(签完直接归档)
needConfirm: dto.needConfirm ?? false,
formFields: dto.formFields ?? [],
// 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回
contentSha256: dto.contentSha256,
contentHtml: cachedContent || undefined,
@@ -757,7 +765,7 @@ function mapSigningTask(
documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`,
department: dto.visitSnapshot.departmentName || '未指定科室',
departmentId: dto.departmentId,
signerName: dto.patientSnapshot.name,
signerName: dto.signerName ?? dto.patientSnapshot.name,
// 签署人类型 / 关系:后端补齐后自动带出,取不到即 undefined,列表不显示代签标签
signerType: dto.signerType ?? undefined,
signerRelation: dto.signerRelation ?? undefined,
@@ -1183,7 +1191,11 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise<
visitId: input.visitId,
templateVersionId: input.templateVersionId,
signMethod: toBackendSigningMethod(input.method),
idempotencyKey: createIdempotencyKey(),
idempotencyKey: input.idempotencyKey || createIdempotencyKey(),
staffAnswers: input.staffAnswers ?? {},
signerType: input.signerType ?? 'SELF',
signerName: input.signerName,
signerRelation: input.signerRelation ?? (input.signerType === 'FAMILY' ? undefined : '本人'),
}
const response = await request.post<ApiResponseOf<SignTaskResponseDto>>(
'/v1/sign-tasks',
@@ -1283,7 +1295,10 @@ export function sendSigningSms(
mobile: string | null,
): Promise<SigningTaskRecord | null> {
if (!useMockData) {
throw new Error('当前后端未提供发送签署短信接口')
if (!mobile) return Promise.reject(new Error('请填写完整手机号后发送短信'))
return sendSigningSmsDelivery(id, { destination: mobile, templateCode: 'SIGN_LINK' }).then(() =>
getSigningTaskDetail(id),
)
}
const task = mockRecords.find((record) => record.id === id)
+35 -7
View File
@@ -27,13 +27,7 @@ export interface WorkbenchOverviewRequestDto {
* `confirming` 只能下载患者已签版本。
*/
export type SigningTaskStatus =
| 'pending'
| 'signing'
| 'confirming'
| 'signed'
| 'expired'
| 'void'
| 'failed'
'pending' | 'signing' | 'confirming' | 'signed' | 'expired' | 'void' | 'failed'
export type PatientSex = '男' | '女' | '未知'
@@ -87,6 +81,8 @@ export interface SigningTemplate {
* 发起签署时随文书带到任务上,决定签完落「待科室确认」还是「已签署」。
*/
needConfirm?: boolean
/** 医护端发起时必须采集的结构化正文填写项。 */
formFields?: SigningFormField[]
/** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */
contentHtml?: string
/**
@@ -97,6 +93,16 @@ export interface SigningTemplate {
contentSha256?: string
}
export interface SigningFormField {
id: string
label: string
kind: SigningFieldKind
required: boolean
options?: string[]
multi?: boolean
risk?: string
}
export interface WorkbenchSummary {
todaySigned: number
todaySignedChange: number
@@ -219,6 +225,15 @@ export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | '30d' | 'al
*/
export type SigningFieldKind = 'text' | 'date' | 'choice' | 'checkbox' | 'signature'
export interface SigningPdfPosition {
page: number
x: number
y: number
width: number
height: number
coordinateSystem: 'TOP_LEFT' | 'PDF_BOTTOM_LEFT'
}
/** 文书里的一个填写项定义,由模板内容中的占位符解析得到。 */
export interface SigningFieldDef {
/** 稳定字段键:与 HIS / 患者快照取值一一对应,不随占位符显示文字变化 */
@@ -242,6 +257,8 @@ export interface SigningFieldDef {
multi?: boolean
/** 取值等于该字符串时视为异常项(如风险筛查选了"是") */
risk?: string
/** 患者签名位在服务端生成 PDF 中的位置,单位为 PDF point。 */
pdfPosition?: SigningPdfPosition
}
/**
@@ -366,6 +383,11 @@ export interface CreateSigningTaskInput {
documentContentHtml?: string
/** 由文书模板带出的「需科室确认」标记;真实接口不提交(后端按 templateVersionId 取)。 */
needConfirm?: boolean
staffAnswers?: Record<string, string>
signerType?: 'SELF' | 'FAMILY'
signerName?: string
signerRelation?: string
idempotencyKey?: string
}
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
@@ -375,6 +397,10 @@ export interface CreateSigningTaskRequest {
templateVersionId: string
signMethod: BackendSigningMethod
idempotencyKey?: string
staffAnswers?: Record<string, string>
signerType?: 'SELF' | 'FAMILY'
signerName?: string
signerRelation?: string
}
export interface SigningApiOptions {
@@ -460,6 +486,7 @@ export interface AvailableTemplateVersionResponseDto {
effectiveAt: string
/** 患者签署后是否需要科室复核签章;后端补齐后前端无需改动即可带出。 */
needConfirm?: boolean | null
formFields?: SigningFormField[] | null
}
export interface SignTaskPatientSnapshotDto {
@@ -512,6 +539,7 @@ export interface SignTaskResponseDto {
* 后端补齐后前端无需改动即可显示;当前取不到时为 undefined。
*/
signerType?: string | null
signerName?: string | null
signerRelation?: string | null
/** 患者签署后是否需要科室复核签章(由文书模板标记带出)。 */
needConfirm?: boolean | null
@@ -49,10 +49,18 @@ const {
selectMethod,
selectTemplate,
selectVisit,
selectedTemplate,
selectedTemplateId,
selectedVisit,
selectedVisitId,
smsDestination,
staffAnswers,
manualFormFields,
signerType,
signerName,
signerRelation,
staffAnswerHasOption,
toggleStaffAnswerOption,
submit: submitForm,
submitting,
suggestionOpen,
@@ -128,7 +136,11 @@ async function submitTask(keepOpen: boolean) {
emit('created', task)
const smsMessage =
task.method === 'sms' ? (isSigningMockEnabled ? ',已生成短信投递意图' : ',短信已发送') : ''
task.method === 'sms'
? isSigningMockEnabled
? ',已生成短信投递意图'
: ',短信投递已排队'
: ''
ElMessage.success(`已创建 1 个签署任务${smsMessage}`)
if (keepOpen) {
@@ -140,8 +152,8 @@ async function submitTask(keepOpen: boolean) {
}
emit('close')
} catch {
ElMessage.error('签署任务创建失败,请稍后重试')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '签署任务创建失败,请稍后重试')
}
}
@@ -404,7 +416,10 @@ watch(
type="button"
class="method-option"
:class="{ 'method-option--active': method === option.key }"
:disabled="!isMethodAllowed(option.key)"
:disabled="
!isMethodAllowed(option.key) ||
(!isSigningMockEnabled && (option.key === 'wx' || option.key === 'ipad'))
"
@click="selectMethod(option.key)"
>
<span class="method-icon">{{ option.icon }}</span>
@@ -445,9 +460,88 @@ watch(
</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">
@@ -882,6 +976,80 @@ watch(
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 {
@@ -1292,6 +1460,10 @@ watch(
}
@media (max-width: 1100px) {
.signing-context-grid,
.staff-answer-fields {
grid-template-columns: 1fr;
}
.document-method-grid {
flex-direction: column;
}
@@ -1,11 +1,8 @@
import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { computed, reactive, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
import { ElMessage } from 'element-plus'
import { sendSigningSms } from '@/api/workbench/deliveries'
import {
buildLinkedPrescription,
type LinkedPrescription,
} from '@/api/workbench/prescriptions'
import { buildLinkedPrescription, type LinkedPrescription } from '@/api/workbench/prescriptions'
import { collectFieldDefs } from '@/utils/signing-fields'
import {
createSigningTask,
@@ -18,6 +15,7 @@ import type {
PatientProfile,
PatientVisit,
SigningMethod,
SigningFormField,
SigningTaskRecord,
SigningTemplate,
WorkbenchCampus,
@@ -54,6 +52,14 @@ export interface DocumentListRow {
export type { LinkedPrescription }
export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
const newIdempotencyKey = () =>
`clinical-web-${
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
}`
let createTaskIdempotencyKey = newIdempotencyKey()
let sendSmsIdempotencyKey = newIdempotencyKey()
const patientId = ref('')
const patient = ref<PatientProfile | null>(null)
const visits = ref<PatientVisit[]>([])
@@ -63,6 +69,10 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
const selectedVisitId = ref('')
const method = ref<SigningMethod>('pad')
const smsDestination = ref('')
const staffAnswers = reactive<Record<string, string>>({})
const signerType = ref<'SELF' | 'FAMILY'>('SELF')
const signerName = ref('')
const signerRelation = ref('')
const loadingTemplates = ref(false)
const locating = ref(false)
const submitting = ref(false)
@@ -81,6 +91,8 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
visit: '',
document: '',
sms: '',
staffAnswers: '',
signer: '',
})
/** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */
@@ -94,6 +106,26 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
)
const manualFormFields = computed(() => {
const systemFields = new Set([
'patient',
'sex',
'age',
'visit',
'phone',
'doctor',
'signedDate',
])
return (selectedTemplate.value?.formFields ?? []).filter(
(field) => field.kind !== 'signature' && !systemFields.has(field.id),
)
})
watch(selectedTemplateId, () => {
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
errors.staffAnswers = ''
})
/** 关联处方:随选中就诊带出(仅演示数据) */
const prescription = computed<LinkedPrescription | null>(() =>
isSigningMockEnabled ? buildLinkedPrescription(selectedVisit.value) : null,
@@ -302,6 +334,8 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
errors.visit = ''
errors.document = ''
errors.sms = ''
errors.staffAnswers = ''
errors.signer = ''
}
function resetForm() {
@@ -315,12 +349,18 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedVisitId.value = ''
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
smsDestination.value = ''
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
signerType.value = 'SELF'
signerName.value = ''
signerRelation.value = ''
phonePlain.value = false
suggestionOpen.value = false
selectedDepartment.value = ''
selectedCategory.value = ''
expandedDepartments.value = []
clearErrors()
createTaskIdempotencyKey = newIdempotencyKey()
sendSmsIdempotencyKey = newIdempotencyKey()
}
function resetForNextTask() {
@@ -330,8 +370,14 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedTemplateId.value = initialTemplate?.id ?? ''
method.value = initialTemplate?.supportedMethods[0] ?? method.value
smsDestination.value = ''
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
signerType.value = 'SELF'
signerName.value = ''
signerRelation.value = ''
errors.document = ''
errors.sms = ''
createTaskIdempotencyKey = newIdempotencyKey()
sendSmsIdempotencyKey = newIdempotencyKey()
initDocumentTree()
}
@@ -481,6 +527,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
* 演示模板一律声明全部四种;真实模式由文档库配置决定。
*/
function isMethodAllowed(nextMethod: SigningMethod) {
if (!isSigningMockEnabled && (nextMethod === 'wx' || nextMethod === 'ipad')) {
return false
}
const template = selectedTemplate.value
if (!template) {
@@ -511,6 +560,18 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
return `${value.slice(0, 3)}****${value.slice(-4)}`
}
function toggleStaffAnswerOption(fieldId: string, option: string, checked: boolean) {
const selected = (staffAnswers[fieldId] ?? '').split('、').filter(Boolean)
const next = checked
? [...new Set([...selected, option])]
: selected.filter((item) => item !== option)
staffAnswers[fieldId] = next.join('、')
}
function staffAnswerHasOption(fieldId: string, option: string) {
return (staffAnswers[fieldId] ?? '').split('、').includes(option)
}
function validate() {
clearErrors()
let valid = true
@@ -542,6 +603,22 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
}
}
const missingField = manualFormFields.value.find(
(field: SigningFormField) => field.required && !staffAnswers[field.id]?.trim(),
)
if (missingField) {
errors.staffAnswers = `请填写必填项:${missingField.label}`
valid = false
}
if (
signerType.value === 'FAMILY' &&
(!signerName.value.trim() || !signerRelation.value.trim())
) {
errors.signer = '家属代签必须填写签署人姓名和与患者关系'
valid = false
}
return valid
}
@@ -567,13 +644,29 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
documentContentHtml: selectedTemplate.value.contentHtml,
// 需科室确认由文书模板标记带出(原型 V6.9 起弹窗里不再让人选)
needConfirm: selectedTemplate.value.needConfirm ?? false,
staffAnswers: { ...staffAnswers },
signerType: signerType.value,
signerName: signerType.value === 'FAMILY' ? signerName.value.trim() : patient.value.name,
signerRelation: signerType.value === 'FAMILY' ? signerRelation.value.trim() : '本人',
idempotencyKey: createTaskIdempotencyKey,
})
if (!isSigningMockEnabled && method.value === 'sms') {
await sendSigningSms(task.id, {
destination: smsDestination.value.trim().replace(/\s+/g, ''),
templateCode: 'SIGN_LINK',
})
try {
await sendSigningSms(
task.id,
{
destination: smsDestination.value.trim().replace(/\s+/g, ''),
templateCode: 'SIGN_LINK',
},
sendSmsIdempotencyKey,
)
} catch {
throw new Error(
`签署任务 ${task.id} 已创建,但短信投递失败。请在工作台任务详情中重试发送。`,
)
}
sendSmsIdempotencyKey = newIdempotencyKey()
}
return task
@@ -612,6 +705,13 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedVisit,
selectedVisitId,
smsDestination,
staffAnswers,
manualFormFields,
signerType,
signerName,
signerRelation,
staffAnswerHasOption,
toggleStaffAnswerOption,
submit,
submitting,
suggestionOpen,
+4 -5
View File
@@ -98,6 +98,7 @@ export interface SignFieldElement {
fieldOptions: string[]
fieldMulti?: boolean
fieldRisk?: string
fieldPdfPosition?: SigningFieldDef['pdfPosition']
/** void 元素也要有一个空文本子节点,否则 Slate 会认为结构非法 */
children: [{ text: '' }]
}
@@ -118,6 +119,7 @@ function buildSignFieldProps(field: SigningFieldDef): Omit<SignFieldElement, 'ty
fieldOptions: [...field.options],
fieldMulti: field.multi,
fieldRisk: field.risk,
fieldPdfPosition: field.pdfPosition ? { ...field.pdfPosition } : undefined,
}
}
@@ -205,6 +207,7 @@ export function readFieldFromSignFieldNode(elem: SignFieldElement): SigningField
options: [...elem.fieldOptions],
...(elem.fieldMulti === undefined ? {} : { multi: elem.fieldMulti }),
...(elem.fieldRisk ? { risk: elem.fieldRisk } : {}),
...(elem.fieldPdfPosition ? { pdfPosition: { ...elem.fieldPdfPosition } } : {}),
}
}
@@ -232,11 +235,7 @@ function isSelected(editor: IDomEditor, elemNode: unknown): boolean {
* 编辑器内渲染:一个只靠属性展示的胶囊。
* 不渲染 children,也不放文本节点,理由见文件头的 ③。
*/
function renderSignField(
elemNode: unknown,
_children: VNode[] | null,
editor: IDomEditor,
): VNode {
function renderSignField(elemNode: unknown, _children: VNode[] | null, editor: IDomEditor): VNode {
const elem = elemNode as SignFieldElement
const kindLabel = SIGNING_FIELD_KIND_LABELS[elem.fieldKind] ?? '文本'
const selected = isSelected(editor, elemNode)
+43 -6
View File
@@ -48,6 +48,12 @@ export const FIELD_REQUIRED_ATTR = 'data-required'
export const FIELD_OPTIONS_ATTR = 'data-options'
export const FIELD_MULTI_ATTR = 'data-multi'
export const FIELD_RISK_ATTR = 'data-risk'
export const FIELD_PDF_PAGE_ATTR = 'data-pdf-page'
export const FIELD_PDF_X_ATTR = 'data-pdf-x'
export const FIELD_PDF_Y_ATTR = 'data-pdf-y'
export const FIELD_PDF_WIDTH_ATTR = 'data-pdf-width'
export const FIELD_PDF_HEIGHT_ATTR = 'data-pdf-height'
export const FIELD_PDF_COORDINATE_SYSTEM_ATTR = 'data-pdf-coordinate-system'
/** 结构化占位符的选择器:渲染端与解析端必须用同一个。 */
export const FIELD_ELEMENT_SELECTOR = `${FIELD_ELEMENT_TAG}[${FIELD_ID_ATTR}]`
@@ -81,6 +87,15 @@ export function createFieldElement(doc: Document, field: SigningFieldDef): HTMLE
element.setAttribute(FIELD_RISK_ATTR, field.risk)
}
if (field.pdfPosition) {
element.setAttribute(FIELD_PDF_PAGE_ATTR, String(field.pdfPosition.page))
element.setAttribute(FIELD_PDF_X_ATTR, String(field.pdfPosition.x))
element.setAttribute(FIELD_PDF_Y_ATTR, String(field.pdfPosition.y))
element.setAttribute(FIELD_PDF_WIDTH_ATTR, String(field.pdfPosition.width))
element.setAttribute(FIELD_PDF_HEIGHT_ATTR, String(field.pdfPosition.height))
element.setAttribute(FIELD_PDF_COORDINATE_SYSTEM_ATTR, field.pdfPosition.coordinateSystem)
}
element.textContent = field.label
return element
@@ -109,10 +124,35 @@ export function readFieldFromElement(element: Element): SigningFieldDef | null {
// 没有 data-field 时用标签反查目录,签名位这类字段至少还能还原成正确的控件
const resolved = id ? null : resolveFieldByLabel(label)
const pdfPositionValues = [
element.getAttribute(FIELD_PDF_PAGE_ATTR),
element.getAttribute(FIELD_PDF_X_ATTR),
element.getAttribute(FIELD_PDF_Y_ATTR),
element.getAttribute(FIELD_PDF_WIDTH_ATTR),
element.getAttribute(FIELD_PDF_HEIGHT_ATTR),
]
const pdfNumbers = pdfPositionValues.map((value) => Number(value))
const hasPdfPosition = pdfPositionValues.every((value) => value !== null && value.trim() !== '')
const pdfCoordinateSystem = element.getAttribute(FIELD_PDF_COORDINATE_SYSTEM_ATTR)
const pdfPosition =
hasPdfPosition && pdfNumbers.every(Number.isFinite)
? {
page: pdfNumbers[0],
x: pdfNumbers[1],
y: pdfNumbers[2],
width: pdfNumbers[3],
height: pdfNumbers[4],
coordinateSystem:
pdfCoordinateSystem === 'PDF_BOTTOM_LEFT'
? ('PDF_BOTTOM_LEFT' as const)
: ('TOP_LEFT' as const),
}
: undefined
return {
id: id || (resolved as SigningFieldDef).id,
label: label || id,
kind: ((element.getAttribute(FIELD_KIND_ATTR) ?? resolved?.kind ?? 'text') as SigningFieldKind),
kind: (element.getAttribute(FIELD_KIND_ATTR) ?? resolved?.kind ?? 'text') as SigningFieldKind,
required:
element.getAttribute(FIELD_REQUIRED_ATTR) === 'true' ||
(!element.hasAttribute(FIELD_REQUIRED_ATTR) && resolved?.required === true),
@@ -122,6 +162,7 @@ export function readFieldFromElement(element: Element): SigningFieldDef | null {
.filter(Boolean),
multi: readMultiAttribute(element, resolved),
risk: element.getAttribute(FIELD_RISK_ATTR)?.trim() || resolved?.risk || undefined,
pdfPosition,
}
}
@@ -375,11 +416,7 @@ export interface ScannedFieldToken {
* 只对自定义文本项有意义(调用方保证),目录里的字段同名共用一份取值是刻意的。
* claimed 是有限集合、next 严格递增,所以循环一定收敛。
*/
function nextFreeCustomSequence(
label: string,
sequence: number,
claimed: Set<string>,
): number {
function nextFreeCustomSequence(label: string, sequence: number, claimed: Set<string>): number {
let next = sequence
while (claimed.has(createCustomField(label, 'text', [], next).id)) {
@@ -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
}
/**
@@ -15,6 +15,7 @@ import type { SigningTaskRecord } from '@/api/workbench/types'
const props = defineProps<{
visible: boolean
task: SigningTaskRecord | null
isMock: boolean
}>()
const emit = defineEmits<{
@@ -38,7 +39,7 @@ watch(
const masked = props.task?.smsMobileMasked?.trim() ?? ''
registeredMasked.value = masked
inputValue.value = masked
inputValue.value = props.isMock ? masked : ''
error.value = ''
},
)
@@ -54,8 +55,11 @@ function submit() {
}
if (unchanged.value) {
// 登记号码本来就是脱敏的,前端拿不到完整值 —— 直接交给后端按任务解析
emit('send', null)
if (props.isMock) {
emit('send', null)
return
}
error.value = '请填写完整手机号;登记号码仅展示脱敏值,不能直接用于投递'
return
}
@@ -108,7 +112,8 @@ function submit() {
<p v-if="error" class="field-error">{{ error }}</p>
<p v-else class="field-hint">
不改号码即发送到登记号码;发送后任务进入「等待患者签署」,链接 48 小时内有效。
{{ isMock ? '演示模式可保留登记号码;' : '真实模式需输入完整手机号;' }}
短信投递排队后,任务进入「等待患者签署」。
</p>
</div>
@@ -44,7 +44,11 @@ import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import { PRIMARY_SIGNATURE_FIELD_ID } from '@/utils/signing-fields'
import { formatSigningMethodTitle, readSigningMethod, toggleSigningMethod } from '@/utils/signing-method'
import {
formatSigningMethodTitle,
readSigningMethod,
toggleSigningMethod,
} from '@/utils/signing-method'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
@@ -833,7 +837,7 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'短信已重新发送',
'短信投递已重新排队',
)
return
}
@@ -1036,7 +1040,7 @@ async function handleSmsSent(mobile: string | null) {
smsSendVisible.value = false
await refreshTask(
updated,
`签署短信已发送${updated?.smsMobileMasked ? `至 ${updated.smsMobileMasked}` : ''},等待患者签署`,
`签署短信投递已排队${updated?.smsMobileMasked ? `至 ${updated.smsMobileMasked}` : ''},等待患者签署`,
)
} catch {
ElMessage.error('发送签署短信失败,请稍后重试')
@@ -1180,6 +1184,7 @@ onMounted(() => {
<SmsSendDialog
:visible="smsSendVisible"
:task="selectedTask"
:is-mock="isSigningMockEnabled"
@close="smsSendVisible = false"
@send="handleSmsSent"
/>