feat(sign): 补齐医护端与患者端签署流程
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
- `clinical-web`:医护端布局、工作台、签署任务、文书库、报表和设置页面;
|
- `clinical-web`:医护端布局、工作台、签署任务、文书库、报表和设置页面;
|
||||||
- `patient-h5`:签署入口、文书确认、签署人信息、手写签名和结果页面;
|
- `patient-h5`:签署入口、文书确认、签署人信息、手写签名和结果页面;
|
||||||
- 默认仍使用演示数据,但医护端已按 MEDISIGN 接口文档接入登录、患者/就诊、模板、签署任务、文件产物、投递、用户组织和模板权限等真实接口;患者 H5 已接入一次性 Token 消费和签名上传请求;
|
- 默认仍使用演示数据。真实签署流程中,医护端创建任务时提交模板表单答案和实际签署人;患者 H5 消费一次性 Token 后读取服务端固化的患者、签署人和文书上下文,再上传签名 PNG;
|
||||||
|
- 真实签署通道目前限于后端已定义的 SMS 与 PAD。短信接口只确认投递意图已排队;短信供应商、微信公众号和 iPad 设备适配仍需后端/集成服务接入;
|
||||||
- `packages` 目录暂时保留,等接口和公共类型稳定后再接入共享包。
|
- `packages` 目录暂时保留,等接口和公共类型稳定后再接入共享包。
|
||||||
|
|
||||||
## 项目定位
|
## 项目定位
|
||||||
@@ -87,6 +88,13 @@ medical-sign/
|
|||||||
- 前端不负责最终权限和签署有效性判断,后端必须重新校验;
|
- 前端不负责最终权限和签署有效性判断,后端必须重新校验;
|
||||||
- 患者 H5 不在 URL 中暴露身份证号、完整病历等敏感信息。
|
- 患者 H5 不在 URL 中暴露身份证号、完整病历等敏感信息。
|
||||||
|
|
||||||
|
## 签署接口契约
|
||||||
|
|
||||||
|
- 模板版本的 `formFields` 保存文书字段定义,`signatureFields` 单独保存患者签名在 PDF 中的页码、坐标、宽高和坐标系;编辑器中的坐标单位为 PDF point。发布模板前必须配置 `signature` 字段的位置。
|
||||||
|
- `GET /api/v1/templates/available-versions` 返回 `formFields` 和 `needConfirm`。创建任务时将人工填写值放入 `staffAnswers`,并传入 `signerType`、`signerName`、`signerRelation`;患者、性别、年龄、就诊号和电话等已知字段仍由后端快照覆盖。
|
||||||
|
- H5 先调用 `POST /api/v1/sign-deliveries/token/consume`,再用返回的 `taskId`、`deliveryId`、`uploadToken` 调用 `POST /api/v1/sign-deliveries/token/context`。上传使用 `POST /api/v1/sign-deliveries/{taskId}/signature` multipart,包含 `file`、`metadata` 和 `signer` JSON 部分,并复用 `Idempotency-Key` 安全重试。
|
||||||
|
- 患者签署后,任务可能处于 `GENERATING`、`WAITING_CONFIRM` 或 `SIGNED`。刷新结果页时 H5 使用只读 context 查询恢复服务端状态,不能将这些状态退回为可重复签署。
|
||||||
|
|
||||||
## 构建与部署规划
|
## 构建与部署规划
|
||||||
|
|
||||||
两个应用分别构建为 `dist`:
|
两个应用分别构建为 `dist`:
|
||||||
|
|||||||
@@ -136,19 +136,37 @@ export function readVersionContentHtml(dto: TemplateVersionResponseDto | null):
|
|||||||
/**
|
/**
|
||||||
* 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。
|
* 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。
|
||||||
*
|
*
|
||||||
* 后端版本 DTO 的 signatureFields 字段承载「这份模板有哪些填写项」,
|
* 正文填写项和 PDF 签名几何是两个独立契约:formFields 负责字段语义,
|
||||||
* 正文本身仍以 contentHtml 提交。不提交这个字段的话,后端拿不到字段口径,
|
* signatureFields 只负责患者签名图在服务端生成 PDF 中的位置。
|
||||||
* 也就无法在签署侧做必填校验与证据固化。
|
|
||||||
*/
|
*/
|
||||||
export function buildSignatureFieldsPayload(contentHtml: string): unknown[] {
|
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) => ({
|
return collectFieldDefs(contentHtml).map((field) => ({
|
||||||
id: field.id,
|
id: field.id,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
kind: field.kind,
|
kind: field.kind,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
options: field.options,
|
options: field.options,
|
||||||
// 单/多选必须一起上报:后端靠它渲染签署控件与做取值校验,
|
|
||||||
// 缺了这个字段就只能按 kind 猜,多选组会被当成单个勾选框
|
|
||||||
...(field.multi === undefined ? {} : { multi: field.multi }),
|
...(field.multi === undefined ? {} : { multi: field.multi }),
|
||||||
...(field.risk ? { risk: field.risk } : {}),
|
...(field.risk ? { risk: field.risk } : {}),
|
||||||
}))
|
}))
|
||||||
@@ -519,6 +537,7 @@ export async function createTemplateWithContent(
|
|||||||
|
|
||||||
await createTemplateVersion(template.id, {
|
await createTemplateVersion(template.id, {
|
||||||
...version,
|
...version,
|
||||||
|
formFields: buildFormFieldsPayload(version.contentHtml),
|
||||||
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
|
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -539,6 +558,7 @@ export async function updateTemplateContent(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await createTemplateVersion(templateId, {
|
await createTemplateVersion(templateId, {
|
||||||
...version,
|
...version,
|
||||||
|
formFields: buildFormFieldsPayload(version.contentHtml),
|
||||||
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
|
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ export interface CreateTemplateVersionRequest {
|
|||||||
versionNo?: string
|
versionNo?: string
|
||||||
contentHtml: string
|
contentHtml: string
|
||||||
signatureFields?: unknown[]
|
signatureFields?: unknown[]
|
||||||
|
formFields?: unknown[]
|
||||||
|
needConfirm?: boolean
|
||||||
fileStorageKey?: string
|
fileStorageKey?: string
|
||||||
fileRelativePath?: string
|
fileRelativePath?: string
|
||||||
fileSizeBytes?: number
|
fileSizeBytes?: number
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ import { mockFlags } from '@/utils/mock-flags'
|
|||||||
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
|
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
|
||||||
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
|
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
import { ALL_SIGNING_METHODS, formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method'
|
import {
|
||||||
import { resendSigningSms as resendSigningSmsDelivery } from './deliveries'
|
ALL_SIGNING_METHODS,
|
||||||
|
formatSigningMethod,
|
||||||
|
toBackendSigningMethod,
|
||||||
|
} from '@/utils/signing-method'
|
||||||
|
import {
|
||||||
|
resendSigningSms as resendSigningSmsDelivery,
|
||||||
|
sendSigningSms as sendSigningSmsDelivery,
|
||||||
|
} from './deliveries'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiPageResponse,
|
ApiPageResponse,
|
||||||
@@ -666,6 +673,7 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing
|
|||||||
supportedMethods: [...ALL_SIGNING_METHODS],
|
supportedMethods: [...ALL_SIGNING_METHODS],
|
||||||
// 需科室确认随模板带出;后端没给就是 false(签完直接归档)
|
// 需科室确认随模板带出;后端没给就是 false(签完直接归档)
|
||||||
needConfirm: dto.needConfirm ?? false,
|
needConfirm: dto.needConfirm ?? false,
|
||||||
|
formFields: dto.formFields ?? [],
|
||||||
// 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回
|
// 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回
|
||||||
contentSha256: dto.contentSha256,
|
contentSha256: dto.contentSha256,
|
||||||
contentHtml: cachedContent || undefined,
|
contentHtml: cachedContent || undefined,
|
||||||
@@ -757,7 +765,7 @@ function mapSigningTask(
|
|||||||
documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`,
|
documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`,
|
||||||
department: dto.visitSnapshot.departmentName || '未指定科室',
|
department: dto.visitSnapshot.departmentName || '未指定科室',
|
||||||
departmentId: dto.departmentId,
|
departmentId: dto.departmentId,
|
||||||
signerName: dto.patientSnapshot.name,
|
signerName: dto.signerName ?? dto.patientSnapshot.name,
|
||||||
// 签署人类型 / 关系:后端补齐后自动带出,取不到即 undefined,列表不显示代签标签
|
// 签署人类型 / 关系:后端补齐后自动带出,取不到即 undefined,列表不显示代签标签
|
||||||
signerType: dto.signerType ?? undefined,
|
signerType: dto.signerType ?? undefined,
|
||||||
signerRelation: dto.signerRelation ?? undefined,
|
signerRelation: dto.signerRelation ?? undefined,
|
||||||
@@ -1183,7 +1191,11 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise<
|
|||||||
visitId: input.visitId,
|
visitId: input.visitId,
|
||||||
templateVersionId: input.templateVersionId,
|
templateVersionId: input.templateVersionId,
|
||||||
signMethod: toBackendSigningMethod(input.method),
|
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>>(
|
const response = await request.post<ApiResponseOf<SignTaskResponseDto>>(
|
||||||
'/v1/sign-tasks',
|
'/v1/sign-tasks',
|
||||||
@@ -1283,7 +1295,10 @@ export function sendSigningSms(
|
|||||||
mobile: string | null,
|
mobile: string | null,
|
||||||
): Promise<SigningTaskRecord | null> {
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
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)
|
const task = mockRecords.find((record) => record.id === id)
|
||||||
|
|||||||
@@ -27,13 +27,7 @@ export interface WorkbenchOverviewRequestDto {
|
|||||||
* `confirming` 只能下载患者已签版本。
|
* `confirming` 只能下载患者已签版本。
|
||||||
*/
|
*/
|
||||||
export type SigningTaskStatus =
|
export type SigningTaskStatus =
|
||||||
| 'pending'
|
'pending' | 'signing' | 'confirming' | 'signed' | 'expired' | 'void' | 'failed'
|
||||||
| 'signing'
|
|
||||||
| 'confirming'
|
|
||||||
| 'signed'
|
|
||||||
| 'expired'
|
|
||||||
| 'void'
|
|
||||||
| 'failed'
|
|
||||||
|
|
||||||
export type PatientSex = '男' | '女' | '未知'
|
export type PatientSex = '男' | '女' | '未知'
|
||||||
|
|
||||||
@@ -87,6 +81,8 @@ export interface SigningTemplate {
|
|||||||
* 发起签署时随文书带到任务上,决定签完落「待科室确认」还是「已签署」。
|
* 发起签署时随文书带到任务上,决定签完落「待科室确认」还是「已签署」。
|
||||||
*/
|
*/
|
||||||
needConfirm?: boolean
|
needConfirm?: boolean
|
||||||
|
/** 医护端发起时必须采集的结构化正文填写项。 */
|
||||||
|
formFields?: SigningFormField[]
|
||||||
/** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */
|
/** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */
|
||||||
contentHtml?: string
|
contentHtml?: string
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +93,16 @@ export interface SigningTemplate {
|
|||||||
contentSha256?: string
|
contentSha256?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SigningFormField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
kind: SigningFieldKind
|
||||||
|
required: boolean
|
||||||
|
options?: string[]
|
||||||
|
multi?: boolean
|
||||||
|
risk?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkbenchSummary {
|
export interface WorkbenchSummary {
|
||||||
todaySigned: number
|
todaySigned: number
|
||||||
todaySignedChange: 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 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 {
|
export interface SigningFieldDef {
|
||||||
/** 稳定字段键:与 HIS / 患者快照取值一一对应,不随占位符显示文字变化 */
|
/** 稳定字段键:与 HIS / 患者快照取值一一对应,不随占位符显示文字变化 */
|
||||||
@@ -242,6 +257,8 @@ export interface SigningFieldDef {
|
|||||||
multi?: boolean
|
multi?: boolean
|
||||||
/** 取值等于该字符串时视为异常项(如风险筛查选了"是") */
|
/** 取值等于该字符串时视为异常项(如风险筛查选了"是") */
|
||||||
risk?: string
|
risk?: string
|
||||||
|
/** 患者签名位在服务端生成 PDF 中的位置,单位为 PDF point。 */
|
||||||
|
pdfPosition?: SigningPdfPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -366,6 +383,11 @@ export interface CreateSigningTaskInput {
|
|||||||
documentContentHtml?: string
|
documentContentHtml?: string
|
||||||
/** 由文书模板带出的「需科室确认」标记;真实接口不提交(后端按 templateVersionId 取)。 */
|
/** 由文书模板带出的「需科室确认」标记;真实接口不提交(后端按 templateVersionId 取)。 */
|
||||||
needConfirm?: boolean
|
needConfirm?: boolean
|
||||||
|
staffAnswers?: Record<string, string>
|
||||||
|
signerType?: 'SELF' | 'FAMILY'
|
||||||
|
signerName?: string
|
||||||
|
signerRelation?: string
|
||||||
|
idempotencyKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
|
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
|
||||||
@@ -375,6 +397,10 @@ export interface CreateSigningTaskRequest {
|
|||||||
templateVersionId: string
|
templateVersionId: string
|
||||||
signMethod: BackendSigningMethod
|
signMethod: BackendSigningMethod
|
||||||
idempotencyKey?: string
|
idempotencyKey?: string
|
||||||
|
staffAnswers?: Record<string, string>
|
||||||
|
signerType?: 'SELF' | 'FAMILY'
|
||||||
|
signerName?: string
|
||||||
|
signerRelation?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SigningApiOptions {
|
export interface SigningApiOptions {
|
||||||
@@ -460,6 +486,7 @@ export interface AvailableTemplateVersionResponseDto {
|
|||||||
effectiveAt: string
|
effectiveAt: string
|
||||||
/** 患者签署后是否需要科室复核签章;后端补齐后前端无需改动即可带出。 */
|
/** 患者签署后是否需要科室复核签章;后端补齐后前端无需改动即可带出。 */
|
||||||
needConfirm?: boolean | null
|
needConfirm?: boolean | null
|
||||||
|
formFields?: SigningFormField[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignTaskPatientSnapshotDto {
|
export interface SignTaskPatientSnapshotDto {
|
||||||
@@ -512,6 +539,7 @@ export interface SignTaskResponseDto {
|
|||||||
* 后端补齐后前端无需改动即可显示;当前取不到时为 undefined。
|
* 后端补齐后前端无需改动即可显示;当前取不到时为 undefined。
|
||||||
*/
|
*/
|
||||||
signerType?: string | null
|
signerType?: string | null
|
||||||
|
signerName?: string | null
|
||||||
signerRelation?: string | null
|
signerRelation?: string | null
|
||||||
/** 患者签署后是否需要科室复核签章(由文书模板标记带出)。 */
|
/** 患者签署后是否需要科室复核签章(由文书模板标记带出)。 */
|
||||||
needConfirm?: boolean | null
|
needConfirm?: boolean | null
|
||||||
|
|||||||
@@ -49,10 +49,18 @@ const {
|
|||||||
selectMethod,
|
selectMethod,
|
||||||
selectTemplate,
|
selectTemplate,
|
||||||
selectVisit,
|
selectVisit,
|
||||||
|
selectedTemplate,
|
||||||
selectedTemplateId,
|
selectedTemplateId,
|
||||||
selectedVisit,
|
selectedVisit,
|
||||||
selectedVisitId,
|
selectedVisitId,
|
||||||
smsDestination,
|
smsDestination,
|
||||||
|
staffAnswers,
|
||||||
|
manualFormFields,
|
||||||
|
signerType,
|
||||||
|
signerName,
|
||||||
|
signerRelation,
|
||||||
|
staffAnswerHasOption,
|
||||||
|
toggleStaffAnswerOption,
|
||||||
submit: submitForm,
|
submit: submitForm,
|
||||||
submitting,
|
submitting,
|
||||||
suggestionOpen,
|
suggestionOpen,
|
||||||
@@ -128,7 +136,11 @@ async function submitTask(keepOpen: boolean) {
|
|||||||
|
|
||||||
emit('created', task)
|
emit('created', task)
|
||||||
const smsMessage =
|
const smsMessage =
|
||||||
task.method === 'sms' ? (isSigningMockEnabled ? ',已生成短信投递意图' : ',短信已发送') : ''
|
task.method === 'sms'
|
||||||
|
? isSigningMockEnabled
|
||||||
|
? ',已生成短信投递意图'
|
||||||
|
: ',短信投递已排队'
|
||||||
|
: ''
|
||||||
ElMessage.success(`已创建 1 个签署任务${smsMessage}`)
|
ElMessage.success(`已创建 1 个签署任务${smsMessage}`)
|
||||||
|
|
||||||
if (keepOpen) {
|
if (keepOpen) {
|
||||||
@@ -140,8 +152,8 @@ async function submitTask(keepOpen: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emit('close')
|
emit('close')
|
||||||
} catch {
|
} catch (error) {
|
||||||
ElMessage.error('签署任务创建失败,请稍后重试')
|
ElMessage.error(error instanceof Error ? error.message : '签署任务创建失败,请稍后重试')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +416,10 @@ watch(
|
|||||||
type="button"
|
type="button"
|
||||||
class="method-option"
|
class="method-option"
|
||||||
:class="{ 'method-option--active': method === option.key }"
|
: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)"
|
@click="selectMethod(option.key)"
|
||||||
>
|
>
|
||||||
<span class="method-icon">{{ option.icon }}</span>
|
<span class="method-icon">{{ option.icon }}</span>
|
||||||
@@ -445,9 +460,88 @@ watch(
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="!isSigningMockEnabled && (method === 'wx' || method === 'ipad')"
|
||||||
|
class="field-error"
|
||||||
|
>
|
||||||
|
当前后端尚未接入该渠道的投递或设备适配,请选择短信或已配置的现场手写板。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<footer class="dialog-footer">
|
<footer class="dialog-footer">
|
||||||
@@ -882,6 +976,80 @@ watch(
|
|||||||
font-size: 12px;
|
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 并排 */
|
/* C + D 并排 */
|
||||||
|
|
||||||
.document-method-grid {
|
.document-method-grid {
|
||||||
@@ -1292,6 +1460,10 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
.signing-context-grid,
|
||||||
|
.staff-answer-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.document-method-grid {
|
.document-method-grid {
|
||||||
flex-direction: column;
|
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 { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import { sendSigningSms } from '@/api/workbench/deliveries'
|
import { sendSigningSms } from '@/api/workbench/deliveries'
|
||||||
import {
|
import { buildLinkedPrescription, type LinkedPrescription } from '@/api/workbench/prescriptions'
|
||||||
buildLinkedPrescription,
|
|
||||||
type LinkedPrescription,
|
|
||||||
} from '@/api/workbench/prescriptions'
|
|
||||||
import { collectFieldDefs } from '@/utils/signing-fields'
|
import { collectFieldDefs } from '@/utils/signing-fields'
|
||||||
import {
|
import {
|
||||||
createSigningTask,
|
createSigningTask,
|
||||||
@@ -18,6 +15,7 @@ import type {
|
|||||||
PatientProfile,
|
PatientProfile,
|
||||||
PatientVisit,
|
PatientVisit,
|
||||||
SigningMethod,
|
SigningMethod,
|
||||||
|
SigningFormField,
|
||||||
SigningTaskRecord,
|
SigningTaskRecord,
|
||||||
SigningTemplate,
|
SigningTemplate,
|
||||||
WorkbenchCampus,
|
WorkbenchCampus,
|
||||||
@@ -54,6 +52,14 @@ export interface DocumentListRow {
|
|||||||
export type { LinkedPrescription }
|
export type { LinkedPrescription }
|
||||||
|
|
||||||
export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
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 patientId = ref('')
|
||||||
const patient = ref<PatientProfile | null>(null)
|
const patient = ref<PatientProfile | null>(null)
|
||||||
const visits = ref<PatientVisit[]>([])
|
const visits = ref<PatientVisit[]>([])
|
||||||
@@ -63,6 +69,10 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
const selectedVisitId = ref('')
|
const selectedVisitId = ref('')
|
||||||
const method = ref<SigningMethod>('pad')
|
const method = ref<SigningMethod>('pad')
|
||||||
const smsDestination = ref('')
|
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 loadingTemplates = ref(false)
|
||||||
const locating = ref(false)
|
const locating = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -81,6 +91,8 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
visit: '',
|
visit: '',
|
||||||
document: '',
|
document: '',
|
||||||
sms: '',
|
sms: '',
|
||||||
|
staffAnswers: '',
|
||||||
|
signer: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */
|
/** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */
|
||||||
@@ -94,6 +106,26 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
|
() => 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>(() =>
|
const prescription = computed<LinkedPrescription | null>(() =>
|
||||||
isSigningMockEnabled ? buildLinkedPrescription(selectedVisit.value) : null,
|
isSigningMockEnabled ? buildLinkedPrescription(selectedVisit.value) : null,
|
||||||
@@ -302,6 +334,8 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
errors.visit = ''
|
errors.visit = ''
|
||||||
errors.document = ''
|
errors.document = ''
|
||||||
errors.sms = ''
|
errors.sms = ''
|
||||||
|
errors.staffAnswers = ''
|
||||||
|
errors.signer = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
@@ -315,12 +349,18 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
selectedVisitId.value = ''
|
selectedVisitId.value = ''
|
||||||
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
|
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
|
||||||
smsDestination.value = ''
|
smsDestination.value = ''
|
||||||
|
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
|
||||||
|
signerType.value = 'SELF'
|
||||||
|
signerName.value = ''
|
||||||
|
signerRelation.value = ''
|
||||||
phonePlain.value = false
|
phonePlain.value = false
|
||||||
suggestionOpen.value = false
|
suggestionOpen.value = false
|
||||||
selectedDepartment.value = ''
|
selectedDepartment.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
expandedDepartments.value = []
|
expandedDepartments.value = []
|
||||||
clearErrors()
|
clearErrors()
|
||||||
|
createTaskIdempotencyKey = newIdempotencyKey()
|
||||||
|
sendSmsIdempotencyKey = newIdempotencyKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForNextTask() {
|
function resetForNextTask() {
|
||||||
@@ -330,8 +370,14 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
selectedTemplateId.value = initialTemplate?.id ?? ''
|
selectedTemplateId.value = initialTemplate?.id ?? ''
|
||||||
method.value = initialTemplate?.supportedMethods[0] ?? method.value
|
method.value = initialTemplate?.supportedMethods[0] ?? method.value
|
||||||
smsDestination.value = ''
|
smsDestination.value = ''
|
||||||
|
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
|
||||||
|
signerType.value = 'SELF'
|
||||||
|
signerName.value = ''
|
||||||
|
signerRelation.value = ''
|
||||||
errors.document = ''
|
errors.document = ''
|
||||||
errors.sms = ''
|
errors.sms = ''
|
||||||
|
createTaskIdempotencyKey = newIdempotencyKey()
|
||||||
|
sendSmsIdempotencyKey = newIdempotencyKey()
|
||||||
initDocumentTree()
|
initDocumentTree()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +527,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
* 演示模板一律声明全部四种;真实模式由文档库配置决定。
|
* 演示模板一律声明全部四种;真实模式由文档库配置决定。
|
||||||
*/
|
*/
|
||||||
function isMethodAllowed(nextMethod: SigningMethod) {
|
function isMethodAllowed(nextMethod: SigningMethod) {
|
||||||
|
if (!isSigningMockEnabled && (nextMethod === 'wx' || nextMethod === 'ipad')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const template = selectedTemplate.value
|
const template = selectedTemplate.value
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
@@ -511,6 +560,18 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
return `${value.slice(0, 3)}****${value.slice(-4)}`
|
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() {
|
function validate() {
|
||||||
clearErrors()
|
clearErrors()
|
||||||
let valid = true
|
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
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,13 +644,29 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
documentContentHtml: selectedTemplate.value.contentHtml,
|
documentContentHtml: selectedTemplate.value.contentHtml,
|
||||||
// 需科室确认由文书模板标记带出(原型 V6.9 起弹窗里不再让人选)
|
// 需科室确认由文书模板标记带出(原型 V6.9 起弹窗里不再让人选)
|
||||||
needConfirm: selectedTemplate.value.needConfirm ?? false,
|
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') {
|
if (!isSigningMockEnabled && method.value === 'sms') {
|
||||||
await sendSigningSms(task.id, {
|
try {
|
||||||
|
await sendSigningSms(
|
||||||
|
task.id,
|
||||||
|
{
|
||||||
destination: smsDestination.value.trim().replace(/\s+/g, ''),
|
destination: smsDestination.value.trim().replace(/\s+/g, ''),
|
||||||
templateCode: 'SIGN_LINK',
|
templateCode: 'SIGN_LINK',
|
||||||
})
|
},
|
||||||
|
sendSmsIdempotencyKey,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`签署任务 ${task.id} 已创建,但短信投递失败。请在工作台任务详情中重试发送。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sendSmsIdempotencyKey = newIdempotencyKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
return task
|
return task
|
||||||
@@ -612,6 +705,13 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
selectedVisit,
|
selectedVisit,
|
||||||
selectedVisitId,
|
selectedVisitId,
|
||||||
smsDestination,
|
smsDestination,
|
||||||
|
staffAnswers,
|
||||||
|
manualFormFields,
|
||||||
|
signerType,
|
||||||
|
signerName,
|
||||||
|
signerRelation,
|
||||||
|
staffAnswerHasOption,
|
||||||
|
toggleStaffAnswerOption,
|
||||||
submit,
|
submit,
|
||||||
submitting,
|
submitting,
|
||||||
suggestionOpen,
|
suggestionOpen,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface SignFieldElement {
|
|||||||
fieldOptions: string[]
|
fieldOptions: string[]
|
||||||
fieldMulti?: boolean
|
fieldMulti?: boolean
|
||||||
fieldRisk?: string
|
fieldRisk?: string
|
||||||
|
fieldPdfPosition?: SigningFieldDef['pdfPosition']
|
||||||
/** void 元素也要有一个空文本子节点,否则 Slate 会认为结构非法 */
|
/** void 元素也要有一个空文本子节点,否则 Slate 会认为结构非法 */
|
||||||
children: [{ text: '' }]
|
children: [{ text: '' }]
|
||||||
}
|
}
|
||||||
@@ -118,6 +119,7 @@ function buildSignFieldProps(field: SigningFieldDef): Omit<SignFieldElement, 'ty
|
|||||||
fieldOptions: [...field.options],
|
fieldOptions: [...field.options],
|
||||||
fieldMulti: field.multi,
|
fieldMulti: field.multi,
|
||||||
fieldRisk: field.risk,
|
fieldRisk: field.risk,
|
||||||
|
fieldPdfPosition: field.pdfPosition ? { ...field.pdfPosition } : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +207,7 @@ export function readFieldFromSignFieldNode(elem: SignFieldElement): SigningField
|
|||||||
options: [...elem.fieldOptions],
|
options: [...elem.fieldOptions],
|
||||||
...(elem.fieldMulti === undefined ? {} : { multi: elem.fieldMulti }),
|
...(elem.fieldMulti === undefined ? {} : { multi: elem.fieldMulti }),
|
||||||
...(elem.fieldRisk ? { risk: elem.fieldRisk } : {}),
|
...(elem.fieldRisk ? { risk: elem.fieldRisk } : {}),
|
||||||
|
...(elem.fieldPdfPosition ? { pdfPosition: { ...elem.fieldPdfPosition } } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,11 +235,7 @@ function isSelected(editor: IDomEditor, elemNode: unknown): boolean {
|
|||||||
* 编辑器内渲染:一个只靠属性展示的胶囊。
|
* 编辑器内渲染:一个只靠属性展示的胶囊。
|
||||||
* 不渲染 children,也不放文本节点,理由见文件头的 ③。
|
* 不渲染 children,也不放文本节点,理由见文件头的 ③。
|
||||||
*/
|
*/
|
||||||
function renderSignField(
|
function renderSignField(elemNode: unknown, _children: VNode[] | null, editor: IDomEditor): VNode {
|
||||||
elemNode: unknown,
|
|
||||||
_children: VNode[] | null,
|
|
||||||
editor: IDomEditor,
|
|
||||||
): VNode {
|
|
||||||
const elem = elemNode as SignFieldElement
|
const elem = elemNode as SignFieldElement
|
||||||
const kindLabel = SIGNING_FIELD_KIND_LABELS[elem.fieldKind] ?? '文本'
|
const kindLabel = SIGNING_FIELD_KIND_LABELS[elem.fieldKind] ?? '文本'
|
||||||
const selected = isSelected(editor, elemNode)
|
const selected = isSelected(editor, elemNode)
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export const FIELD_REQUIRED_ATTR = 'data-required'
|
|||||||
export const FIELD_OPTIONS_ATTR = 'data-options'
|
export const FIELD_OPTIONS_ATTR = 'data-options'
|
||||||
export const FIELD_MULTI_ATTR = 'data-multi'
|
export const FIELD_MULTI_ATTR = 'data-multi'
|
||||||
export const FIELD_RISK_ATTR = 'data-risk'
|
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}]`
|
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)
|
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
|
element.textContent = field.label
|
||||||
|
|
||||||
return element
|
return element
|
||||||
@@ -109,10 +124,35 @@ export function readFieldFromElement(element: Element): SigningFieldDef | null {
|
|||||||
// 没有 data-field 时用标签反查目录,签名位这类字段至少还能还原成正确的控件
|
// 没有 data-field 时用标签反查目录,签名位这类字段至少还能还原成正确的控件
|
||||||
const resolved = id ? null : resolveFieldByLabel(label)
|
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 {
|
return {
|
||||||
id: id || (resolved as SigningFieldDef).id,
|
id: id || (resolved as SigningFieldDef).id,
|
||||||
label: label || 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:
|
required:
|
||||||
element.getAttribute(FIELD_REQUIRED_ATTR) === 'true' ||
|
element.getAttribute(FIELD_REQUIRED_ATTR) === 'true' ||
|
||||||
(!element.hasAttribute(FIELD_REQUIRED_ATTR) && resolved?.required === true),
|
(!element.hasAttribute(FIELD_REQUIRED_ATTR) && resolved?.required === true),
|
||||||
@@ -122,6 +162,7 @@ export function readFieldFromElement(element: Element): SigningFieldDef | null {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
multi: readMultiAttribute(element, resolved),
|
multi: readMultiAttribute(element, resolved),
|
||||||
risk: element.getAttribute(FIELD_RISK_ATTR)?.trim() || resolved?.risk || undefined,
|
risk: element.getAttribute(FIELD_RISK_ATTR)?.trim() || resolved?.risk || undefined,
|
||||||
|
pdfPosition,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,11 +416,7 @@ export interface ScannedFieldToken {
|
|||||||
* 只对自定义文本项有意义(调用方保证),目录里的字段同名共用一份取值是刻意的。
|
* 只对自定义文本项有意义(调用方保证),目录里的字段同名共用一份取值是刻意的。
|
||||||
* claimed 是有限集合、next 严格递增,所以循环一定收敛。
|
* claimed 是有限集合、next 严格递增,所以循环一定收敛。
|
||||||
*/
|
*/
|
||||||
function nextFreeCustomSequence(
|
function nextFreeCustomSequence(label: string, sequence: number, claimed: Set<string>): number {
|
||||||
label: string,
|
|
||||||
sequence: number,
|
|
||||||
claimed: Set<string>,
|
|
||||||
): number {
|
|
||||||
let next = sequence
|
let next = sequence
|
||||||
|
|
||||||
while (claimed.has(createCustomField(label, 'text', [], next).id)) {
|
while (claimed.has(createCustomField(label, 'text', [], next).id)) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ interface EditorPaneHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const valueHtml = ref('<p><br></p>')
|
const valueHtml = ref('<p><br></p>')
|
||||||
|
const needConfirm = ref(false)
|
||||||
const docxInput = ref<HTMLInputElement | null>(null)
|
const docxInput = ref<HTMLInputElement | null>(null)
|
||||||
const importing = ref(false)
|
const importing = ref(false)
|
||||||
const paneRef = ref<EditorPaneHandle | null>(null)
|
const paneRef = ref<EditorPaneHandle | null>(null)
|
||||||
@@ -128,17 +129,19 @@ const fieldDraft = reactive({
|
|||||||
options: [] as string[],
|
options: [] as string[],
|
||||||
multi: false,
|
multi: false,
|
||||||
risk: '',
|
risk: '',
|
||||||
|
pdfPage: '',
|
||||||
|
pdfX: '',
|
||||||
|
pdfY: '',
|
||||||
|
pdfWidth: '',
|
||||||
|
pdfHeight: '',
|
||||||
|
pdfCoordinateSystem: 'TOP_LEFT' as 'TOP_LEFT' | 'PDF_BOTTOM_LEFT',
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 类型下拉的取值(原型 fpKind 的 5 个选项) */
|
/** 类型下拉的取值(原型 fpKind 的 5 个选项) */
|
||||||
const KIND_OPTIONS = Object.entries(SIGNING_FIELD_KIND_LABELS) as Array<
|
const KIND_OPTIONS = Object.entries(SIGNING_FIELD_KIND_LABELS) as Array<[SigningFieldKind, string]>
|
||||||
[SigningFieldKind, string]
|
|
||||||
>
|
|
||||||
|
|
||||||
/** 选项行与高危值行只在选项类出现(原型 docFpKind) */
|
/** 选项行与高危值行只在选项类出现(原型 docFpKind) */
|
||||||
const isOptionKind = computed(
|
const isOptionKind = computed(() => fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox')
|
||||||
() => fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox',
|
|
||||||
)
|
|
||||||
|
|
||||||
function syncDraft(field: SigningFieldDef | null) {
|
function syncDraft(field: SigningFieldDef | null) {
|
||||||
fieldDraft.label = field?.label ?? ''
|
fieldDraft.label = field?.label ?? ''
|
||||||
@@ -148,6 +151,12 @@ function syncDraft(field: SigningFieldDef | null) {
|
|||||||
// 缺省口径与 signing-fields 的 SigningFieldDef.multi 一致:勾选类默认可多选
|
// 缺省口径与 signing-fields 的 SigningFieldDef.multi 一致:勾选类默认可多选
|
||||||
fieldDraft.multi = field?.multi ?? field?.kind === 'checkbox'
|
fieldDraft.multi = field?.multi ?? field?.kind === 'checkbox'
|
||||||
fieldDraft.risk = field?.risk ?? ''
|
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(
|
watch(
|
||||||
() => fieldDraft.kind,
|
() => fieldDraft.kind,
|
||||||
(kind) => {
|
(kind) => {
|
||||||
if ((kind === 'choice' || kind === 'checkbox') && !fieldDraft.options.length && kind === 'choice') {
|
if (
|
||||||
|
(kind === 'choice' || kind === 'checkbox') &&
|
||||||
|
!fieldDraft.options.length &&
|
||||||
|
kind === 'choice'
|
||||||
|
) {
|
||||||
fieldDraft.options = ['否', '是']
|
fieldDraft.options = ['否', '是']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -186,6 +199,7 @@ watch(
|
|||||||
// 升级沿用同一套解析口径,字段键前后完全一致,历史 fieldAnswers 不受影响;
|
// 升级沿用同一套解析口径,字段键前后完全一致,历史 fieldAnswers 不受影响;
|
||||||
// 正文里没有 { 时这一步原样返回,不会白白做一次 DOM 往返。
|
// 正文里没有 { 时这一步原样返回,不会白白做一次 DOM 往返。
|
||||||
valueHtml.value = upgradeFieldTokensToStructured(rawContent)
|
valueHtml.value = upgradeFieldTokensToStructured(rawContent)
|
||||||
|
needConfirm.value = Boolean(props.template?.needConfirm)
|
||||||
message.text = ''
|
message.text = ''
|
||||||
message.tone = ''
|
message.tone = ''
|
||||||
selectedFieldId.value = ''
|
selectedFieldId.value = ''
|
||||||
@@ -238,6 +252,33 @@ function applyFieldDraft() {
|
|||||||
|
|
||||||
const options = fieldDraft.options.map((option) => option.trim()).filter(Boolean)
|
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) {
|
if (isOptionKind.value && new Set(options).size !== options.length) {
|
||||||
setMessage('存在重复选项,请修改后重试', 'warn')
|
setMessage('存在重复选项,请修改后重试', 'warn')
|
||||||
return
|
return
|
||||||
@@ -261,6 +302,7 @@ function applyFieldDraft() {
|
|||||||
options,
|
options,
|
||||||
multi: options.length ? fieldDraft.multi : undefined,
|
multi: options.length ? fieldDraft.multi : undefined,
|
||||||
risk: fieldDraft.risk.trim() || undefined,
|
risk: fieldDraft.risk.trim() || undefined,
|
||||||
|
pdfPosition,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
@@ -307,17 +349,20 @@ const stats = computed(() => {
|
|||||||
* 只挡"确定是错的":选项类少于两个选项、同名占位符写重复。
|
* 只挡"确定是错的":选项类少于两个选项、同名占位符写重复。
|
||||||
* 不挡"没有签名位"这类业务判断——那属于提示,不该拦住保存。
|
* 不挡"没有签名位"这类业务判断——那属于提示,不该拦住保存。
|
||||||
*/
|
*/
|
||||||
function validate(): string | null {
|
function validate(requireSignaturePosition = true): string | null {
|
||||||
const broken = detectedFields.value.find(
|
const broken = detectedFields.value.find(
|
||||||
(field) =>
|
(field) => (field.kind === 'choice' || field.kind === 'checkbox') && field.options.length === 1,
|
||||||
(field.kind === 'choice' || field.kind === 'checkbox') &&
|
|
||||||
field.options.length === 1,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (broken) {
|
if (broken) {
|
||||||
return `「${broken.label}」只配了一个选项,勾选类至少要两个(或删掉选项改成单个勾选框)`
|
return `「${broken.label}」只配了一个选项,勾选类至少要两个(或删掉选项改成单个勾选框)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const patientSignature = detectedFields.value.find((field) => field.id === 'signature')
|
||||||
|
if (requireSignaturePosition && !patientSignature?.pdfPosition) {
|
||||||
|
return '请选中“患者签名”占位符,配置其 PDF 页码和签名位置后再保存'
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +387,7 @@ const previewing = ref(false)
|
|||||||
|
|
||||||
function togglePreview() {
|
function togglePreview() {
|
||||||
if (!previewing.value) {
|
if (!previewing.value) {
|
||||||
const error = validate()
|
const error = validate(false)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setMessage(error, 'warn')
|
setMessage(error, 'warn')
|
||||||
@@ -418,7 +463,11 @@ function save() {
|
|||||||
return
|
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>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="visible" class="dialog-mask" role="presentation" @click.self="requestClose">
|
<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">
|
<header class="dialog-header">
|
||||||
<div class="dialog-title">
|
<div class="dialog-title">
|
||||||
<h2 id="word-editor-title">编辑 Word 文书</h2>
|
<h2 id="word-editor-title">编辑 Word 文书</h2>
|
||||||
@@ -464,13 +518,23 @@ function requestClose() {
|
|||||||
<button type="button" class="action-button" @click="togglePreview">
|
<button type="button" class="action-button" @click="togglePreview">
|
||||||
{{ previewing ? '✏️ 返回编辑' : '👁 签署预览' }}
|
{{ previewing ? '✏️ 返回编辑' : '👁 签署预览' }}
|
||||||
</button>
|
</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">
|
<span class="editor-tip">
|
||||||
勾选项支持配置多个选项(可多选);导入 Word 后表格「勾选」列自动转为交互勾选项
|
勾选项支持配置多个选项(可多选);导入 Word 后表格「勾选」列自动转为交互勾选项
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 }}
|
{{ message.text }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -525,9 +589,44 @@ function requestClose() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div v-if="isOptionKind" class="fp-row">
|
||||||
<span v-for="(_, index) in fieldDraft.options" :key="index" class="option-item">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="option-remove"
|
class="option-remove"
|
||||||
|
|||||||
@@ -183,8 +183,7 @@ const filteredTemplates = computed(() => {
|
|||||||
|
|
||||||
return allTemplates.value.filter((template) => {
|
return allTemplates.value.filter((template) => {
|
||||||
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
||||||
const matchesStatus =
|
const matchesStatus = filters.status === 'all' || readLibraryStatus(template) === filters.status
|
||||||
filters.status === 'all' || readLibraryStatus(template) === filters.status
|
|
||||||
// 搜索范围照原型:模板名 / 编号 / 科室 / 分类,外加检查类型与关联项目编码。
|
// 搜索范围照原型:模板名 / 编号 / 科室 / 分类,外加检查类型与关联项目编码。
|
||||||
// 后两者是卡片上看得见的字段,搜不到会让人以为这份文书不存在。
|
// 后两者是卡片上看得见的字段,搜不到会让人以为这份文书不存在。
|
||||||
const matchesKeyword =
|
const matchesKeyword =
|
||||||
@@ -710,6 +709,7 @@ function updateLocalContent(form: DocumentContentForm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
located.document.contentHtml = form.contentHtml
|
located.document.contentHtml = form.contentHtml
|
||||||
|
located.document.needConfirm = form.needConfirm
|
||||||
located.document.updatedAt = new Date().toISOString()
|
located.document.updatedAt = new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +739,10 @@ async function handleContentSubmit(form: DocumentContentForm) {
|
|||||||
// 新建的文书还没有任何版本:第一个版本用 createTemplateWithContent 的版本那一步等价路径,
|
// 新建的文书还没有任何版本:第一个版本用 createTemplateWithContent 的版本那一步等价路径,
|
||||||
// 这里统一走 createTemplateVersion(见 updateTemplateContent),
|
// 这里统一走 createTemplateVersion(见 updateTemplateContent),
|
||||||
// 后端会把它当成该模板的第一个版本
|
// 后端会把它当成该模板的第一个版本
|
||||||
await updateTemplateContent(form.id, { contentHtml: form.contentHtml })
|
await updateTemplateContent(form.id, {
|
||||||
|
contentHtml: form.contentHtml,
|
||||||
|
needConfirm: form.needConfirm,
|
||||||
|
})
|
||||||
closeContentDialog()
|
closeContentDialog()
|
||||||
await loadLibrary()
|
await loadLibrary()
|
||||||
ElMessage.success('正文已保存为新版本')
|
ElMessage.success('正文已保存为新版本')
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface DocumentContentForm {
|
|||||||
id: string
|
id: string
|
||||||
/** 正文 HTML(含结构化占位符 <span data-field>) */
|
/** 正文 HTML(含结构化占位符 <span data-field>) */
|
||||||
contentHtml: string
|
contentHtml: string
|
||||||
|
needConfirm: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { SigningTaskRecord } from '@/api/workbench/types'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
task: SigningTaskRecord | null
|
task: SigningTaskRecord | null
|
||||||
|
isMock: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -38,7 +39,7 @@ watch(
|
|||||||
|
|
||||||
const masked = props.task?.smsMobileMasked?.trim() ?? ''
|
const masked = props.task?.smsMobileMasked?.trim() ?? ''
|
||||||
registeredMasked.value = masked
|
registeredMasked.value = masked
|
||||||
inputValue.value = masked
|
inputValue.value = props.isMock ? masked : ''
|
||||||
error.value = ''
|
error.value = ''
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -54,10 +55,13 @@ function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (unchanged.value) {
|
if (unchanged.value) {
|
||||||
// 登记号码本来就是脱敏的,前端拿不到完整值 —— 直接交给后端按任务解析
|
if (props.isMock) {
|
||||||
emit('send', null)
|
emit('send', null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
error.value = '请填写完整手机号;登记号码仅展示脱敏值,不能直接用于投递'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!/^1[3-9]\d{9}$/.test(raw)) {
|
if (!/^1[3-9]\d{9}$/.test(raw)) {
|
||||||
error.value = '请输入正确的 11 位手机号'
|
error.value = '请输入正确的 11 位手机号'
|
||||||
@@ -108,7 +112,8 @@ function submit() {
|
|||||||
|
|
||||||
<p v-if="error" class="field-error">{{ error }}</p>
|
<p v-if="error" class="field-error">{{ error }}</p>
|
||||||
<p v-else class="field-hint">
|
<p v-else class="field-hint">
|
||||||
不改号码即发送到登记号码;发送后任务进入「等待患者签署」,链接 48 小时内有效。
|
{{ isMock ? '演示模式可保留登记号码;' : '真实模式需输入完整手机号;' }}
|
||||||
|
短信投递排队后,任务进入「等待患者签署」。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { useLoginStore } from '@/stores/login'
|
import { useLoginStore } from '@/stores/login'
|
||||||
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
||||||
import { PRIMARY_SIGNATURE_FIELD_ID } from '@/utils/signing-fields'
|
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 { downloadSigningPdf } from '@/utils/signing-pdf'
|
||||||
|
|
||||||
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
||||||
@@ -833,7 +837,7 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
|
|||||||
...signingApiOptions.value,
|
...signingApiOptions.value,
|
||||||
expectedRowVersion: task.rowVersion,
|
expectedRowVersion: task.rowVersion,
|
||||||
}),
|
}),
|
||||||
'短信已重新发送',
|
'短信投递已重新排队',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1036,7 +1040,7 @@ async function handleSmsSent(mobile: string | null) {
|
|||||||
smsSendVisible.value = false
|
smsSendVisible.value = false
|
||||||
await refreshTask(
|
await refreshTask(
|
||||||
updated,
|
updated,
|
||||||
`签署短信已发送${updated?.smsMobileMasked ? `至 ${updated.smsMobileMasked}` : ''},等待患者签署`,
|
`签署短信投递已排队${updated?.smsMobileMasked ? `至 ${updated.smsMobileMasked}` : ''},等待患者签署`,
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('发送签署短信失败,请稍后重试')
|
ElMessage.error('发送签署短信失败,请稍后重试')
|
||||||
@@ -1180,6 +1184,7 @@ onMounted(() => {
|
|||||||
<SmsSendDialog
|
<SmsSendDialog
|
||||||
:visible="smsSendVisible"
|
:visible="smsSendVisible"
|
||||||
:task="selectedTask"
|
:task="selectedTask"
|
||||||
|
:is-mock="isSigningMockEnabled"
|
||||||
@close="smsSendVisible = false"
|
@close="smsSendVisible = false"
|
||||||
@send="handleSmsSent"
|
@send="handleSmsSent"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+20
-27
@@ -5,16 +5,20 @@ import type {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
SignatureUploadInput,
|
SignatureUploadInput,
|
||||||
SignatureUploadResponse,
|
SignatureUploadResponse,
|
||||||
|
SigningProof,
|
||||||
SigningContextResponse,
|
SigningContextResponse,
|
||||||
TokenConsumeRequest,
|
TokenConsumeRequest,
|
||||||
TokenConsumeResponse,
|
TokenConsumeResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
|
export function consumeSigningToken(
|
||||||
|
payload: TokenConsumeRequest,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<TokenConsumeResponse> {
|
||||||
return request
|
return request
|
||||||
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
|
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
|
||||||
headers: {
|
headers: {
|
||||||
'Idempotency-Key': createIdempotencyKey(),
|
'Idempotency-Key': idempotencyKey,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(unwrapApiResponse)
|
.then(unwrapApiResponse)
|
||||||
@@ -23,48 +27,37 @@ export function consumeSigningToken(payload: TokenConsumeRequest): Promise<Token
|
|||||||
/**
|
/**
|
||||||
* 读取本次签署的完整上下文:患者信息 / 签署人 / 文书正文 / 医生已填结果。
|
* 读取本次签署的完整上下文:患者信息 / 签署人 / 文书正文 / 医生已填结果。
|
||||||
*
|
*
|
||||||
* 契约见 `.workbuddy-ai/患者端H5真实接入方案-2026-09-28.md` 第三节。
|
* Context 使用已消费凭证,不再把原始一次性 Token 放进请求 URL、日志或后续请求。
|
||||||
* **后端就绪前这个调用会失败**,所以调用方必须能优雅降级 ——
|
|
||||||
* 拿不到正文就不能放行签署(见 `useSignFlow` 的 `documentError`)。
|
|
||||||
*/
|
*/
|
||||||
export function fetchSigningContext(token: string): Promise<SigningContextResponse> {
|
export function fetchSigningContext(proof: SigningProof): Promise<SigningContextResponse> {
|
||||||
return request
|
return request
|
||||||
.get<ApiResponse<SigningContextResponse>>('/v1/sign-deliveries/token/context', {
|
.post<ApiResponse<SigningContextResponse>>('/v1/sign-deliveries/token/context', proof)
|
||||||
params: { token },
|
|
||||||
})
|
|
||||||
.then(unwrapApiResponse)
|
.then(unwrapApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createIdempotencyKey(value?: string) {
|
|
||||||
if (value) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
||||||
return `patient-h5-${crypto.randomUUID()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `patient-h5-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uploadSigningSignature(
|
export function uploadSigningSignature(
|
||||||
payload: SignatureUploadInput,
|
payload: SignatureUploadInput,
|
||||||
): Promise<SignatureUploadResponse> {
|
): Promise<SignatureUploadResponse> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', payload.file, 'signature.png')
|
formData.append('file', payload.file, 'signature.png')
|
||||||
formData.append('metadata', payload.metadata)
|
formData.append('deliveryId', payload.deliveryId)
|
||||||
|
formData.append('uploadToken', payload.uploadToken)
|
||||||
|
formData.append(
|
||||||
|
'metadata',
|
||||||
|
new Blob([JSON.stringify(payload.metadata)], { type: 'application/json' }),
|
||||||
|
)
|
||||||
|
formData.append(
|
||||||
|
'signer',
|
||||||
|
new Blob([JSON.stringify(payload.signer)], { type: 'application/json' }),
|
||||||
|
)
|
||||||
|
|
||||||
return request
|
return request
|
||||||
.post<ApiResponse<SignatureUploadResponse>>(
|
.post<ApiResponse<SignatureUploadResponse>>(
|
||||||
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
|
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
params: {
|
|
||||||
deliveryId: payload.deliveryId,
|
|
||||||
uploadToken: payload.uploadToken,
|
|
||||||
},
|
|
||||||
headers: {
|
headers: {
|
||||||
'Idempotency-Key': createIdempotencyKey(payload.idempotencyKey),
|
'Idempotency-Key': payload.idempotencyKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,38 @@ export interface TokenConsumeRequest {
|
|||||||
taskId?: string
|
taskId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignatureFieldPosition {
|
||||||
|
id: string
|
||||||
|
page: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
coordinateSystem: 'TOP_LEFT' | 'PDF_BOTTOM_LEFT'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Facts read from the actual PNG plus an optional assertion against a configured PDF field. */
|
||||||
|
export interface SignaturePngMetadata {
|
||||||
|
imageWidth: number
|
||||||
|
imageHeight: number
|
||||||
|
sizeBytes: number
|
||||||
|
sha256: string
|
||||||
|
contentType: 'image/png'
|
||||||
|
fieldId: string
|
||||||
|
page: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
coordinateSystem: SignatureFieldPosition['coordinateSystem']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningProof {
|
||||||
|
taskId: string
|
||||||
|
deliveryId: string
|
||||||
|
uploadToken: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenConsumeResponse {
|
export interface TokenConsumeResponse {
|
||||||
deliveryId: string
|
deliveryId: string
|
||||||
taskId: string
|
taskId: string
|
||||||
@@ -35,12 +67,7 @@ export interface TokenConsumeResponse {
|
|||||||
|
|
||||||
/** 患者端看到的任务状态。不是 `WAITING_SIGN` 时不该放行签署 */
|
/** 患者端看到的任务状态。不是 `WAITING_SIGN` 时不该放行签署 */
|
||||||
export type SigningTaskSignStatus =
|
export type SigningTaskSignStatus =
|
||||||
| 'WAITING_SIGN'
|
'WAITING_SIGN' | 'WAITING_CONFIRM' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'FAILED' | string
|
||||||
| 'SIGNED'
|
|
||||||
| 'EXPIRED'
|
|
||||||
| 'VOIDED'
|
|
||||||
| 'FAILED'
|
|
||||||
| string
|
|
||||||
|
|
||||||
export interface SigningContextPatient {
|
export interface SigningContextPatient {
|
||||||
name?: string | null
|
name?: string | null
|
||||||
@@ -107,10 +134,24 @@ export interface SigningContextResponse {
|
|||||||
staffAnswers?: Record<string, string> | null
|
staffAnswers?: Record<string, string> | null
|
||||||
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
||||||
fields?: SigningContextField[] | null
|
fields?: SigningContextField[] | null
|
||||||
|
/** Configured PDF signature locations, separate from body form-field definitions. */
|
||||||
|
signatureFields?: SignatureFieldPosition[] | null
|
||||||
|
/** Recoverable summary of the real server pipeline and artifacts after a successful upload. */
|
||||||
|
uploadResult?: SignatureUploadResponse | null
|
||||||
|
/** Server-owned patient signature completion timestamp (separate from department confirmation). */
|
||||||
|
signedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SignatureUploadStatus =
|
export type SignatureUploadStatus =
|
||||||
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' | string
|
| 'CREATED'
|
||||||
|
| 'WAITING_SIGN'
|
||||||
|
| 'SIGNED'
|
||||||
|
| 'EXPIRED'
|
||||||
|
| 'VOIDED'
|
||||||
|
| 'GENERATING'
|
||||||
|
| 'WAITING_CONFIRM'
|
||||||
|
| 'FAILED'
|
||||||
|
| string
|
||||||
|
|
||||||
export interface SignatureUploadResponse {
|
export interface SignatureUploadResponse {
|
||||||
taskId: string
|
taskId: string
|
||||||
@@ -132,6 +173,13 @@ export interface SignatureUploadInput {
|
|||||||
deliveryId: string
|
deliveryId: string
|
||||||
uploadToken: string
|
uploadToken: string
|
||||||
file: Blob
|
file: Blob
|
||||||
metadata: string
|
metadata: SignaturePngMetadata
|
||||||
idempotencyKey?: string
|
signer: {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
relation: string
|
||||||
|
consentAccepted: true
|
||||||
|
observedSignedAt: string
|
||||||
|
}
|
||||||
|
idempotencyKey: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import type {
|
|||||||
import { DEMO_DOCUMENT_HTML, DEMO_PATIENT_FIELD_ID, DEMO_STAFF_ANSWERS } from '@/mock/demo-document'
|
import { DEMO_DOCUMENT_HTML, DEMO_PATIENT_FIELD_ID, DEMO_STAFF_ANSWERS } from '@/mock/demo-document'
|
||||||
import { DEMO_NARCOTIC_HTML, DEMO_NARCOTIC_STAFF_ANSWERS } from '@/mock/demo-narcotic'
|
import { DEMO_NARCOTIC_HTML, DEMO_NARCOTIC_STAFF_ANSWERS } from '@/mock/demo-narcotic'
|
||||||
import type { SignerRelation, SignFlowState } from '@/types/signing'
|
import type { SignerRelation, SignFlowState } from '@/types/signing'
|
||||||
|
import {
|
||||||
|
readPendingSigningUpload,
|
||||||
|
removePendingSigningUpload,
|
||||||
|
savePendingSigningUpload,
|
||||||
|
} from '@/utils/signing-vault'
|
||||||
|
import { buildSignaturePngMetadata } from '@/utils/signing-png'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地会话(sessionStorage)。
|
* 本地会话(sessionStorage)。
|
||||||
@@ -22,17 +28,18 @@ import type { SignerRelation, SignFlowState } from '@/types/signing'
|
|||||||
*/
|
*/
|
||||||
const SESSION_KEY = 'medical-sign:patient-session'
|
const SESSION_KEY = 'medical-sign:patient-session'
|
||||||
|
|
||||||
/** 后端没给 `expiresAt` 时的兜底有效期,避免本地会话永不过期 */
|
|
||||||
const SESSION_FALLBACK_TTL_MS = 2 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
interface PersistedSession {
|
interface PersistedSession {
|
||||||
taskId: string
|
taskId: string | null
|
||||||
taskNo: string
|
taskNo: string
|
||||||
deliveryId: string
|
deliveryId: string | null
|
||||||
uploadToken: string
|
uploadToken: string | null
|
||||||
channel: SignDeliveryChannel | ''
|
channel: SignDeliveryChannel | ''
|
||||||
expiresAt: string | null
|
expiresAt: string | null
|
||||||
savedAt: number
|
savedAt: number
|
||||||
|
/** 保存于首次 consume 之前;响应丢失后使用同一 token 和 key 安全重试。 */
|
||||||
|
pendingToken: string | null
|
||||||
|
consumeIdempotencyKey: string | null
|
||||||
|
uploadIdempotencyKey: string | null
|
||||||
/** 连上下文一起缓存:刷新后没有 token 可再用,重拉不了 */
|
/** 连上下文一起缓存:刷新后没有 token 可再用,重拉不了 */
|
||||||
context: SigningContextResponse | null
|
context: SigningContextResponse | null
|
||||||
}
|
}
|
||||||
@@ -52,6 +59,7 @@ const initialState: SignFlowState = {
|
|||||||
contentSha256: '',
|
contentSha256: '',
|
||||||
staffAnswers: {},
|
staffAnswers: {},
|
||||||
fields: [],
|
fields: [],
|
||||||
|
signatureFields: [],
|
||||||
documentLoaded: false,
|
documentLoaded: false,
|
||||||
documentError: '',
|
documentError: '',
|
||||||
taskNo: 'CT-20260827-001',
|
taskNo: 'CT-20260827-001',
|
||||||
@@ -75,18 +83,24 @@ export const signingState = reactive<SignFlowState>({ ...initialState })
|
|||||||
|
|
||||||
/** 最近一次拿到的上下文,只用于写本地缓存 */
|
/** 最近一次拿到的上下文,只用于写本地缓存 */
|
||||||
let lastContext: SigningContextResponse | null = null
|
let lastContext: SigningContextResponse | null = null
|
||||||
/** 会话有效期:consume 不返回,由 context 补上 */
|
|
||||||
let sessionExpiresAt: string | null = null
|
let sessionExpiresAt: string | null = null
|
||||||
|
let pendingToken: string | null = null
|
||||||
|
let consumeIdempotencyKey: string | null = null
|
||||||
|
let uploadIdempotencyKey: string | null = null
|
||||||
|
|
||||||
export function resetSignFlow() {
|
export function resetSignFlow() {
|
||||||
Object.assign(signingState, {
|
Object.assign(signingState, {
|
||||||
...initialState,
|
...initialState,
|
||||||
staffAnswers: {},
|
staffAnswers: {},
|
||||||
fields: [],
|
fields: [],
|
||||||
|
signatureFields: [],
|
||||||
uploadResult: null,
|
uploadResult: null,
|
||||||
})
|
})
|
||||||
lastContext = null
|
lastContext = null
|
||||||
sessionExpiresAt = null
|
sessionExpiresAt = null
|
||||||
|
pendingToken = null
|
||||||
|
consumeIdempotencyKey = null
|
||||||
|
uploadIdempotencyKey = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
@@ -105,7 +119,10 @@ function readPersistedSession(): PersistedSession | null {
|
|||||||
|
|
||||||
const parsed = JSON.parse(raw) as PersistedSession
|
const parsed = JSON.parse(raw) as PersistedSession
|
||||||
|
|
||||||
if (!parsed?.taskId || !parsed?.uploadToken) {
|
if (
|
||||||
|
!parsed ||
|
||||||
|
(!parsed.pendingToken && (!parsed.taskId || !parsed.deliveryId || !parsed.uploadToken))
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,26 +132,40 @@ function readPersistedSession(): PersistedSession | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createIdempotencyKey() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `patient-h5-${crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `patient-h5-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
function persistSession() {
|
function persistSession() {
|
||||||
if (!signingState.taskId || !signingState.uploadToken) {
|
if (
|
||||||
|
!pendingToken &&
|
||||||
|
(!signingState.taskId || !signingState.deliveryId || !signingState.uploadToken)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: PersistedSession = {
|
const payload: PersistedSession = {
|
||||||
taskId: signingState.taskId,
|
taskId: signingState.taskId || null,
|
||||||
taskNo: signingState.taskNo,
|
taskNo: signingState.taskNo,
|
||||||
deliveryId: signingState.deliveryId,
|
deliveryId: signingState.deliveryId || null,
|
||||||
uploadToken: signingState.uploadToken,
|
uploadToken: signingState.uploadToken || null,
|
||||||
channel: signingState.channel,
|
channel: signingState.channel,
|
||||||
expiresAt: sessionExpiresAt,
|
expiresAt: sessionExpiresAt,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
|
pendingToken,
|
||||||
|
consumeIdempotencyKey,
|
||||||
|
uploadIdempotencyKey,
|
||||||
context: lastContext,
|
context: lastContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload))
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload))
|
||||||
} catch {
|
} catch {
|
||||||
// 无痕模式 / 配额写满:存不下就不存,不影响本次签署
|
throw new Error('浏览器无法保存本次签署恢复凭证,请检查隐私或存储设置后重新打开链接。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +184,8 @@ function isSessionExpired(session: PersistedSession) {
|
|||||||
return expiresAt <= Date.now()
|
return expiresAt <= Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
return Date.now() - session.savedAt > SESSION_FALLBACK_TTL_MS
|
// A response without an explicit expiry is still bounded to the service's 48h signing window.
|
||||||
|
return Date.now() - session.savedAt > 48 * 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用本地会话恢复现场。恢复不了返回 false,由调用方走「链接无效」分支 */
|
/** 用本地会话恢复现场。恢复不了返回 false,由调用方走「链接无效」分支 */
|
||||||
@@ -170,18 +202,21 @@ function restoreSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
signingState.mode = 'real'
|
signingState.mode = 'real'
|
||||||
signingState.taskId = session.taskId
|
signingState.taskId = session.taskId ?? ''
|
||||||
signingState.taskNo = session.taskNo || session.taskId
|
signingState.taskNo = session.taskNo || session.taskId || ''
|
||||||
signingState.deliveryId = session.deliveryId
|
signingState.deliveryId = session.deliveryId ?? ''
|
||||||
signingState.uploadToken = session.uploadToken
|
signingState.uploadToken = session.uploadToken ?? ''
|
||||||
signingState.channel = session.channel
|
signingState.channel = session.channel
|
||||||
sessionExpiresAt = session.expiresAt
|
sessionExpiresAt = session.expiresAt
|
||||||
|
pendingToken = session.pendingToken
|
||||||
|
consumeIdempotencyKey = session.consumeIdempotencyKey
|
||||||
|
uploadIdempotencyKey = session.uploadIdempotencyKey
|
||||||
|
|
||||||
if (session.context) {
|
if (session.context) {
|
||||||
applyContext(session.context)
|
applyContext(session.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
signingState.status = 'ready'
|
signingState.status = session.pendingToken ? 'loading' : 'ready'
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +291,15 @@ function applyContext(context: SigningContextResponse) {
|
|||||||
signingState.fields = [...context.fields]
|
signingState.fields = [...context.fields]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.signatureFields) {
|
||||||
|
const patientField = context.signatureFields.find((field) => field.id === 'signature')
|
||||||
|
signingState.signatureFields = patientField
|
||||||
|
? [patientField]
|
||||||
|
: context.signatureFields.length === 1
|
||||||
|
? [...context.signatureFields]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
if (context.status) {
|
if (context.status) {
|
||||||
signingState.taskStatus = context.status
|
signingState.taskStatus = context.status
|
||||||
}
|
}
|
||||||
@@ -268,24 +312,96 @@ function applyContext(context: SigningContextResponse) {
|
|||||||
signingState.deliveryId = context.deliveryId
|
signingState.deliveryId = context.deliveryId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.uploadResult) {
|
||||||
|
signingState.uploadResult = context.uploadResult
|
||||||
|
signingState.pipelineId = context.uploadResult.pipelineId
|
||||||
|
}
|
||||||
|
|
||||||
|
signingState.backendStatus = context.status ?? signingState.backendStatus
|
||||||
|
signingState.signatureCaptured = Boolean(
|
||||||
|
context.uploadResult ||
|
||||||
|
context.status === 'GENERATING' ||
|
||||||
|
context.status === 'WAITING_CONFIRM' ||
|
||||||
|
context.status === 'SIGNED',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (context.uploadResult?.completedAt) {
|
||||||
|
signingState.submittedAt = context.uploadResult.completedAt
|
||||||
|
} else if (context.signedAt) {
|
||||||
|
signingState.submittedAt = context.signedAt
|
||||||
|
}
|
||||||
|
|
||||||
if (context.expiresAt) {
|
if (context.expiresAt) {
|
||||||
sessionExpiresAt = context.expiresAt
|
sessionExpiresAt = context.expiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
signingState.documentLoaded = Boolean(signingState.documentContentHtml)
|
const hasPatientSignatureField = signingState.signatureFields.length > 0
|
||||||
|
signingState.documentLoaded =
|
||||||
|
Boolean(signingState.documentContentHtml) && hasPatientSignatureField
|
||||||
signingState.documentError = signingState.documentLoaded
|
signingState.documentError = signingState.documentLoaded
|
||||||
? ''
|
? ''
|
||||||
|
: signingState.documentContentHtml
|
||||||
|
? '本文书尚未配置有效的患者 PDF 签名位置,请联系医务人员。'
|
||||||
: '暂未读取到本次签署的文书正文,无法展示正式文书。'
|
: '暂未读取到本次签署的文书正文,无法展示正式文书。'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSigningContext(token: string) {
|
function getHttpStatus(error: unknown) {
|
||||||
try {
|
if (typeof error !== 'object' || error === null || !('response' in error)) {
|
||||||
applyContext(await fetchSigningContext(token))
|
return undefined
|
||||||
} catch {
|
}
|
||||||
// 接口未接入 / 任务无效:如实告知,**不放行签署** ——
|
|
||||||
// 让患者在没看过正式文书的情况下签名,比签不了更严重
|
const response = (error as { response?: { status?: unknown } }).response
|
||||||
|
return typeof response?.status === 'number' ? response.status : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRejectedProof(error: unknown) {
|
||||||
|
const status = getHttpStatus(error)
|
||||||
|
return status === 401 || status === 403 || status === 404 || status === 410 || status === 422
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentProof() {
|
||||||
|
if (!signingState.taskId || !signingState.deliveryId || !signingState.uploadToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId: signingState.taskId,
|
||||||
|
deliveryId: signingState.deliveryId,
|
||||||
|
uploadToken: signingState.uploadToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPostUploadStatus(status: string) {
|
||||||
|
return status === 'GENERATING' || status === 'WAITING_CONFIRM' || status === 'SIGNED'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSigningContext(): Promise<SigningContextResponse | null> {
|
||||||
|
const proof = currentProof()
|
||||||
|
|
||||||
|
if (!proof) {
|
||||||
signingState.documentLoaded = false
|
signingState.documentLoaded = false
|
||||||
signingState.documentError = '暂时无法读取本次签署的文书内容,请联系现场医务人员。'
|
signingState.documentError = '签署凭证不完整,请重新打开签署链接。'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await fetchSigningContext(proof)
|
||||||
|
applyContext(context)
|
||||||
|
persistSession()
|
||||||
|
return context
|
||||||
|
} catch (error) {
|
||||||
|
if (isRejectedProof(error)) {
|
||||||
|
clearPersistedSession()
|
||||||
|
await removePendingSigningUpload(signingState.taskId).catch(() => undefined)
|
||||||
|
signingState.taskId = ''
|
||||||
|
signingState.deliveryId = ''
|
||||||
|
signingState.uploadToken = ''
|
||||||
|
}
|
||||||
|
signingState.documentLoaded = false
|
||||||
|
signingState.documentError = isRejectedProof(error)
|
||||||
|
? '签署凭证已失效或被撤销,请联系现场医务人员重新发起。'
|
||||||
|
: '暂时无法读取本次签署的文书内容,请检查网络后重试。'
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,8 +415,7 @@ async function loadSigningContext(token: string) {
|
|||||||
function applyDemoContext() {
|
function applyDemoContext() {
|
||||||
// `?demo=narcotic` 切到精麻同意书:它是演示文库(clinical-web)里唯一真实存在的模板,
|
// `?demo=narcotic` 切到精麻同意书:它是演示文库(clinical-web)里唯一真实存在的模板,
|
||||||
// 字段也最多(代办人 / 身份核验 / 用药信息),最能压出占位符渲染的问题
|
// 字段也最多(代办人 / 身份核验 / 用药信息),最能压出占位符渲染的问题
|
||||||
const useNarcotic =
|
const useNarcotic = new URLSearchParams(window.location.search).get('demo') === 'narcotic'
|
||||||
new URLSearchParams(window.location.search).get('demo') === 'narcotic'
|
|
||||||
|
|
||||||
signingState.documentTitle = useNarcotic
|
signingState.documentTitle = useNarcotic
|
||||||
? '麻醉药品、精神药品知情同意书'
|
? '麻醉药品、精神药品知情同意书'
|
||||||
@@ -327,9 +442,15 @@ export async function initializeSignFlow(token?: string) {
|
|||||||
|
|
||||||
const candidate = token?.trim()
|
const candidate = token?.trim()
|
||||||
|
|
||||||
// 地址栏没有 token 时先尝试恢复本地会话(刷新 / 切后台回来都会走到这里)
|
// 地址栏没有 token 时恢复:若上次消费响应丢失,按原 key 重放;若已经拿到 proof,重取上下文。
|
||||||
if (!candidate && restoreSession()) {
|
if (!candidate && restoreSession()) {
|
||||||
return true
|
if (pendingToken && consumeIdempotencyKey) {
|
||||||
|
return consumeAndLoad(pendingToken, consumeIdempotencyKey)
|
||||||
|
}
|
||||||
|
const context = await loadSigningContext()
|
||||||
|
signingState.status = context ? 'ready' : 'error'
|
||||||
|
signingState.errorMessage = context ? '' : signingState.documentError
|
||||||
|
return Boolean(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false'
|
const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false'
|
||||||
@@ -350,19 +471,38 @@ export async function initializeSignFlow(token?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
signingState.status = 'loading'
|
signingState.status = 'loading'
|
||||||
|
pendingToken = candidate
|
||||||
|
consumeIdempotencyKey = createIdempotencyKey()
|
||||||
|
// Store before the network call so a response lost after server commit can be replayed safely.
|
||||||
|
persistSession()
|
||||||
|
return consumeAndLoad(candidate, consumeIdempotencyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeAndLoad(token: string, idempotencyKey: string) {
|
||||||
|
signingState.status = 'loading'
|
||||||
try {
|
try {
|
||||||
const session = await consumeSigningToken({ token: candidate })
|
const session = await consumeSigningToken({ token }, idempotencyKey)
|
||||||
|
|
||||||
signingState.taskId = session.taskId
|
signingState.taskId = session.taskId
|
||||||
signingState.taskNo = session.taskId
|
signingState.taskNo = session.taskId
|
||||||
signingState.deliveryId = session.deliveryId
|
signingState.deliveryId = session.deliveryId
|
||||||
signingState.uploadToken = session.uploadToken
|
signingState.uploadToken = session.uploadToken
|
||||||
signingState.channel = session.channel
|
signingState.channel = session.channel
|
||||||
|
pendingToken = null
|
||||||
signingState.status = 'ready'
|
signingState.status = 'ready'
|
||||||
|
|
||||||
await loadSigningContext(candidate)
|
|
||||||
persistSession()
|
persistSession()
|
||||||
|
|
||||||
|
const context = await loadSigningContext()
|
||||||
|
if (!context) {
|
||||||
|
signingState.status = 'error'
|
||||||
|
signingState.errorMessage = signingState.documentError
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server status is authoritative after response loss; do not ask the patient to sign twice.
|
||||||
|
if (isPostUploadStatus(context.status ?? '')) {
|
||||||
|
signingState.signatureCaptured = true
|
||||||
|
signingState.status = 'submitted'
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signingState.status = 'error'
|
signingState.status = 'error'
|
||||||
@@ -374,7 +514,7 @@ export async function initializeSignFlow(token?: string) {
|
|||||||
export async function submitRealSignature(input: {
|
export async function submitRealSignature(input: {
|
||||||
file: Blob
|
file: Blob
|
||||||
consentAccepted: boolean
|
consentAccepted: boolean
|
||||||
}): Promise<SignatureUploadResponse> {
|
}): Promise<SignatureUploadResponse | null> {
|
||||||
if (signingState.mode !== 'real') {
|
if (signingState.mode !== 'real') {
|
||||||
throw new Error('当前是演示签署流程')
|
throw new Error('当前是演示签署流程')
|
||||||
}
|
}
|
||||||
@@ -392,32 +532,77 @@ export async function submitRealSignature(input: {
|
|||||||
signingState.errorMessage = ''
|
signingState.errorMessage = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const signatureField = signingState.signatureFields[0]
|
||||||
|
if (!signatureField) {
|
||||||
|
throw new Error('当前文书未配置患者签名位置,请联系现场医务人员。')
|
||||||
|
}
|
||||||
|
const signerType = signingState.signerType.toUpperCase() === 'FAMILY' ? 'FAMILY' : 'SELF'
|
||||||
|
const signerName = signingState.signerName.trim() || signingState.patientName.trim()
|
||||||
|
const relation = signingState.relation || '本人'
|
||||||
|
if (!signerName) {
|
||||||
|
throw new Error('签署人信息不完整,请联系现场医务人员。')
|
||||||
|
}
|
||||||
|
const observedSignedAt = new Date().toISOString()
|
||||||
|
const metadata = await buildSignaturePngMetadata(input.file, signatureField)
|
||||||
|
const pending = await readPendingSigningUpload(signingState.taskId)
|
||||||
|
const idempotencyKey = pending?.idempotencyKey ?? uploadIdempotencyKey ?? createIdempotencyKey()
|
||||||
|
uploadIdempotencyKey = idempotencyKey
|
||||||
|
const signer = {
|
||||||
|
type: signerType,
|
||||||
|
name: signerName,
|
||||||
|
relation,
|
||||||
|
consentAccepted: true as const,
|
||||||
|
observedSignedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the exact PNG and idempotency key before upload; response loss can then retry
|
||||||
|
// without asking the patient to redraw or creating a second pipeline.
|
||||||
|
await savePendingSigningUpload({
|
||||||
|
taskId: signingState.taskId,
|
||||||
|
deliveryId: signingState.deliveryId,
|
||||||
|
expiresAt: sessionExpiresAt,
|
||||||
|
file: input.file,
|
||||||
|
metadata: pending?.metadata ?? metadata,
|
||||||
|
signer: pending?.signer ?? signer,
|
||||||
|
idempotencyKey,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = await readPendingSigningUpload(signingState.taskId)
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('签署恢复信息已过期,请联系现场医务人员重新发起。')
|
||||||
|
}
|
||||||
const result = await uploadSigningSignature({
|
const result = await uploadSigningSignature({
|
||||||
taskId: signingState.taskId,
|
taskId: signingState.taskId,
|
||||||
deliveryId: signingState.deliveryId,
|
deliveryId: signingState.deliveryId,
|
||||||
uploadToken: signingState.uploadToken,
|
uploadToken: signingState.uploadToken,
|
||||||
file: input.file,
|
file: payload.file,
|
||||||
metadata: JSON.stringify({
|
metadata: payload.metadata,
|
||||||
// 签署人信息由医生在医护端填好,患者端原样带上,不再自己填
|
signer: payload.signer,
|
||||||
signerName: signingState.signerName,
|
idempotencyKey: payload.idempotencyKey,
|
||||||
signerType: signingState.signerType,
|
|
||||||
relation: signingState.relation,
|
|
||||||
consentAccepted: input.consentAccepted,
|
|
||||||
signedAt: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
signingState.signatureCaptured = true
|
signingState.signatureCaptured = true
|
||||||
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
signingState.submittedAt = result.completedAt ?? new Date().toLocaleString('zh-CN')
|
||||||
signingState.backendStatus = result.status
|
signingState.backendStatus = result.status
|
||||||
signingState.pipelineId = result.pipelineId
|
signingState.pipelineId = result.pipelineId
|
||||||
signingState.uploadResult = result
|
signingState.uploadResult = result
|
||||||
// 凭证用掉了就清掉:既避免重复提交,也不在这台设备上留着
|
// 保留短期 proof 以便结果页刷新时安全读取状态;服务端只允许它读上下文,不能重复上传。
|
||||||
signingState.uploadToken = ''
|
|
||||||
signingState.status = 'submitted'
|
signingState.status = 'submitted'
|
||||||
clearPersistedSession()
|
await removePendingSigningUpload(signingState.taskId)
|
||||||
|
persistSession()
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const refreshedContext = await loadSigningContext().catch(() => null)
|
||||||
|
if (refreshedContext && isPostUploadStatus(refreshedContext.status ?? '')) {
|
||||||
|
signingState.signatureCaptured = true
|
||||||
|
signingState.status = 'submitted'
|
||||||
|
signingState.backendStatus = refreshedContext.status ?? ''
|
||||||
|
signingState.submittedAt = refreshedContext.signedAt ?? new Date().toISOString()
|
||||||
|
await removePendingSigningUpload(signingState.taskId).catch(() => undefined)
|
||||||
|
persistSession()
|
||||||
|
return null
|
||||||
|
}
|
||||||
signingState.status = 'ready'
|
signingState.status = 'ready'
|
||||||
signingState.errorMessage = getErrorMessage(error)
|
signingState.errorMessage = getErrorMessage(error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { SignDeliveryChannel, SignatureUploadResponse, SigningContextField } from '@/api/types'
|
import type {
|
||||||
|
SignDeliveryChannel,
|
||||||
|
SignatureFieldPosition,
|
||||||
|
SignatureUploadResponse,
|
||||||
|
SigningContextField,
|
||||||
|
} from '@/api/types'
|
||||||
|
|
||||||
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
||||||
|
|
||||||
@@ -34,6 +39,8 @@ export interface SignFlowState {
|
|||||||
staffAnswers: Record<string, string>
|
staffAnswers: Record<string, string>
|
||||||
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
||||||
fields: SigningContextField[]
|
fields: SigningContextField[]
|
||||||
|
/** PDF points and page number supplied by the task's frozen template version. */
|
||||||
|
signatureFields: SignatureFieldPosition[]
|
||||||
/** 有没有真的拿到正文 —— 没拿到就不该让患者签 */
|
/** 有没有真的拿到正文 —— 没拿到就不该让患者签 */
|
||||||
documentLoaded: boolean
|
documentLoaded: boolean
|
||||||
documentError: string
|
documentError: string
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { SignatureFieldPosition, SignaturePngMetadata } from '@/api/types'
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
|
||||||
|
|
||||||
|
function hex(value: ArrayBuffer) {
|
||||||
|
return [...new Uint8Array(value)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readImageSize(file: Blob) {
|
||||||
|
if (typeof createImageBitmap === 'function') {
|
||||||
|
const bitmap = await createImageBitmap(file)
|
||||||
|
const size = { width: bitmap.width, height: bitmap.height }
|
||||||
|
bitmap.close()
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = new Image()
|
||||||
|
image.src = objectUrl
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
image.onload = () => resolve()
|
||||||
|
image.onerror = () => reject(new Error('无法读取签名图片尺寸,请重新签名。'))
|
||||||
|
})
|
||||||
|
return { width: image.naturalWidth, height: image.naturalHeight }
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSignaturePngMetadata(
|
||||||
|
file: Blob,
|
||||||
|
signatureField: SignatureFieldPosition,
|
||||||
|
): Promise<SignaturePngMetadata> {
|
||||||
|
if (file.type !== 'image/png') {
|
||||||
|
throw new Error('签名图片必须是 PNG 格式。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = new Uint8Array(await file.slice(0, PNG_SIGNATURE.length).arrayBuffer())
|
||||||
|
|
||||||
|
if (!PNG_SIGNATURE.every((byte, index) => header[index] === byte)) {
|
||||||
|
throw new Error('签名图片内容不是有效的 PNG 文件,请重新签名。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto?.subtle) {
|
||||||
|
throw new Error('当前浏览器无法校验签名图片,请通过 HTTPS 页面重新打开签署链接。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ width, height }, digest] = await Promise.all([
|
||||||
|
readImageSize(file),
|
||||||
|
crypto.subtle.digest('SHA-256', await file.arrayBuffer()),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error('签名图片尺寸无效,请重新签名。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageWidth: width,
|
||||||
|
imageHeight: height,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
sha256: hex(digest),
|
||||||
|
contentType: 'image/png',
|
||||||
|
fieldId: signatureField.id,
|
||||||
|
page: signatureField.page,
|
||||||
|
x: signatureField.x,
|
||||||
|
y: signatureField.y,
|
||||||
|
width: signatureField.width,
|
||||||
|
height: signatureField.height,
|
||||||
|
coordinateSystem: signatureField.coordinateSystem,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { SignaturePngMetadata } from '@/api/types'
|
||||||
|
|
||||||
|
export interface PendingSigningUpload {
|
||||||
|
taskId: string
|
||||||
|
deliveryId: string
|
||||||
|
expiresAt: string | null
|
||||||
|
file: Blob
|
||||||
|
metadata: SignaturePngMetadata
|
||||||
|
signer: {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
relation: string
|
||||||
|
consentAccepted: true
|
||||||
|
observedSignedAt: string
|
||||||
|
}
|
||||||
|
idempotencyKey: string
|
||||||
|
savedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATABASE_NAME = 'medical-sign:patient-vault'
|
||||||
|
const DATABASE_VERSION = 1
|
||||||
|
const UPLOAD_STORE = 'pending-uploads'
|
||||||
|
|
||||||
|
function openDatabase(): Promise<IDBDatabase> {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
return Promise.reject(new Error('此浏览器不支持安全保存签署恢复信息,请使用最新版浏览器。'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result
|
||||||
|
|
||||||
|
if (!database.objectStoreNames.contains(UPLOAD_STORE)) {
|
||||||
|
database.createObjectStore(UPLOAD_STORE, { keyPath: 'taskId' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(new Error('无法保存签署恢复信息,请检查浏览器存储设置。'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStore<T>(
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
action: (store: IDBObjectStore) => IDBRequest<T>,
|
||||||
|
) {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(UPLOAD_STORE, mode)
|
||||||
|
const request = action(transaction.objectStore(UPLOAD_STORE))
|
||||||
|
let result: T
|
||||||
|
request.onsuccess = () => {
|
||||||
|
result = request.result
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(new Error('签署恢复信息保存失败,请稍后重试。'))
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
database.close()
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
transaction.onerror = () => {
|
||||||
|
database.close()
|
||||||
|
reject(new Error('签署恢复信息保存失败,请稍后重试。'))
|
||||||
|
}
|
||||||
|
transaction.onabort = () => {
|
||||||
|
database.close()
|
||||||
|
reject(new Error('签署恢复信息保存失败,请稍后重试。'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePendingSigningUpload(upload: PendingSigningUpload): Promise<IDBValidKey> {
|
||||||
|
return withStore('readwrite', (store) => store.put(upload))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPendingSigningUpload(
|
||||||
|
taskId: string,
|
||||||
|
): Promise<PendingSigningUpload | null> {
|
||||||
|
const upload = await withStore<PendingSigningUpload | undefined>('readonly', (store) =>
|
||||||
|
store.get(taskId),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!upload) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = upload.expiresAt ? new Date(upload.expiresAt).getTime() : Number.NaN
|
||||||
|
|
||||||
|
if (!Number.isNaN(expiresAt) && expiresAt <= Date.now()) {
|
||||||
|
await removePendingSigningUpload(taskId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return upload
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePendingSigningUpload(taskId: string): Promise<void> {
|
||||||
|
await withStore<undefined>('readwrite', (store) => store.delete(taskId))
|
||||||
|
}
|
||||||
@@ -69,6 +69,20 @@ const terminal = computed(() => {
|
|||||||
? `签署时间:${signingState.submittedAt}。无需重复签署。`
|
? `签署时间:${signingState.submittedAt}。无需重复签署。`
|
||||||
: '本次文书已完成签署,无需重复签署。',
|
: '本次文书已完成签署,无需重复签署。',
|
||||||
}
|
}
|
||||||
|
case 'WAITING_CONFIRM':
|
||||||
|
return {
|
||||||
|
eyebrow: '患者已签署',
|
||||||
|
title: '签署已提交,等待科室确认',
|
||||||
|
copy: signingState.submittedAt
|
||||||
|
? `提交时间:${signingState.submittedAt}。无需重复签署。`
|
||||||
|
: '患者签名已提交,等待医务人员完成后续确认。无需重复签署。',
|
||||||
|
}
|
||||||
|
case 'GENERATING':
|
||||||
|
return {
|
||||||
|
eyebrow: '签名已提交',
|
||||||
|
title: '文书正在生成',
|
||||||
|
copy: '签名已收到,系统正在生成正式文书。无需重复签署。',
|
||||||
|
}
|
||||||
case 'EXPIRED':
|
case 'EXPIRED':
|
||||||
return {
|
return {
|
||||||
eyebrow: '链接已过期',
|
eyebrow: '链接已过期',
|
||||||
@@ -197,7 +211,9 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<strong>请使用实际签署人的信息</strong>
|
<strong>请使用实际签署人的信息</strong>
|
||||||
<p>
|
<p>
|
||||||
本次签署人:{{ signerLine }}。文书内容已由医务人员填写完成,如与实际不符请联系现场医务人员。
|
本次签署人:{{
|
||||||
|
signerLine
|
||||||
|
}}。文书内容已由医务人员填写完成,如与实际不符请联系现场医务人员。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,15 +6,18 @@ import { clearPersistedSession, resetSignFlow, signingState } from '@/composable
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const resultTitle = computed(() =>
|
const resultTitle = computed(() => {
|
||||||
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
if (signingState.mode !== 'real') return '知情同意书已提交'
|
||||||
? '签名已提交,文书生成中'
|
if (signingState.backendStatus === 'GENERATING') return '签名已提交,文书生成中'
|
||||||
: '知情同意书已提交',
|
if (signingState.backendStatus === 'WAITING_CONFIRM') return '签署已提交,等待科室确认'
|
||||||
)
|
return '知情同意书已提交'
|
||||||
|
})
|
||||||
|
|
||||||
const resultCopy = computed(() =>
|
const resultCopy = computed(() =>
|
||||||
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
||||||
? '签名图片已安全提交,系统正在生成签署后文书。最终状态以医签通服务端为准。'
|
? '签名图片已安全提交,系统正在生成签署后文书。最终状态以医签通服务端为准。'
|
||||||
|
: signingState.mode === 'real' && signingState.backendStatus === 'WAITING_CONFIRM'
|
||||||
|
? '患者签名和签署文书已安全保存,等待医务人员完成科室确认。请勿重复签署。'
|
||||||
: '本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。',
|
: '本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ const statusLabel = computed(() => {
|
|||||||
|
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
SIGNED: '已签署',
|
SIGNED: '已签署',
|
||||||
|
WAITING_CONFIRM: '待科室确认',
|
||||||
GENERATING: '生成中',
|
GENERATING: '生成中',
|
||||||
WAITING_SIGN: '待签署',
|
WAITING_SIGN: '待签署',
|
||||||
FAILED: '处理失败',
|
FAILED: '处理失败',
|
||||||
|
|||||||
Reference in New Issue
Block a user