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 const artifactLabels: Record = { 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 { if (useMockData) { return buildMockArtifacts(taskId) } try { const response = await request.get>( `/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 { if (useMockData) { return null } try { const response = await request.get>( `/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 { 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(`/v1/sign-artifacts/${encodeURIComponent(artifactId)}/download`, { responseType: 'blob', }) } /** Blob → dataURL */ function blobToDataUrl(blob: Blob): Promise { 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 而不是直接把下载地址塞给 :文书导出 PDF 走 * html2canvas 截图,跨域的 会被画布安全策略拦掉 * (`useCORS` 只在服务端返回 CORS 响应头时才生效),表现为 * 「页面上看得到签名、导出的 PDF 里却是空的」。 */ export async function fetchSignatureDataUrl(artifact: SigningArtifact): Promise { 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> { 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 = {} 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 }