Files
xh-medical-sign-web/clinical-web/src/api/workbench/artifacts.ts
T

282 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
import { unwrapApiResponse } from '@/utils/api-response'
import { mockFlags } from '@/utils/mock-flags'
import { request } from '@/utils/request'
import { getMockSigningTask } from './signing'
import type {
ApiPageResponse,
ApiResponseOf,
SignArtifactResponseDto,
SignArtifactType,
SigningArtifact,
} from './types'
const useMockData = mockFlags.signing
type ArtifactCollection = SignArtifactResponseDto[] | ApiPageResponse<SignArtifactResponseDto>
const artifactLabels: Record<string, string> = {
ORIGINAL_PDF: 'PDF 原件',
SIGNATURE_IMAGE: '签名原图',
SIGNED_PDF: '签署后 PDF',
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
.replace('T', ' ')
.replace(/\.\d+Z$/, '')
.replace(/Z$/, '')
}
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours(),
)}:${pad(date.getMinutes())}`
}
function getCollectionRecords(collection: ArtifactCollection) {
return Array.isArray(collection) ? collection : collection.records
}
function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact {
const artifactType: SignArtifactType = dto.artifactType
const label = artifactLabels[artifactType] ?? '签署文件'
const mimeType = dto.mimeType || 'application/octet-stream'
const extension =
mimeType === 'application/pdf' ? 'pdf' : mimeType === 'image/png' ? 'png' : 'bin'
return {
id: dto.id,
taskId: dto.taskId,
pipelineId: dto.pipelineId,
artifactType,
label,
fileName: `${label}.${extension}`,
mimeType,
sizeBytes: dto.sizeBytes,
sha256: dto.sha256,
metadata: dto.metadata ?? {},
createdAt: formatDateTime(dto.createdAt),
}
}
export const isArtifactMockEnabled = useMockData
/**
* 演示签名原图产物的 id 前缀。
* 演示环境没有文件服务,下载时靠 id 反查任务里的签名图,
* 保证「签名原图」在演示里也是真能下载到的文件,而不是占位提示。
*/
const MOCK_SIGNATURE_PREFIX = 'mock-signature-'
function estimateDataUrlBytes(dataUrl: string) {
const payload = dataUrl.split(',')[1]
if (!payload) {
return 0
}
// base64 每 4 个字符还原 3 字节,末尾可能有填充,这里取近似值用于展示
return Math.floor((payload.replace(/=+$/, '').length * 3) / 4)
}
/** 演示产物:已签署且有签名图时,给出可下载的签名原图 */
function buildMockArtifacts(taskId: string): SigningArtifact[] {
const task = getMockSigningTask(taskId)
if (!task || task.status !== 'signed' || !task.signatureDataUrl) {
return []
}
return [
{
id: `${MOCK_SIGNATURE_PREFIX}${task.id}`,
taskId: task.id,
pipelineId: `mock-pipeline-${task.id}`,
artifactType: 'SIGNATURE_IMAGE',
label: artifactLabels.SIGNATURE_IMAGE ?? '签名原图',
fileName: '签名原图.png',
mimeType: 'image/png',
sizeBytes: estimateDataUrlBytes(task.signatureDataUrl),
sha256: '',
metadata: {},
createdAt: formatDateTime(task.signedAt ?? task.updatedAt),
},
]
}
/** 把签名图 dataURL 还原成可下载的二进制 Blob */
function dataUrlToBlob(dataUrl: string): Blob {
const [meta, payload] = dataUrl.split(',')
const mimeType = /data:([^;]+)/.exec(meta ?? '')?.[1] ?? 'application/octet-stream'
if (!payload) {
return new Blob([], { type: mimeType })
}
const binary = window.atob(payload)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return new Blob([bytes], { type: mimeType })
}
export async function getSigningArtifacts(taskId: string): Promise<SigningArtifact[]> {
if (useMockData) {
return buildMockArtifacts(taskId)
}
try {
const response = await request.get<ApiResponseOf<ArtifactCollection>>(
`/v1/sign-artifacts/task/${encodeURIComponent(taskId)}`,
)
return getCollectionRecords(unwrapApiResponse(response)).map(normalizeArtifact)
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return []
}
throw error
}
}
export async function getSigningArtifact(
taskId: string,
artifactId: string,
): Promise<SigningArtifact | null> {
if (useMockData) {
return null
}
try {
const response = await request.get<ApiResponseOf<SignArtifactResponseDto>>(
`/v1/sign-artifacts/task/${encodeURIComponent(taskId)}/${encodeURIComponent(artifactId)}`,
)
return normalizeArtifact(unwrapApiResponse(response))
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null
}
throw error
}
}
export function downloadSigningArtifact(artifactId: string): Promise<Blob> {
if (useMockData) {
// 演示环境没有文件服务,按 id 反查任务里的签名图,返回真实的 PNG
if (artifactId.startsWith(MOCK_SIGNATURE_PREFIX)) {
const taskId = artifactId.slice(MOCK_SIGNATURE_PREFIX.length)
const signatureDataUrl = getMockSigningTask(taskId)?.signatureDataUrl
if (signatureDataUrl) {
return Promise.resolve(dataUrlToBlob(signatureDataUrl))
}
}
return Promise.resolve(new Blob(['Mock 签署文件,仅用于界面演示。'], { type: 'text/plain' }))
}
return request.get<Blob>(`/v1/sign-artifacts/${encodeURIComponent(artifactId)}/download`, {
responseType: 'blob',
})
}
/** Blob → dataURL */
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(typeof reader.result === 'string' ? reader.result : '')
}
reader.onerror = () => {
reject(new Error('签名图读取失败'))
}
reader.readAsDataURL(blob)
})
}
/**
* 取回签名图并转成 dataURL,供文书渲染使用。
*
* 签名在后端是独立的 SIGNATURE_IMAGE 产物,任务接口(SignTaskResponseDto)
* **不返回任何签名字段**,所以前端必须自己把它取回来——否则文书里的签名位
* 永远是「待患者签名」,导出的 PDF 也就没有患者签名。
*
* 这里刻意转成 dataURL 而不是直接把下载地址塞给 <img>:文书导出 PDF 走
* html2canvas 截图,跨域的 <img src="http://..."> 会被画布安全策略拦掉
* (`useCORS` 只在服务端返回 CORS 响应头时才生效),表现为
* 「页面上看得到签名、导出的 PDF 里却是空的」。
*/
export async function fetchSignatureDataUrl(artifact: SigningArtifact): Promise<string> {
try {
return await blobToDataUrl(await downloadSigningArtifact(artifact.id))
} catch {
// 取签名图失败不该让整个详情页失败:正文、审计轨迹、产物列表都还能看
return ''
}
}
/** Restore each position from the lossless evidence sheet; legacy images remain supported. */
export async function fetchSignatureImages(
artifact: SigningArtifact,
): Promise<Record<string, string>> {
const source = await fetchSignatureDataUrl(artifact)
if (!source) return {}
const entries = artifact.metadata.signatures
if (!Array.isArray(entries))
return { [String(artifact.metadata.signatureFieldId || 'signature')]: source }
if (!entries.length) return {}
const image = new Image()
image.src = source
await image.decode()
const result: Record<string, string> = {}
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue
const { field, sourceX, sourceY, imageWidth, imageHeight } = entry
if (
typeof field?.id !== 'string' ||
![sourceX, sourceY, imageWidth, imageHeight].every(Number.isInteger) ||
sourceX < 0 ||
sourceY < 0 ||
imageWidth <= 0 ||
imageHeight <= 0 ||
sourceX + imageWidth > image.naturalWidth ||
sourceY + imageHeight > image.naturalHeight
)
continue
const canvas = document.createElement('canvas')
canvas.width = imageWidth
canvas.height = imageHeight
const context = canvas.getContext('2d')
if (!context) continue
context.drawImage(
image,
sourceX,
sourceY,
imageWidth,
imageHeight,
0,
0,
imageWidth,
imageHeight,
)
result[field.id] = canvas.toDataURL('image/png')
}
return result
}