282 lines
8.4 KiB
TypeScript
282 lines
8.4 KiB
TypeScript
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
|
||
}
|