feat(签署): 支持文书填写项与签名回显
This commit is contained in:
@@ -23,6 +23,7 @@ import type {
|
|||||||
SignTaskEventResponseDto,
|
SignTaskEventResponseDto,
|
||||||
SignTaskResponseDto,
|
SignTaskResponseDto,
|
||||||
SignTaskSendPreparationResponseDto,
|
SignTaskSendPreparationResponseDto,
|
||||||
|
SigningAnswers,
|
||||||
SigningApiOptions,
|
SigningApiOptions,
|
||||||
SigningMethod,
|
SigningMethod,
|
||||||
SigningTaskEvent,
|
SigningTaskEvent,
|
||||||
@@ -351,6 +352,8 @@ function cloneTask(task: SigningTaskRecord): SigningTaskRecord {
|
|||||||
...task,
|
...task,
|
||||||
// 勾选结果是数组,浅拷贝会让调用方和内存数据共享同一个引用
|
// 勾选结果是数组,浅拷贝会让调用方和内存数据共享同一个引用
|
||||||
selectedItems: task.selectedItems ? [...task.selectedItems] : task.selectedItems,
|
selectedItems: task.selectedItems ? [...task.selectedItems] : task.selectedItems,
|
||||||
|
// 填写项取值同理,调用方会就地改动它
|
||||||
|
fieldAnswers: task.fieldAnswers ? { ...task.fieldAnswers } : task.fieldAnswers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,6 +1011,7 @@ export function completeSigningTask(
|
|||||||
id: string,
|
id: string,
|
||||||
signatureDataUrl: string,
|
signatureDataUrl: string,
|
||||||
selectedItems: string[] = [],
|
selectedItems: string[] = [],
|
||||||
|
fieldAnswers: SigningAnswers = {},
|
||||||
): Promise<SigningTaskRecord | null> {
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
throw new Error('真实签署需要通过签署投递与文件上传接口完成')
|
throw new Error('真实签署需要通过签署投递与文件上传接口完成')
|
||||||
@@ -1025,6 +1029,8 @@ export function completeSigningTask(
|
|||||||
task.updatedAt = task.signedAt
|
task.updatedAt = task.signedAt
|
||||||
// 勾选结果随签署一起固化,否则重新打开文书会丢掉患者勾了哪些项
|
// 勾选结果随签署一起固化,否则重新打开文书会丢掉患者勾了哪些项
|
||||||
task.selectedItems = [...selectedItems]
|
task.selectedItems = [...selectedItems]
|
||||||
|
// 填写项取值(含签名图)随签署一起固化:重新打开文书时按原位置回显
|
||||||
|
task.fieldAnswers = { ...fieldAnswers, signature: signatureDataUrl }
|
||||||
persistMockRecords()
|
persistMockRecords()
|
||||||
return Promise.resolve(cloneTask(task))
|
return Promise.resolve(cloneTask(task))
|
||||||
}
|
}
|
||||||
@@ -1050,8 +1056,9 @@ export async function reopenSigningTask(
|
|||||||
task.status = 'pending'
|
task.status = 'pending'
|
||||||
task.signatureDataUrl = undefined
|
task.signatureDataUrl = undefined
|
||||||
task.signedAt = undefined
|
task.signedAt = undefined
|
||||||
// 重新发起等于重新签,上一次的勾选不再沿用
|
// 重新发起等于重新签,上一次的勾选与填写项取值都不再沿用
|
||||||
task.selectedItems = []
|
task.selectedItems = []
|
||||||
|
task.fieldAnswers = undefined
|
||||||
task.updatedAt = formatMockDate()
|
task.updatedAt = formatMockDate()
|
||||||
persistMockRecords()
|
persistMockRecords()
|
||||||
return Promise.resolve(cloneTask(task))
|
return Promise.resolve(cloneTask(task))
|
||||||
|
|||||||
@@ -167,6 +167,31 @@ export interface SigningTaskQuery extends PageQuery {
|
|||||||
|
|
||||||
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
|
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文书填写项的控件类型。
|
||||||
|
* signature 与其他类型并列:签名是文书上的一个填写项,不是特殊的文本替换。
|
||||||
|
*/
|
||||||
|
export type SigningFieldKind = 'text' | 'date' | 'choice' | 'checkbox' | 'signature'
|
||||||
|
|
||||||
|
/** 文书里的一个填写项定义,由模板内容中的占位符解析得到。 */
|
||||||
|
export interface SigningFieldDef {
|
||||||
|
/** 稳定字段键:与 HIS / 患者快照取值一一对应,不随占位符显示文字变化 */
|
||||||
|
id: string
|
||||||
|
/** 文书中显示的文字 */
|
||||||
|
label: string
|
||||||
|
kind: SigningFieldKind
|
||||||
|
required: boolean
|
||||||
|
/** choice / checkbox 的可选项 */
|
||||||
|
options: string[]
|
||||||
|
/** 取值等于该字符串时视为异常项(如风险筛查选了"是") */
|
||||||
|
risk?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SigningFieldValue = string | boolean
|
||||||
|
|
||||||
|
/** 填写项取值,key 为 SigningFieldDef.id。 */
|
||||||
|
export type SigningAnswers = Record<string, SigningFieldValue>
|
||||||
|
|
||||||
export interface SigningTaskRecord extends WorkbenchTask {
|
export interface SigningTaskRecord extends WorkbenchTask {
|
||||||
templateVersionId: string
|
templateVersionId: string
|
||||||
campus: WorkbenchCampus
|
campus: WorkbenchCampus
|
||||||
@@ -192,6 +217,11 @@ export interface SigningTaskRecord extends WorkbenchTask {
|
|||||||
documentContentHtml?: string
|
documentContentHtml?: string
|
||||||
/** 原型:患者勾选结果,随签署一起留存(对应真实实现里随签署证据提交的勾选摘要)。 */
|
/** 原型:患者勾选结果,随签署一起留存(对应真实实现里随签署证据提交的勾选摘要)。 */
|
||||||
selectedItems?: string[]
|
selectedItems?: string[]
|
||||||
|
/**
|
||||||
|
* 文书填写项取值(含签名图),签署时固化。
|
||||||
|
* 重新打开文书时用它回显,签名因此落在文书原本的签名位上。
|
||||||
|
*/
|
||||||
|
fieldAnswers?: SigningAnswers
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||||
|
|||||||
@@ -1,31 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* 签署文书 HTML 渲染管线(原型)。
|
* 文书渲染管线(原型)。
|
||||||
*
|
*
|
||||||
* 把「文书定稿 HTML」按签署时的效果做一次变换:
|
* 把「模板定稿 HTML」渲染成签署态:
|
||||||
* ① 占位符填充({患者姓名}等 → 患者与就诊快照数据);
|
* ① 填写项渲染:占位符按控件类型渲染成文本/日期/单选/勾选/签名控件,
|
||||||
* ② 签名域:已有签名图时替换 {患者签名},否则给出待签署提示;
|
* 初值来自 HIS 与患者就诊快照,签署时可在文书上直接确认或补录;
|
||||||
* ③ 勾选表识别:表头含「勾选」列的表格,逐行转为复选框;
|
* ② 签名回显:签名是 kind=signature 的填写项,签名图作为该字段的取值,
|
||||||
* ④ 行内选项识别:正文手打的 □ 方框(□否 □是)转为单选/多选框。
|
* 签完后落在文书原本的签名位上,不需要"替换文字"这一步;
|
||||||
|
* ③ 勾选表识别(存量兼容):表头含「勾选」列的表格逐行转为复选框;
|
||||||
|
* ④ 行内选项识别(存量兼容):正文手打的 □ 方框转成单选/多选框。
|
||||||
*
|
*
|
||||||
* 签署任务详情(SigningDocumentInteractive)与文书库的「签署预览」共用本模块,
|
* ③④ 是为已经存在的存量文书保留的启发式识别;用占位符写的新文书不再依赖它们。
|
||||||
* 避免两处各自演化后渲染结果不一致。真实实现中填充在后端完成、勾选结果随签署证据
|
*
|
||||||
* 提交,此处仅用于前端闭环验证。
|
* 渲染所需的取值模型见 signing-fields.ts;真实实现中填充与固化在后端完成,
|
||||||
|
* 此处仅用于前端闭环验证。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 渲染所需的患者/就诊快照字段;SigningTaskRecord 与预览用示例数据都满足此形状。 */
|
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
|
||||||
export interface SigningDocumentData {
|
|
||||||
patientName: string
|
import {
|
||||||
sex: string
|
createFieldTokenPattern,
|
||||||
age: number | string
|
FIELD_TOKEN_OPEN,
|
||||||
visitNo: string
|
resolveAnswersFromSystem,
|
||||||
visitDate: string
|
resolveFieldByLabel,
|
||||||
signedAt?: string
|
type SigningSystemSnapshot,
|
||||||
signatureDataUrl?: string
|
} from './signing-fields'
|
||||||
/** 已勾选的条目名:签署完成后重新打开文书时用来还原勾选状态 */
|
|
||||||
|
/** 渲染所需的患者/就诊快照 + 已填写的填写项取值。 */
|
||||||
|
export interface SigningDocumentData extends SigningSystemSnapshot {
|
||||||
|
/** 已签署的存量文书里,由「勾选表」收集的条目 */
|
||||||
selectedItems?: string[]
|
selectedItems?: string[]
|
||||||
|
/**
|
||||||
|
* 填写项取值:已填过的值优先于系统快照(用于签署后回显)。
|
||||||
|
* 属性名与 SigningTaskRecord.fieldAnswers 一致,避免任务对象透传时对不上而静默丢值。
|
||||||
|
*/
|
||||||
|
fieldAnswers?: SigningAnswers
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIGNATURE_PLACEHOLDER = '{患者签名}'
|
/** 填写项在文书上的宿主节点属性,签署端靠它收集取值 */
|
||||||
|
const FIELD_ATTR = 'data-sign-field'
|
||||||
|
const FIELD_KIND_ATTR = 'data-sign-kind'
|
||||||
|
const FIELD_LABEL_ATTR = 'data-sign-label'
|
||||||
|
const FIELD_REQUIRED_ATTR = 'data-sign-required'
|
||||||
|
const FIELD_RISK_ATTR = 'data-sign-risk'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。
|
* 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。
|
||||||
@@ -41,69 +57,224 @@ const OPTION_TEST_PATTERN = new RegExp(`[${OPTION_MARKERS}]`)
|
|||||||
/** 判定「同一组选项」的块级容器:同一段、同一单元格里的方框视为一组 */
|
/** 判定「同一组选项」的块级容器:同一段、同一单元格里的方框视为一组 */
|
||||||
const OPTION_BLOCK_SELECTOR = 'p, li, td, th, div, h1, h2, h3, h4, h5, h6'
|
const OPTION_BLOCK_SELECTOR = 'p, li, td, th, div, h1, h2, h3, h4, h5, h6'
|
||||||
|
|
||||||
/** 收集所有包含指定关键字(占位符前缀等)的文本节点 */
|
/** 一个填写项在文书上的取值状态,用于签署端提示必填与异常 */
|
||||||
function collectTextNodes(root: Element, keyword: string): Text[] {
|
export interface SigningFieldProgress {
|
||||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
total: number
|
||||||
|
filled: number
|
||||||
|
/** 未填写的必填项标签 */
|
||||||
|
missingRequired: string[]
|
||||||
|
/** 取值命中风险的项标签 */
|
||||||
|
abnormal: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldWidget(
|
||||||
|
doc: Document,
|
||||||
|
field: SigningFieldDef,
|
||||||
|
answers: SigningAnswers,
|
||||||
|
interactive: boolean,
|
||||||
|
): HTMLElement {
|
||||||
|
const value = answers[field.id]
|
||||||
|
const abnormal =
|
||||||
|
Boolean(field.risk) && typeof value === 'string' && value === (field.risk as string)
|
||||||
|
|
||||||
|
const host = doc.createElement('span')
|
||||||
|
host.className = `sign-field sign-field--${field.kind}${abnormal ? ' sign-field--abnormal' : ''}`
|
||||||
|
host.setAttribute(FIELD_ATTR, field.id)
|
||||||
|
host.setAttribute(FIELD_KIND_ATTR, field.kind)
|
||||||
|
host.setAttribute(FIELD_LABEL_ATTR, field.label)
|
||||||
|
host.setAttribute(FIELD_REQUIRED_ATTR, field.required ? 'true' : 'false')
|
||||||
|
|
||||||
|
if (field.risk) {
|
||||||
|
host.setAttribute(FIELD_RISK_ATTR, field.risk)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = doc.createElement('span')
|
||||||
|
label.className = 'sign-field__label'
|
||||||
|
label.textContent = field.label
|
||||||
|
|
||||||
|
if (field.required) {
|
||||||
|
const mark = doc.createElement('span')
|
||||||
|
mark.className = 'sign-field__required'
|
||||||
|
mark.textContent = '*'
|
||||||
|
label.appendChild(mark)
|
||||||
|
}
|
||||||
|
|
||||||
|
host.appendChild(label)
|
||||||
|
|
||||||
|
const control = doc.createElement('span')
|
||||||
|
control.className = 'sign-field__control'
|
||||||
|
control.appendChild(buildFieldControl(doc, field, value, interactive))
|
||||||
|
host.appendChild(control)
|
||||||
|
|
||||||
|
if (abnormal) {
|
||||||
|
const badge = doc.createElement('span')
|
||||||
|
badge.className = 'sign-field__badge'
|
||||||
|
badge.textContent = '异常项'
|
||||||
|
host.appendChild(badge)
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldControl(
|
||||||
|
doc: Document,
|
||||||
|
field: SigningFieldDef,
|
||||||
|
value: SigningAnswers[string],
|
||||||
|
interactive: boolean,
|
||||||
|
): Node {
|
||||||
|
const textValue = typeof value === 'string' ? value : value ? '是' : ''
|
||||||
|
|
||||||
|
if (field.kind === 'signature') {
|
||||||
|
if (textValue) {
|
||||||
|
const image = doc.createElement('img')
|
||||||
|
image.className = 'sign-doc-signature'
|
||||||
|
image.src = textValue
|
||||||
|
image.alt = field.label
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = doc.createElement('span')
|
||||||
|
empty.className = 'sign-field__signature-empty'
|
||||||
|
empty.textContent = `待${field.label}`
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.kind === 'choice') {
|
||||||
|
const group = doc.createElement('span')
|
||||||
|
group.className = 'sign-field__choices'
|
||||||
|
|
||||||
|
field.options.forEach((option) => {
|
||||||
|
const wrapper = doc.createElement('label')
|
||||||
|
wrapper.className = 'sign-doc-option'
|
||||||
|
|
||||||
|
const input = doc.createElement('input')
|
||||||
|
input.className = 'sign-field__radio'
|
||||||
|
input.type = 'radio'
|
||||||
|
input.name = `sign-field-${field.id}`
|
||||||
|
input.value = option
|
||||||
|
input.disabled = !interactive
|
||||||
|
|
||||||
|
// 必须同时落成 attribute:innerHTML 只序列化 HTML 属性,
|
||||||
|
// 只设 .checked 这个 property 会在生成 HTML 时被丢掉
|
||||||
|
if (textValue === option) {
|
||||||
|
input.checked = true
|
||||||
|
input.setAttribute('checked', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.appendChild(input)
|
||||||
|
wrapper.appendChild(doc.createTextNode(option))
|
||||||
|
group.appendChild(wrapper)
|
||||||
|
})
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.kind === 'checkbox') {
|
||||||
|
const wrapper = doc.createElement('label')
|
||||||
|
wrapper.className = 'sign-doc-option'
|
||||||
|
|
||||||
|
const input = doc.createElement('input')
|
||||||
|
input.className = 'sign-field__checkbox'
|
||||||
|
input.type = 'checkbox'
|
||||||
|
input.disabled = !interactive
|
||||||
|
|
||||||
|
if (value === true) {
|
||||||
|
input.checked = true
|
||||||
|
input.setAttribute('checked', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.appendChild(input)
|
||||||
|
wrapper.appendChild(doc.createTextNode(field.label))
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = doc.createElement('input')
|
||||||
|
input.className = 'sign-field__input'
|
||||||
|
input.type = field.kind === 'date' ? 'date' : 'text'
|
||||||
|
input.value = textValue
|
||||||
|
// value 属性同理:只设 property 的话序列化出来是空值
|
||||||
|
input.setAttribute('value', textValue)
|
||||||
|
input.readOnly = !interactive
|
||||||
|
input.disabled = !interactive
|
||||||
|
if (interactive) {
|
||||||
|
input.placeholder = '请填写'
|
||||||
|
}
|
||||||
|
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 占位符渲染:把 {患者姓名} 就地替换成填写项控件,保留同一文本节点内的前后文字。
|
||||||
|
* 模板未收录的占位符按自定义文本项处理,仍可在文书上填写,
|
||||||
|
* 避免出现"写了占位符但签署时既填不上也看不见"的情况。
|
||||||
|
*/
|
||||||
|
function renderTokenFields(root: Element, answers: SigningAnswers, interactive: boolean) {
|
||||||
|
const doc = root.ownerDocument
|
||||||
|
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
const targets: Text[] = []
|
const targets: Text[] = []
|
||||||
let node = walker.nextNode()
|
let node = walker.nextNode()
|
||||||
|
|
||||||
while (node) {
|
while (node) {
|
||||||
if ((node.textContent ?? '').includes(keyword)) {
|
if ((node.textContent ?? '').includes(FIELD_TOKEN_OPEN)) {
|
||||||
targets.push(node as Text)
|
targets.push(node as Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
node = walker.nextNode()
|
node = walker.nextNode()
|
||||||
}
|
}
|
||||||
|
|
||||||
return targets
|
targets.forEach((textNode) => {
|
||||||
}
|
const text = textNode.textContent ?? ''
|
||||||
|
const pattern = createFieldTokenPattern()
|
||||||
|
const fragment = doc.createDocumentFragment()
|
||||||
|
let cursor = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
function replaceTextPlaceholder(root: Element, placeholder: string, replacement: string) {
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
collectTextNodes(root, placeholder).forEach((textNode) => {
|
if (match.index > cursor) {
|
||||||
textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(replacement)
|
fragment.appendChild(doc.createTextNode(text.slice(cursor, match.index)))
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 {患者签名} 就地换成签名图片,保留同一文本节点内的前后文字 */
|
fragment.appendChild(
|
||||||
function replaceSignaturePlaceholder(root: Element, dataUrl: string) {
|
buildFieldWidget(doc, resolveFieldByLabel(match[1] ?? ''), answers, interactive),
|
||||||
collectTextNodes(root, SIGNATURE_PLACEHOLDER).forEach((textNode) => {
|
)
|
||||||
const parts = (textNode.textContent ?? '').split(SIGNATURE_PLACEHOLDER)
|
|
||||||
const fragment = document.createDocumentFragment()
|
|
||||||
|
|
||||||
if (parts[0]) {
|
cursor = pattern.lastIndex
|
||||||
fragment.appendChild(document.createTextNode(parts[0]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = document.createElement('img')
|
if (cursor < text.length) {
|
||||||
image.src = dataUrl
|
fragment.appendChild(doc.createTextNode(text.slice(cursor)))
|
||||||
image.alt = '患者签名'
|
|
||||||
image.className = 'sign-doc-signature'
|
|
||||||
fragment.appendChild(image)
|
|
||||||
|
|
||||||
if (parts[1]) {
|
|
||||||
fragment.appendChild(document.createTextNode(parts[1]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textNode.replaceWith(fragment)
|
textNode.replaceWith(fragment)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillPatientPlaceholders(root: Element, data: SigningDocumentData) {
|
/**
|
||||||
const replacements: Record<string, string> = {
|
* 结构化占位符渲染:兼容 <span data-field="..."> 写法。
|
||||||
'{患者姓名}': data.patientName,
|
* 该写法是目标格式(字段语义显式、不依赖中文标签),
|
||||||
'{性别}': data.sex,
|
* 供后端模板或后续换编辑器后生产的内容使用。
|
||||||
'{年龄}': `${data.age} 岁`,
|
*/
|
||||||
'{就诊号}': data.visitNo,
|
function renderStructuredFields(root: Element, answers: SigningAnswers, interactive: boolean) {
|
||||||
'{联系电话}': '—',
|
const doc = root.ownerDocument
|
||||||
'{预约日期}': data.visitDate,
|
|
||||||
'{与患者关系}': '本人',
|
|
||||||
'{签署日期}': data.signedAt || data.visitDate || ' 年 月 日',
|
|
||||||
}
|
|
||||||
|
|
||||||
collectTextNodes(root, '{').forEach((textNode) => {
|
root.querySelectorAll('span[data-field]').forEach((element) => {
|
||||||
Object.entries(replacements).forEach(([placeholder, value]) => {
|
const id = element.getAttribute('data-field') ?? ''
|
||||||
textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(value)
|
const label = element.getAttribute('data-label') ?? id
|
||||||
})
|
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: SigningFieldDef = {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
kind: (element.getAttribute('data-kind') ?? 'text') as SigningFieldKind,
|
||||||
|
required: element.getAttribute('data-required') === 'true',
|
||||||
|
options: (element.getAttribute('data-options') ?? '').split('|').filter(Boolean),
|
||||||
|
risk: element.getAttribute('data-risk') ?? undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
element.replaceWith(buildFieldWidget(doc, field, answers, interactive))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +336,7 @@ function convertCheckboxes(root: Element, interactive: boolean, selected: Set<st
|
|||||||
|
|
||||||
/** 收集所有含方框符号的文本节点 */
|
/** 收集所有含方框符号的文本节点 */
|
||||||
function collectOptionTextNodes(root: Element): Text[] {
|
function collectOptionTextNodes(root: Element): Text[] {
|
||||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
const targets: Text[] = []
|
const targets: Text[] = []
|
||||||
let node = walker.nextNode()
|
let node = walker.nextNode()
|
||||||
|
|
||||||
@@ -192,14 +363,14 @@ function replaceOptionMarkers(
|
|||||||
selected: Set<string>,
|
selected: Set<string>,
|
||||||
) {
|
) {
|
||||||
const parts = (textNode.textContent ?? '').split(OPTION_SPLIT_PATTERN)
|
const parts = (textNode.textContent ?? '').split(OPTION_SPLIT_PATTERN)
|
||||||
const fragment = document.createDocumentFragment()
|
const fragment = textNode.ownerDocument.createDocumentFragment()
|
||||||
|
|
||||||
for (let index = 0; index < parts.length; index += 1) {
|
for (let index = 0; index < parts.length; index += 1) {
|
||||||
const part = parts[index] ?? ''
|
const part = parts[index] ?? ''
|
||||||
|
|
||||||
if (!OPTION_TEST_PATTERN.test(part)) {
|
if (!OPTION_TEST_PATTERN.test(part)) {
|
||||||
if (part) {
|
if (part) {
|
||||||
fragment.appendChild(document.createTextNode(part))
|
fragment.appendChild(textNode.ownerDocument.createTextNode(part))
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
@@ -210,7 +381,7 @@ function replaceOptionMarkers(
|
|||||||
const label = labelMatch?.[1] ?? ''
|
const label = labelMatch?.[1] ?? ''
|
||||||
const tail = labelMatch?.[2] ?? ''
|
const tail = labelMatch?.[2] ?? ''
|
||||||
|
|
||||||
const input = document.createElement('input')
|
const input = textNode.ownerDocument.createElement('input')
|
||||||
input.type = type
|
input.type = type
|
||||||
input.className = 'sign-doc-checkbox'
|
input.className = 'sign-doc-checkbox'
|
||||||
input.disabled = !interactive
|
input.disabled = !interactive
|
||||||
@@ -227,15 +398,15 @@ function replaceOptionMarkers(
|
|||||||
input.value = label
|
input.value = label
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapper = document.createElement('label')
|
const wrapper = textNode.ownerDocument.createElement('label')
|
||||||
wrapper.className = 'sign-doc-option'
|
wrapper.className = 'sign-doc-option'
|
||||||
wrapper.appendChild(input)
|
wrapper.appendChild(input)
|
||||||
wrapper.appendChild(document.createTextNode(label))
|
wrapper.appendChild(textNode.ownerDocument.createTextNode(label))
|
||||||
|
|
||||||
fragment.appendChild(wrapper)
|
fragment.appendChild(wrapper)
|
||||||
|
|
||||||
if (tail) {
|
if (tail) {
|
||||||
fragment.appendChild(document.createTextNode(tail))
|
fragment.appendChild(textNode.ownerDocument.createTextNode(tail))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选项名已随方框一起消费,跳过它对应的片段
|
// 选项名已随方框一起消费,跳过它对应的片段
|
||||||
@@ -246,7 +417,7 @@ function replaceOptionMarkers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 行内选项识别:正文里手打的方框(□否 □是、□同意 □不同意)。
|
* 行内选项识别(存量兼容):正文里手打的方框(□否 □是、□同意 □不同意)。
|
||||||
* 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选;
|
* 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选;
|
||||||
* 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。
|
* 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。
|
||||||
*/
|
*/
|
||||||
@@ -286,7 +457,7 @@ function convertInlineOptions(root: Element, interactive: boolean, selected: Set
|
|||||||
/**
|
/**
|
||||||
* 生成签署态文书 HTML。
|
* 生成签署态文书 HTML。
|
||||||
* contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。
|
* contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。
|
||||||
* interactive 为 false(预览场景)时复选框只读展示。
|
* interactive 为 false(预览 / 已签署查看)时填写项只读展示取值。
|
||||||
*/
|
*/
|
||||||
export function buildSigningDocumentHtml(
|
export function buildSigningDocumentHtml(
|
||||||
contentHtml: string,
|
contentHtml: string,
|
||||||
@@ -307,19 +478,104 @@ export function buildSigningDocumentHtml(
|
|||||||
return contentHtml
|
return contentHtml
|
||||||
}
|
}
|
||||||
|
|
||||||
fillPatientPlaceholders(root, data)
|
// 已填写的取值优先于系统快照:签署后被固化的填写项要保持原样回显
|
||||||
|
const answers: SigningAnswers = {
|
||||||
if (data.signatureDataUrl) {
|
...resolveAnswersFromSystem(data),
|
||||||
replaceSignaturePlaceholder(root, data.signatureDataUrl)
|
...(data.fieldAnswers ?? {}),
|
||||||
} else {
|
|
||||||
replaceTextPlaceholder(root, SIGNATURE_PLACEHOLDER, '(待患者签署)')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已签署的文书再次打开时,把上次记录的勾选状态还原出来
|
if (data.signatureDataUrl && !answers.signature) {
|
||||||
const selected = new Set(data.selectedItems ?? [])
|
answers.signature = data.signatureDataUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStructuredFields(root, answers, interactive)
|
||||||
|
renderTokenFields(root, answers, interactive)
|
||||||
|
|
||||||
|
const selected = new Set(data.selectedItems ?? [])
|
||||||
convertCheckboxes(root, interactive, selected)
|
convertCheckboxes(root, interactive, selected)
|
||||||
convertInlineOptions(root, interactive, selected)
|
convertInlineOptions(root, interactive, selected)
|
||||||
|
|
||||||
return root.innerHTML
|
return root.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从渲染后的文书 DOM 里读回填写项取值。
|
||||||
|
* 签名读的是签名图,其余按控件类型读,返回结果直接可作为签署证据的一部分固化。
|
||||||
|
*/
|
||||||
|
export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
|
||||||
|
const answers: SigningAnswers = {}
|
||||||
|
|
||||||
|
root.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`).forEach((host) => {
|
||||||
|
const id = host.getAttribute(FIELD_ATTR)
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = (host.getAttribute(FIELD_KIND_ATTR) ?? 'text') as SigningFieldKind
|
||||||
|
|
||||||
|
if (kind === 'signature') {
|
||||||
|
answers[id] = host.querySelector('img')?.getAttribute('src') ?? ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'choice') {
|
||||||
|
const checked = host.querySelector<HTMLInputElement>('input[type="radio"]:checked')
|
||||||
|
answers[id] = checked?.value ?? ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'checkbox') {
|
||||||
|
const box = host.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||||
|
answers[id] = Boolean(box?.checked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answers[id] = host.querySelector<HTMLInputElement>('input')?.value ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return answers
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计填写项进度:已填数量、未填的必填项、命中的异常项 */
|
||||||
|
export function describeFieldProgress(
|
||||||
|
root: HTMLElement,
|
||||||
|
answers: SigningAnswers,
|
||||||
|
): SigningFieldProgress {
|
||||||
|
const progress: SigningFieldProgress = {
|
||||||
|
total: 0,
|
||||||
|
filled: 0,
|
||||||
|
missingRequired: [],
|
||||||
|
abnormal: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`).forEach((host) => {
|
||||||
|
const id = host.getAttribute(FIELD_ATTR)
|
||||||
|
const label = host.getAttribute(FIELD_LABEL_ATTR) ?? id ?? ''
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = answers[id]
|
||||||
|
const empty = value === undefined || value === '' || value === false
|
||||||
|
const risk = host.getAttribute(FIELD_RISK_ATTR)
|
||||||
|
const isSignature = host.getAttribute(FIELD_KIND_ATTR) === 'signature'
|
||||||
|
|
||||||
|
progress.total += 1
|
||||||
|
|
||||||
|
if (!empty) {
|
||||||
|
progress.filled += 1
|
||||||
|
} else if (host.getAttribute(FIELD_REQUIRED_ATTR) === 'true' && !isSignature) {
|
||||||
|
// 签名必填但尚未采集,正是"点击签署"要完成的事,不能算作待补的必填项,
|
||||||
|
// 否则签名入口会把自己挡住。
|
||||||
|
progress.missingRequired.push(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (risk && value === risk) {
|
||||||
|
progress.abnormal.push(label)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 文书填写项(占位符)模型。
|
||||||
|
*
|
||||||
|
* 相比「把占位符当普通文字做字符串替换」,这里给每个占位符补上语义:
|
||||||
|
* ① 稳定字段键(id):与 HIS / 患者快照的取值一一对应,不再依赖中文标签匹配;
|
||||||
|
* ② 控件类型(kind):签名是与文本/日期/单选/勾选并列的一种填写项,
|
||||||
|
* 签完把取值写回同一字段即可在文书原位置回显;
|
||||||
|
* ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完;
|
||||||
|
* ④ 风险值(risk):签署取值命中即标记为异常项。
|
||||||
|
*
|
||||||
|
* 载体仍沿用既有文书里的 {患者姓名} 写法,原因见 buildFieldToken 的注释:
|
||||||
|
* 当前编辑器(WangEditor / Slate)无法无损往返带 data-* 属性的结构化节点,
|
||||||
|
* 而纯文本占位符可以,且存量模板零迁移。渲染管线同时支持 span[data-field] 写法,
|
||||||
|
* 后续换成支持结构化节点的编辑器时可以平滑升级。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
/** 占位符书写格式:{患者姓名} */
|
||||||
|
export const FIELD_TOKEN_OPEN = '{'
|
||||||
|
export const FIELD_TOKEN_CLOSE = '}'
|
||||||
|
|
||||||
|
const FIELD_TOKEN_SOURCE = `${FIELD_TOKEN_OPEN}([^${FIELD_TOKEN_OPEN}${FIELD_TOKEN_CLOSE}]{1,40})${FIELD_TOKEN_CLOSE}`
|
||||||
|
|
||||||
|
/** /g 正则有 lastIndex 副作用,每次解析都新建一个,避免跨调用互相干扰。 */
|
||||||
|
export function createFieldTokenPattern() {
|
||||||
|
return new RegExp(FIELD_TOKEN_SOURCE, 'g')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。
|
||||||
|
* id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。
|
||||||
|
*/
|
||||||
|
export const SIGNING_FIELD_PRESETS: SigningFieldDef[] = [
|
||||||
|
{ id: 'patient', label: '患者姓名', kind: 'text', required: true, options: [] },
|
||||||
|
{ id: 'sex', label: '性别', kind: 'text', required: false, options: [] },
|
||||||
|
{ id: 'age', label: '年龄', kind: 'text', required: false, options: [] },
|
||||||
|
{ id: 'visit', label: '就诊号', kind: 'text', required: false, options: [] },
|
||||||
|
{ id: 'phone', label: '联系电话', kind: 'text', required: false, options: [] },
|
||||||
|
{ id: 'appointment', label: '预约日期', kind: 'date', required: false, options: [] },
|
||||||
|
{ id: 'signedDate', label: '签署日期', kind: 'date', required: true, options: [] },
|
||||||
|
{ id: 'relationship', label: '与患者关系', kind: 'text', required: false, options: [] },
|
||||||
|
{ id: 'signature', label: '患者签名', kind: 'signature', required: true, options: [] },
|
||||||
|
{ id: 'doctor', label: '医师签名', kind: 'signature', required: false, options: [] },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一字段在不同文书里的常见写法。
|
||||||
|
* 各科室历史文书用词不统一(患者签名 / 患方签名 / 患者(或家属)签名),
|
||||||
|
* 归一到同一个字段键,取值才不会因为换了叫法就填不上。
|
||||||
|
*/
|
||||||
|
const FIELD_LABEL_ALIASES: Record<string, string> = {
|
||||||
|
'患者(或家属)签名': 'signature',
|
||||||
|
'患者/家属签名': 'signature',
|
||||||
|
患方签名: 'signature',
|
||||||
|
'患者/监护人签字': 'signature',
|
||||||
|
签字: 'signature',
|
||||||
|
'住院号/门诊号': 'visit',
|
||||||
|
'门诊/住院号': 'visit',
|
||||||
|
门诊号: 'visit',
|
||||||
|
住院号: 'visit',
|
||||||
|
手机号: 'phone',
|
||||||
|
预约日期: 'appointment',
|
||||||
|
日期: 'signedDate',
|
||||||
|
签名日期: 'signedDate',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_BY_ID = new Map(SIGNING_FIELD_PRESETS.map((field) => [field.id, field]))
|
||||||
|
const PRESET_BY_LABEL = new Map(SIGNING_FIELD_PRESETS.map((field) => [field.label, field]))
|
||||||
|
|
||||||
|
/** 自定义占位符的字段键前缀:label 不在目录里时按文本填写项处理 */
|
||||||
|
const CUSTOM_FIELD_PREFIX = 'custom:'
|
||||||
|
|
||||||
|
export function getPresetField(id: string): SigningFieldDef | null {
|
||||||
|
const preset = PRESET_BY_ID.get(id)
|
||||||
|
return preset ? { ...preset } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCustomField(
|
||||||
|
label: string,
|
||||||
|
kind: SigningFieldKind = 'text',
|
||||||
|
options: string[] = [],
|
||||||
|
): SigningFieldDef {
|
||||||
|
return {
|
||||||
|
id: `${CUSTOM_FIELD_PREFIX}${label}`,
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
required: false,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按占位符文字定位字段定义:先查目录,再查别名,都不是就当自定义文本项。
|
||||||
|
* 这样存量文书里写的是「患者(或家属)签名」也能命中签名控件。
|
||||||
|
*/
|
||||||
|
export function resolveFieldByLabel(label: string): SigningFieldDef {
|
||||||
|
const trimmed = label.trim()
|
||||||
|
const preset = PRESET_BY_LABEL.get(trimmed)
|
||||||
|
if (preset) {
|
||||||
|
return { ...preset }
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliasId = FIELD_LABEL_ALIASES[trimmed]
|
||||||
|
if (aliasId) {
|
||||||
|
const aliased = PRESET_BY_ID.get(aliasId)
|
||||||
|
if (aliased) {
|
||||||
|
return { ...aliased }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createCustomField(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成占位符文本。
|
||||||
|
*
|
||||||
|
* 用纯文本而不是 <span data-field>:WangEditor 是 Slate 模型,插入的 HTML 会被
|
||||||
|
* 重新解析,未注册的自定义元素会退化成纯文本、data-* 属性全部丢失,
|
||||||
|
* 导致「保存后再打开,占位符已经不是占位符」。纯文本可以无损往返,
|
||||||
|
* 且不需要为编辑器注册自定义节点,存量模板也不需要迁移。
|
||||||
|
*/
|
||||||
|
export function buildFieldToken(field: SigningFieldDef): string {
|
||||||
|
return `${FIELD_TOKEN_OPEN}${field.label}${FIELD_TOKEN_CLOSE}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析文书 HTML 里的全部占位符,按出现顺序去重。
|
||||||
|
* 同一个字段在文书里出现多次(如签名与日期在每页都出现)只算一个填写项。
|
||||||
|
*/
|
||||||
|
export function collectFieldDefs(contentHtml: string): SigningFieldDef[] {
|
||||||
|
if (!contentHtml) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const fields: SigningFieldDef[] = []
|
||||||
|
const pattern = createFieldTokenPattern()
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = pattern.exec(contentHtml)) !== null) {
|
||||||
|
const field = resolveFieldByLabel(match[1] ?? '')
|
||||||
|
if (!field.label || seen.has(field.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(field.id)
|
||||||
|
fields.push(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复) */
|
||||||
|
export function findDuplicateFields(contentHtml: string): SigningFieldDef[] {
|
||||||
|
if (!contentHtml) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = new Map<string, { field: SigningFieldDef; count: number }>()
|
||||||
|
const pattern = createFieldTokenPattern()
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = pattern.exec(contentHtml)) !== null) {
|
||||||
|
const field = resolveFieldByLabel(match[1] ?? '')
|
||||||
|
const entry = counts.get(field.id)
|
||||||
|
counts.set(field.id, { field, count: (entry?.count ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...counts.values()].filter((item) => item.count > 1).map((item) => item.field)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统侧取值来源(HIS / 患者与就诊快照)。
|
||||||
|
* 字段键与目录里的 id 对应,是「自动填充」的唯一入口。
|
||||||
|
*/
|
||||||
|
export interface SigningSystemSnapshot {
|
||||||
|
patientName?: string
|
||||||
|
sex?: string
|
||||||
|
age?: number | string
|
||||||
|
visitNo?: string
|
||||||
|
visitDate?: string
|
||||||
|
phone?: string
|
||||||
|
doctorName?: string
|
||||||
|
signedAt?: string
|
||||||
|
signatureDataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把患者/就诊快照映射成填写项初值,字段键即 SIGNING_FIELD_PRESETS 的 id。 */
|
||||||
|
export function resolveAnswersFromSystem(snapshot: SigningSystemSnapshot): SigningAnswers {
|
||||||
|
return {
|
||||||
|
patient: snapshot.patientName ?? '',
|
||||||
|
sex: snapshot.sex ?? '',
|
||||||
|
age: snapshot.age === undefined || snapshot.age === null ? '' : String(snapshot.age),
|
||||||
|
visit: snapshot.visitNo ?? '',
|
||||||
|
phone: snapshot.phone ?? '',
|
||||||
|
appointment: snapshot.visitDate ?? '',
|
||||||
|
// 签署日期是必填项,未签署前先给当天日期,符合"系统预填 + 现场确认"的业务预期;
|
||||||
|
// 真正确认的日期以签署完成时固化的取值(signedAt)为准
|
||||||
|
signedDate: (snapshot.signedAt ?? new Date().toISOString()).slice(0, 10),
|
||||||
|
signature: snapshot.signatureDataUrl ?? '',
|
||||||
|
// 医师签名与代理人关系必须由现场填写,不能由系统代填;
|
||||||
|
// 上一版把「与患者关系」恒填「本人」,等于把代理人签署这条路堵死了。
|
||||||
|
relationship: '',
|
||||||
|
doctor: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { buildSigningDocumentHtml } from '@/utils/signing-document'
|
import {
|
||||||
|
buildSigningDocumentHtml,
|
||||||
|
describeFieldProgress,
|
||||||
|
readAnswersFromRoot,
|
||||||
|
type SigningFieldProgress,
|
||||||
|
} from '@/utils/signing-document'
|
||||||
|
|
||||||
import { documentStatusLabels, signingPreviewPatient } from '../mock'
|
import { documentStatusLabels, signingPreviewPatient } from '../mock'
|
||||||
import type { DocumentTemplate } from '../types'
|
import type { DocumentTemplate } from '../types'
|
||||||
@@ -22,14 +27,30 @@ const emit = defineEmits<{
|
|||||||
const activeTab = ref<'content' | 'signing'>('content')
|
const activeTab = ref<'content' | 'signing'>('content')
|
||||||
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
|
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
|
||||||
const signingSelection = ref<string[]>([])
|
const signingSelection = ref<string[]>([])
|
||||||
|
/** 签署预览里填写项的完成情况,同样只用于预览反馈 */
|
||||||
|
const signingProgress = ref<SigningFieldProgress>({
|
||||||
|
total: 0,
|
||||||
|
filled: 0,
|
||||||
|
missingRequired: [],
|
||||||
|
abnormal: [],
|
||||||
|
})
|
||||||
const signingBodyRef = ref<HTMLElement | null>(null)
|
const signingBodyRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// 切到签署预览时才挂载文书 DOM,挂载后再统计填写项,否则进页面会显示「0 / 0 项」
|
||||||
|
watch(activeTab, async (tab) => {
|
||||||
|
if (tab === 'signing') {
|
||||||
|
await nextTick()
|
||||||
|
syncPreviewFields()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
activeTab.value = 'content'
|
activeTab.value = 'content'
|
||||||
signingSelection.value = []
|
signingSelection.value = []
|
||||||
|
signingProgress.value = { total: 0, filled: 0, missingRequired: [], abnormal: [] }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -98,20 +119,19 @@ const signingPreviewHtml = computed(() =>
|
|||||||
buildSigningDocumentHtml(props.template?.contentHtml ?? '', signingPreviewPatient, true),
|
buildSigningDocumentHtml(props.template?.contentHtml ?? '', signingPreviewPatient, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
|
// 填写项与勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
|
||||||
function handleSigningSelectionChange(event: Event) {
|
function syncPreviewFields() {
|
||||||
const target = event.target as HTMLInputElement
|
const body = signingBodyRef.value
|
||||||
|
|
||||||
if (!target.classList.contains('sign-doc-checkbox')) {
|
if (!body) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = signingBodyRef.value
|
const answers = readAnswersFromRoot(body)
|
||||||
const checked = body
|
signingProgress.value = describeFieldProgress(body, answers)
|
||||||
? Array.from(body.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
|
signingSelection.value = Array.from(
|
||||||
: []
|
body.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'),
|
||||||
|
).map((input) => input.getAttribute('data-sign-item') ?? '')
|
||||||
signingSelection.value = checked.map((input) => input.getAttribute('data-sign-item') ?? '')
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -179,14 +199,22 @@ function handleSigningSelectionChange(event: Event) {
|
|||||||
<div
|
<div
|
||||||
ref="signingBodyRef"
|
ref="signingBodyRef"
|
||||||
class="signing-preview"
|
class="signing-preview"
|
||||||
@change="handleSigningSelectionChange"
|
@change="syncPreviewFields"
|
||||||
|
@input="syncPreviewFields"
|
||||||
v-html="signingPreviewHtml"
|
v-html="signingPreviewHtml"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
<p class="signing-selection">
|
<p class="signing-selection">
|
||||||
已勾选 <strong>{{ signingSelection.length }}</strong> 项{{
|
填写项 <strong>{{ signingProgress.filled }}</strong> /
|
||||||
signingSelection.length ? `:${signingSelection.join('、')}` : ''
|
{{ signingProgress.total }} 项已填<template v-if="signingSelection.length">
|
||||||
}}
|
・已勾选 <strong>{{ signingSelection.length }}</strong> 项:{{
|
||||||
|
signingSelection.join('、')
|
||||||
|
}}</template
|
||||||
|
><template v-if="signingProgress.abnormal.length">
|
||||||
|
・异常项:<strong class="is-abnormal">{{
|
||||||
|
signingProgress.abnormal.join('、')
|
||||||
|
}}</strong></template
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<iframe
|
<iframe
|
||||||
@@ -473,6 +501,101 @@ function handleSigningSelectionChange(event: Event) {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 填写项:签署预览里按签署时的控件外观渲染,样式与签署端保持一致 */
|
||||||
|
.signing-preview :deep(.sign-field) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
margin: 0 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: #f7fbfd;
|
||||||
|
border: 1px solid #d4e3e8;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__label) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #587786;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__required) {
|
||||||
|
margin-left: 2px;
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__control) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__input) {
|
||||||
|
width: 9em;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
color: #1c2b33;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cfe0e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__input:focus) {
|
||||||
|
border-color: var(--brand);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__choices) {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__radio),
|
||||||
|
.signing-preview :deep(.sign-field__checkbox) {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__signature-empty) {
|
||||||
|
padding: 12px 18px;
|
||||||
|
color: #a96900;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #fff9ec;
|
||||||
|
border: 1px dashed #f0a21a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field--signature) {
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field--signature .sign-field__control) {
|
||||||
|
min-width: 150px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field--abnormal) {
|
||||||
|
background: #fdf0f0;
|
||||||
|
border-color: #efb9bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field--abnormal .sign-field__label) {
|
||||||
|
color: #a42e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signing-preview :deep(.sign-field__badge) {
|
||||||
|
padding: 1px 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--err);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.signing-selection {
|
.signing-selection {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
@@ -487,6 +610,10 @@ function handleSigningSelectionChange(event: Event) {
|
|||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signing-selection .is-abnormal {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-paper {
|
.preview-paper {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
padding: 26px 34px;
|
padding: 26px 34px;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||||
|
import {
|
||||||
|
buildFieldToken,
|
||||||
|
collectFieldDefs,
|
||||||
|
findDuplicateFields,
|
||||||
|
SIGNING_FIELD_PRESETS,
|
||||||
|
} from '@/utils/signing-fields'
|
||||||
|
|
||||||
import EditorWangPane from './editors/EditorWangPane.vue'
|
import EditorWangPane from './editors/EditorWangPane.vue'
|
||||||
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||||||
@@ -35,16 +41,9 @@ const importing = ref(false)
|
|||||||
const importMessage = ref('')
|
const importMessage = ref('')
|
||||||
const paneRef = ref<EditorPaneHandle | null>(null)
|
const paneRef = ref<EditorPaneHandle | null>(null)
|
||||||
|
|
||||||
// 占位符在签署时由系统按患者与就诊信息自动填充;签署交互管线同时会识别"勾选"表格。
|
// 占位符在签署时按字段键取 HIS 与患者就诊信息自动填充,其中签名是独立控件类型:
|
||||||
const placeholders = [
|
// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字。
|
||||||
'{患者姓名}',
|
// 目录见 utils/signing-fields 的 SIGNING_FIELD_PRESETS,与签署端渲染共用同一份。
|
||||||
'{性别}',
|
|
||||||
'{年龄}',
|
|
||||||
'{就诊号}',
|
|
||||||
'{签署日期}',
|
|
||||||
'{患者签名}',
|
|
||||||
'{与患者关系}',
|
|
||||||
]
|
|
||||||
|
|
||||||
const form = reactive<TemplateEditorForm>({
|
const form = reactive<TemplateEditorForm>({
|
||||||
templateCode: '',
|
templateCode: '',
|
||||||
@@ -108,6 +107,44 @@ function insertContent(text: string) {
|
|||||||
paneRef.value?.insertText(text)
|
paneRef.value?.insertText(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertField(field: (typeof SIGNING_FIELD_PRESETS)[number]) {
|
||||||
|
insertContent(buildFieldToken(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏 */
|
||||||
|
const detectedFields = computed(() => collectFieldDefs(valueHtml.value))
|
||||||
|
|
||||||
|
const placeholderHints = computed(() => {
|
||||||
|
const hints: string[] = []
|
||||||
|
const ids = new Set(detectedFields.value.map((field) => field.id))
|
||||||
|
|
||||||
|
if (!ids.has('signature')) {
|
||||||
|
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「患者签名」。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ids.has('signedDate')) {
|
||||||
|
hints.push('文书中没有签署日期占位符,建议插入「签署日期」以便留痕。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates = findDuplicateFields(valueHtml.value)
|
||||||
|
|
||||||
|
if (duplicates.length) {
|
||||||
|
hints.push(
|
||||||
|
`以下占位符出现多次:${duplicates.map((field) => field.label).join('、')}(同一字段共用一份取值)。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints
|
||||||
|
})
|
||||||
|
|
||||||
|
const kindLabels: Record<string, string> = {
|
||||||
|
text: '文本',
|
||||||
|
date: '日期',
|
||||||
|
choice: '单选',
|
||||||
|
checkbox: '勾选',
|
||||||
|
signature: '签名',
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDocxChange(event: Event) {
|
async function handleDocxChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
@@ -163,12 +200,7 @@ function save() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div v-if="visible" class="dialog-mask" role="presentation" @click.self="emit('close')">
|
||||||
v-if="visible"
|
|
||||||
class="dialog-mask"
|
|
||||||
role="presentation"
|
|
||||||
@click.self="emit('close')"
|
|
||||||
>
|
|
||||||
<section
|
<section
|
||||||
class="word-editor"
|
class="word-editor"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -206,7 +238,11 @@ function save() {
|
|||||||
<span>所属科室</span>
|
<span>所属科室</span>
|
||||||
<select v-model="form.departmentId">
|
<select v-model="form.departmentId">
|
||||||
<option value="">全院通用</option>
|
<option value="">全院通用</option>
|
||||||
<option v-for="department in availableDepartments" :key="department.id" :value="department.id">
|
<option
|
||||||
|
v-for="department in availableDepartments"
|
||||||
|
:key="department.id"
|
||||||
|
:value="department.id"
|
||||||
|
>
|
||||||
{{ department.name }}
|
{{ department.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -230,13 +266,15 @@ function save() {
|
|||||||
<span>插入签署占位符</span>
|
<span>插入签署占位符</span>
|
||||||
<div class="placeholder-row">
|
<div class="placeholder-row">
|
||||||
<button
|
<button
|
||||||
v-for="item in placeholders"
|
v-for="field in SIGNING_FIELD_PRESETS"
|
||||||
:key="item"
|
:key="field.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="placeholder-chip"
|
class="placeholder-chip"
|
||||||
@click="insertContent(item)"
|
:class="{ 'is-signature': field.kind === 'signature' }"
|
||||||
|
:title="`${kindLabels[field.kind]}字段;签署时按患者与就诊信息自动填充${field.required ? ',必填' : ''}`"
|
||||||
|
@click="insertField(field)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ field.label }}{{ field.required ? ' *' : '' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,6 +295,26 @@ function save() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="placeholder-check">
|
||||||
|
<div class="placeholder-check__head">
|
||||||
|
<strong>占位符检查</strong>
|
||||||
|
<span>{{ detectedFields.length }} 个填写项</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="detectedFields.length" class="placeholder-check__list">
|
||||||
|
<span v-for="field in detectedFields" :key="field.id" class="placeholder-check__item">
|
||||||
|
{{ field.label }}
|
||||||
|
<em>{{ kindLabels[field.kind] }}</em>
|
||||||
|
<i v-if="field.required">必填</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-else class="placeholder-check__empty">
|
||||||
|
文书中还没有任何填写项,签署时不会采集患者填写内容。
|
||||||
|
</p>
|
||||||
|
<ul v-if="placeholderHints.length" class="placeholder-check__hints">
|
||||||
|
<li v-for="hint in placeholderHints" :key="hint">{{ hint }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="editor-shell">
|
<div class="editor-shell">
|
||||||
<div class="docx-bar">
|
<div class="docx-bar">
|
||||||
<button
|
<button
|
||||||
@@ -277,7 +335,8 @@ function save() {
|
|||||||
/>
|
/>
|
||||||
<span v-if="importMessage" class="docx-message">{{ importMessage }}</span>
|
<span v-if="importMessage" class="docx-message">{{ importMessage }}</span>
|
||||||
<span v-else class="docx-hint">
|
<span v-else class="docx-hint">
|
||||||
建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。
|
建议从 Word/WPS
|
||||||
|
中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-area">
|
<div class="editor-area">
|
||||||
@@ -435,6 +494,77 @@ function save() {
|
|||||||
filter: brightness(0.95);
|
filter: brightness(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 签名类占位符用不同颜色区分:它决定签名落位,是文书里最不能漏的一项 */
|
||||||
|
.placeholder-chip.is-signature {
|
||||||
|
color: #a96900;
|
||||||
|
background: #fff4e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check {
|
||||||
|
padding: 10px 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__head strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__head span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__item {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 11.5px;
|
||||||
|
background: #f4f8fa;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__item em {
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__item i {
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-check__hints {
|
||||||
|
padding-left: 16px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-shell {
|
.editor-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -41,5 +41,6 @@ export const signingPreviewPatient: SigningDocumentData = {
|
|||||||
age: 46,
|
age: 46,
|
||||||
visitNo: 'V20260914001',
|
visitNo: 'V20260914001',
|
||||||
visitDate: '2026-09-14',
|
visitDate: '2026-09-14',
|
||||||
|
phone: '138****5678',
|
||||||
signedAt: '',
|
signedAt: '',
|
||||||
}
|
}
|
||||||
|
|||||||
+241
-41
@@ -1,12 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
|
||||||
import { buildSigningDocumentHtml } from '@/utils/signing-document'
|
import {
|
||||||
|
buildSigningDocumentHtml,
|
||||||
|
describeFieldProgress,
|
||||||
|
readAnswersFromRoot,
|
||||||
|
type SigningDocumentData,
|
||||||
|
type SigningFieldProgress,
|
||||||
|
} from '@/utils/signing-document'
|
||||||
|
|
||||||
// 签署文书交互化渲染:变换管线(占位符填充 / 签名域 / 勾选表转复选框)见
|
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
|
||||||
// utils/signing-document,与文书库的「签署预览」共用同一实现。
|
// utils/signing-document 与 utils/signing-fields,与文书库的「签署预览」共用同一实现。
|
||||||
// 本组件只负责挂载 HTML、收集勾选结果并渲染签署态外壳。
|
// 本组件只负责挂载 HTML、收集填写项取值并渲染签署态外壳。
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
task: SigningTaskRecord
|
task: SigningTaskRecord
|
||||||
@@ -17,32 +23,71 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'selection-change': [items: string[]]
|
'selection-change': [items: string[]]
|
||||||
|
'field-change': [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
const selectedItems = ref<string[]>([])
|
const selectedItems = ref<string[]>([])
|
||||||
|
const fieldAnswers = ref<SigningAnswers>({})
|
||||||
|
const progress = ref<SigningFieldProgress>({
|
||||||
|
total: 0,
|
||||||
|
filled: 0,
|
||||||
|
missingRequired: [],
|
||||||
|
abnormal: [],
|
||||||
|
})
|
||||||
|
|
||||||
// 任务自带勾选(已签署的文书)在挂载/切换任务时同步出来并上报,
|
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
|
||||||
// 否则父级拿到的是空数组,会当成"一项都没勾"
|
// 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
|
||||||
watch(
|
const documentData = computed<SigningDocumentData>(() => ({
|
||||||
() => props.task,
|
patientName: props.task.patientName,
|
||||||
(task) => {
|
sex: props.task.sex,
|
||||||
selectedItems.value = [...(task.selectedItems ?? [])]
|
age: props.task.age,
|
||||||
emit('selection-change', [...selectedItems.value])
|
visitNo: props.task.visitNo,
|
||||||
},
|
visitDate: props.task.visitDate,
|
||||||
{ immediate: true },
|
signedAt: props.task.signedAt,
|
||||||
)
|
signatureDataUrl: props.task.signatureDataUrl,
|
||||||
|
selectedItems: props.task.selectedItems,
|
||||||
|
fieldAnswers: props.task.fieldAnswers,
|
||||||
|
}))
|
||||||
|
|
||||||
const processedHtml = computed(() =>
|
const processedHtml = computed(() =>
|
||||||
buildSigningDocumentHtml(props.task.documentContentHtml ?? '', props.task, props.interactive),
|
buildSigningDocumentHtml(
|
||||||
|
props.task.documentContentHtml ?? '',
|
||||||
|
documentData.value,
|
||||||
|
props.interactive,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 文书里的填写项数量(含只读展示),用于提示"这份文书有几个填写项" */
|
||||||
|
const fieldTotal = computed(() => progress.value.total)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 兜底:文书 HTML 更新后,按任务记录再同步一次 DOM 的勾选状态。
|
* 从渲染后的 DOM 读回填写项取值。
|
||||||
* 生成 HTML 时已经写了 checked 属性,这里不依赖 attribute 在序列化中的表现,
|
* 生成 HTML 时已把取值写成 attribute,这里再按取值同步一次 DOM 状态,
|
||||||
* 确保「签完再打开时勾选还在」这件事稳定成立。
|
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
|
||||||
*/
|
*/
|
||||||
function applyStoredSelection() {
|
function syncFromDom() {
|
||||||
|
const container = containerRef.value
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = readAnswersFromRoot(container)
|
||||||
|
fieldAnswers.value = answers
|
||||||
|
progress.value = describeFieldProgress(container, answers)
|
||||||
|
|
||||||
|
const checked = Array.from(
|
||||||
|
container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'),
|
||||||
|
)
|
||||||
|
selectedItems.value = checked.map((item) => item.getAttribute('data-sign-item') ?? '')
|
||||||
|
|
||||||
|
emit('field-change', { ...answers }, { ...progress.value })
|
||||||
|
emit('selection-change', [...selectedItems.value])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按已固化的取值还原 DOM 控件状态(存量勾选表 + 新填写项都覆盖) */
|
||||||
|
function applyStoredValues() {
|
||||||
const container = containerRef.value
|
const container = containerRef.value
|
||||||
|
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -54,33 +99,64 @@ function applyStoredSelection() {
|
|||||||
container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox').forEach((input) => {
|
container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox').forEach((input) => {
|
||||||
input.checked = selected.has(input.getAttribute('data-sign-item') ?? '')
|
input.checked = selected.has(input.getAttribute('data-sign-item') ?? '')
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
watch(processedHtml, applyStoredSelection, { flush: 'post' })
|
const answers: SigningAnswers = { ...(props.task.fieldAnswers ?? {}) }
|
||||||
|
|
||||||
onMounted(applyStoredSelection)
|
if (props.task.signatureDataUrl && !answers.signature) {
|
||||||
|
answers.signature = props.task.signatureDataUrl
|
||||||
function handleSelectionChange(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
|
|
||||||
if (!target.classList.contains('sign-doc-checkbox')) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = containerRef.value
|
container.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((host) => {
|
||||||
const checked = container
|
const id = host.getAttribute('data-sign-field')
|
||||||
? Array.from(container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
|
const value = id ? answers[id] : undefined
|
||||||
: []
|
|
||||||
|
|
||||||
selectedItems.value = checked.map((item) => item.getAttribute('data-sign-item') ?? '')
|
if (id === undefined || value === undefined) {
|
||||||
emit('selection-change', [...selectedItems.value])
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
const checkbox = host.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = value
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = host.querySelector<HTMLInputElement>(
|
||||||
|
'input:not([type="radio"]):not([type="checkbox"])',
|
||||||
|
)
|
||||||
|
if (input) {
|
||||||
|
input.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLInputElement>('input[type="radio"]').forEach((radio) => {
|
||||||
|
radio.checked = radio.value === value
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.task,
|
||||||
|
() => {
|
||||||
|
selectedItems.value = [...(props.task.selectedItems ?? [])]
|
||||||
|
fieldAnswers.value = { ...(props.task.fieldAnswers ?? {}) }
|
||||||
|
emit('selection-change', [...selectedItems.value])
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(processedHtml, applyStoredValues, { flush: 'post' })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyStoredValues()
|
||||||
|
syncFromDom()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="sign-doc">
|
<article class="sign-doc">
|
||||||
<div class="sign-doc-banner">
|
<div class="sign-doc-banner">
|
||||||
<span>已按任务固化的文书版本渲染(占位符已填充、"勾选"表已交互化)</span>
|
<span>已按任务固化的文书版本渲染(填写项已按患者信息预填,签名落在文书签名位)</span>
|
||||||
<span class="sign-doc-state">
|
<span class="sign-doc-state">
|
||||||
{{
|
{{
|
||||||
task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署'
|
task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署'
|
||||||
@@ -92,18 +168,30 @@ function handleSelectionChange(event: Event) {
|
|||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="sign-doc-body"
|
class="sign-doc-body"
|
||||||
@change="handleSelectionChange"
|
@change="syncFromDom"
|
||||||
|
@input="syncFromDom"
|
||||||
v-html="processedHtml"
|
v-html="processedHtml"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
|
||||||
<footer v-if="interactive" class="sign-doc-footer">
|
<footer v-if="interactive" class="sign-doc-footer">
|
||||||
<span>
|
<span>
|
||||||
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{
|
填写项 <strong>{{ progress.filled }}</strong> / {{ fieldTotal }} 项已填
|
||||||
selectedItems.length ? `:${selectedItems.join('、')}` : ''
|
<template v-if="progress.abnormal.length">
|
||||||
}}
|
・异常项:<strong class="is-abnormal">{{ progress.abnormal.join('、') }}</strong>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="progress.missingRequired.length" class="is-missing">
|
||||||
|
必填未完成:{{ progress.missingRequired.join('、') }},请补充后再采集签名。
|
||||||
|
</span>
|
||||||
|
<span v-else
|
||||||
|
>完成填写后通过「手写板签署 / 线上签署」采集签名,填写项与勾选结果随签署证据一并提交。</span
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
<footer v-else-if="fieldTotal" class="sign-doc-footer">
|
||||||
|
<span>
|
||||||
|
填写项 <strong>{{ progress.filled }}</strong> / {{ fieldTotal }} 项已填,取值已随签署固化。
|
||||||
</span>
|
</span>
|
||||||
<span>完成勾选后通过「手写板签署 / 线上签署」采集签名,勾选结果将随签署证据一并提交。</span>
|
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
@@ -160,6 +248,14 @@ function handleSelectionChange(event: Event) {
|
|||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sign-doc-footer .is-abnormal {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-footer .is-missing {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
|
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
|
||||||
.sign-doc-body :deep(p) {
|
.sign-doc-body :deep(p) {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
@@ -223,4 +319,108 @@ function handleSelectionChange(event: Event) {
|
|||||||
max-height: 52px;
|
max-height: 52px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 填写项 ===== */
|
||||||
|
.sign-doc-body :deep(.sign-field) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
margin: 0 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: #f7fbfd;
|
||||||
|
border: 1px solid #d4e3e8;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__label) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #587786;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__required) {
|
||||||
|
margin-left: 2px;
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__control) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__input) {
|
||||||
|
width: 9em;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
color: #1c2b33;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cfe0e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__input:focus) {
|
||||||
|
border-color: var(--brand);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__input:disabled) {
|
||||||
|
color: #1c2b33;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-text-fill-color: #1c2b33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__choices) {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__radio),
|
||||||
|
.sign-doc-body :deep(.sign-field__checkbox) {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__signature-empty) {
|
||||||
|
padding: 12px 18px;
|
||||||
|
color: #a96900;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #fff9ec;
|
||||||
|
border: 1px dashed #f0a21a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 签名位比其他填写项更宽,避免签名图被压扁 */
|
||||||
|
.sign-doc-body :deep(.sign-field--signature) {
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field--signature .sign-field__control) {
|
||||||
|
min-width: 150px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field--abnormal) {
|
||||||
|
background: #fdf0f0;
|
||||||
|
border-color: #efb9bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field--abnormal .sign-field__label) {
|
||||||
|
color: #a42e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-field__badge) {
|
||||||
|
padding: 1px 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--err);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SigningArtifact, SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
|
import type {
|
||||||
|
SigningAnswers,
|
||||||
|
SigningArtifact,
|
||||||
|
SigningTaskEvent,
|
||||||
|
SigningTaskRecord,
|
||||||
|
} from '@/api/workbench/types'
|
||||||
|
import type { SigningFieldProgress } from '@/utils/signing-document'
|
||||||
|
|
||||||
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
|
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
|
||||||
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
||||||
@@ -25,6 +31,7 @@ const emit = defineEmits<{
|
|||||||
action: [action: SigningTaskAction]
|
action: [action: SigningTaskAction]
|
||||||
downloadArtifact: [artifact: SigningArtifact]
|
downloadArtifact: [artifact: SigningArtifact]
|
||||||
selectionChange: [items: string[]]
|
selectionChange: [items: string[]]
|
||||||
|
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -146,6 +153,7 @@ const emit = defineEmits<{
|
|||||||
:task="task"
|
:task="task"
|
||||||
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
|
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
|
||||||
@selection-change="emit('selectionChange', $event)"
|
@selection-change="emit('selectionChange', $event)"
|
||||||
|
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
|
||||||
/>
|
/>
|
||||||
<SigningDocumentPreview v-else :task="task" />
|
<SigningDocumentPreview v-else :task="task" />
|
||||||
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
|
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
voidSigningTask,
|
voidSigningTask,
|
||||||
} from '@/api/workbench/signing'
|
} from '@/api/workbench/signing'
|
||||||
import type {
|
import type {
|
||||||
|
SigningAnswers,
|
||||||
SigningDateRange,
|
SigningDateRange,
|
||||||
SigningMethod,
|
SigningMethod,
|
||||||
SigningArtifact,
|
SigningArtifact,
|
||||||
@@ -32,6 +33,7 @@ import type {
|
|||||||
} from '@/api/workbench/types'
|
} from '@/api/workbench/types'
|
||||||
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import type { SigningFieldProgress } from '@/utils/signing-document'
|
||||||
|
|
||||||
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
||||||
import SignaturePadDialog from './components/SignaturePadDialog.vue'
|
import SignaturePadDialog from './components/SignaturePadDialog.vue'
|
||||||
@@ -82,6 +84,10 @@ const selectedTaskId = ref<string | null>(null)
|
|||||||
const selectedTask = ref<SigningTaskRecord | null>(null)
|
const selectedTask = ref<SigningTaskRecord | null>(null)
|
||||||
/** 当前任务里患者已勾选的条目,签署完成时随签名一起固化到任务上 */
|
/** 当前任务里患者已勾选的条目,签署完成时随签名一起固化到任务上 */
|
||||||
const signingSelection = ref<string[]>([])
|
const signingSelection = ref<string[]>([])
|
||||||
|
/** 当前任务文书的填写项取值(含签名图),签署完成时随签名一起固化 */
|
||||||
|
const signingAnswers = ref<SigningAnswers>({})
|
||||||
|
/** 文书填写项完成情况:签署前用它做必填校验、提示异常项 */
|
||||||
|
const signingProgress = ref<SigningFieldProgress | null>(null)
|
||||||
const taskEvents = ref<SigningTaskEvent[]>([])
|
const taskEvents = ref<SigningTaskEvent[]>([])
|
||||||
const artifacts = ref<SigningArtifact[]>([])
|
const artifacts = ref<SigningArtifact[]>([])
|
||||||
const templates = ref<SigningTemplate[]>([])
|
const templates = ref<SigningTemplate[]>([])
|
||||||
@@ -215,9 +221,11 @@ async function loadOrganizationOptions() {
|
|||||||
|
|
||||||
async function loadTaskDetail(id: string | null) {
|
async function loadTaskDetail(id: string | null) {
|
||||||
const requestId = ++detailRequestId
|
const requestId = ++detailRequestId
|
||||||
// 换任务时先清空勾选,避免把上一个任务的勾选带到新任务上;
|
// 换任务时先清空勾选与填写项,避免把上一个任务的取值带到新任务上;
|
||||||
// 文书组件挂载后会用任务自身的勾选重新上报
|
// 文书组件挂载后会用任务自身固化的取值重新上报
|
||||||
signingSelection.value = []
|
signingSelection.value = []
|
||||||
|
signingAnswers.value = {}
|
||||||
|
signingProgress.value = null
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
selectedTask.value = null
|
selectedTask.value = null
|
||||||
@@ -254,6 +262,7 @@ async function loadTaskDetail(id: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedTask.value = detailResult.value
|
selectedTask.value = detailResult.value
|
||||||
|
signingAnswers.value = { ...(detailResult.value.fieldAnswers ?? {}) }
|
||||||
|
|
||||||
if (eventsResult.status === 'fulfilled') {
|
if (eventsResult.status === 'fulfilled') {
|
||||||
taskEvents.value = eventsResult.value
|
taskEvents.value = eventsResult.value
|
||||||
@@ -348,6 +357,26 @@ function handleSelectionChange(items: string[]) {
|
|||||||
signingSelection.value = items
|
signingSelection.value = items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgress) {
|
||||||
|
signingAnswers.value = answers
|
||||||
|
signingProgress.value = progress
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集签名前的填写项校验。
|
||||||
|
* 必填项没填完就采集签名,等于签了一份内容不完整的文书,因此在入口处挡住。
|
||||||
|
*/
|
||||||
|
function assertFieldsReady() {
|
||||||
|
const missing = signingProgress.value?.missingRequired ?? []
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
ElMessage.warning(`请先完成必填项:${missing.join('、')}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function handlePageChange(nextPage: number) {
|
function handlePageChange(nextPage: number) {
|
||||||
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
|
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
|
||||||
|
|
||||||
@@ -383,6 +412,10 @@ async function handleTaskAction(action: SigningTaskAction) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!assertFieldsReady()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
signatureDialogVisible.value = true
|
signatureDialogVisible.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -409,6 +442,10 @@ async function handleTaskAction(action: SigningTaskAction) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!assertFieldsReady()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
onlineDialogVisible.value = true
|
onlineDialogVisible.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -514,7 +551,12 @@ async function confirmPadSignature(signatureDataUrl: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshTask(
|
await refreshTask(
|
||||||
await completeSigningTask(task.id, signatureDataUrl, signingSelection.value),
|
await completeSigningTask(
|
||||||
|
task.id,
|
||||||
|
signatureDataUrl,
|
||||||
|
signingSelection.value,
|
||||||
|
signingAnswers.value,
|
||||||
|
),
|
||||||
'手写签署已完成',
|
'手写签署已完成',
|
||||||
)
|
)
|
||||||
signatureDialogVisible.value = false
|
signatureDialogVisible.value = false
|
||||||
@@ -532,7 +574,12 @@ async function completeOnlineSignature(signatureDataUrl: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshTask(
|
await refreshTask(
|
||||||
await completeSigningTask(task.id, signatureDataUrl, signingSelection.value),
|
await completeSigningTask(
|
||||||
|
task.id,
|
||||||
|
signatureDataUrl,
|
||||||
|
signingSelection.value,
|
||||||
|
signingAnswers.value,
|
||||||
|
),
|
||||||
'线上签署已完成',
|
'线上签署已完成',
|
||||||
)
|
)
|
||||||
onlineDialogVisible.value = false
|
onlineDialogVisible.value = false
|
||||||
@@ -655,6 +702,7 @@ onMounted(() => {
|
|||||||
@action="handleTaskAction"
|
@action="handleTaskAction"
|
||||||
@download-artifact="downloadArtifact"
|
@download-artifact="downloadArtifact"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
|
@field-change="handleFieldChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user