import axios from 'axios' import { unwrapApiResponse } from '@/utils/api-response' import { mockFlags } from '@/utils/mock-flags' import { request } from '@/utils/request' import { collectFieldDefs } from '@/utils/signing-fields' import type { ApiResponse, PageResult } from '@/types/common' import type { BackendCollection, BackendPage, BackendTemplateStatus, CreateTemplateRequest, CreateTemplateVersionRequest, DocumentListResponse, DocumentQuery, DocumentRecord, TemplateResponseDto, TemplateVersionResponseDto, UpdateTemplateRequest, } from './types' const useMockData = mockFlags.documents export const isDocumentMockEnabled = useMockData const mockRecords: DocumentRecord[] = [ { id: 'doc-001', name: '住院患者知情同意书', category: '住院', version: 'V2.1', status: 'published', updatedBy: '张文静', updatedAt: '2026-08-26 17:20', }, { id: 'doc-002', name: '急诊留观知情同意书', category: '急诊', version: 'V1.3', status: 'published', updatedBy: '张文静', updatedAt: '2026-08-25 10:15', }, ] /** * 后端时间戳 → 页面展示用的 `YYYY-MM-DD HH:mm`。 * * 导出给版本历史弹窗复用:版本列表与模板列表是同一批时间字段, * 各写一份格式化早晚会分叉出两种显示口径。解析不出来时原样返回去壳的字符串, * 不返回 Invalid Date。 */ export function formatTemplateDateTime(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 normalizeDocumentStatus(status: BackendTemplateStatus | null | undefined) { switch (status) { case 'PUBLISHED': case 'APPROVED': return 'published' as const case 'ARCHIVED': case 'DISABLED': return 'archived' as const case 'PENDING_REVIEW': case 'REJECTED': return 'review' as const case 'DRAFT': default: return 'draft' as const } } function mapDocument(dto: TemplateResponseDto): DocumentRecord { const versionId = dto.latestVersionId ?? dto.currentVersionId ?? undefined const version = dto.latestVersionNo ?? (dto.versionSequence ? `v${dto.versionSequence}` : '—') const backendStatus = dto.latestVersionStatus ?? dto.status return { id: dto.id, name: dto.name, code: dto.templateCode, category: dto.category ?? '未分类', version: String(version), status: normalizeDocumentStatus(backendStatus), backendStatus: backendStatus ?? undefined, updatedBy: dto.updatedBy ?? dto.createdBy ?? '—', updatedAt: formatTemplateDateTime(dto.updatedAt ?? dto.createdAt), versionId, currentVersionId: dto.currentVersionId ?? undefined, departmentId: dto.departmentId, departmentName: dto.departmentId ? undefined : '全院通用', campusId: dto.campusId, description: dto.description ?? '', // 后端补齐这两个字段后卡片会自动显示「检查类型 / 关联项目」; // 现在取不到就是 undefined,卡片不渲染对应标签,不伪造取值 examType: dto.examType ?? undefined, projectCodes: dto.projectCodes ?? undefined, // 需科室确认:后端没给就是 false(走原路直接归档), // 不能默认成 true —— 那会给所有文书凭空多插一道人工节点 needConfirm: dto.needConfirm ?? false, } } /** * 从模板版本 DTO 里取正文 HTML。 * 契约给了 contentHtml 与 content 两个别名(见 TemplateVersionResponseDto), * 这里按 contentHtml → content 的顺序取,两个都没有才返回空。 */ export function readVersionContentHtml(dto: TemplateVersionResponseDto | null): string { if (!dto) { return '' } return dto.contentHtml || dto.content || '' } /** * 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。 * * 正文填写项和 PDF 签名几何是两个独立契约:formFields 负责字段语义, * signatureFields 只负责患者签名图在服务端生成 PDF 中的位置。 */ 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) => ({ id: field.id, label: field.label, kind: field.kind, required: field.required, options: field.options, ...(field.multi === undefined ? {} : { multi: field.multi }), ...(field.risk ? { risk: field.risk } : {}), })) } /** * 读取模板当前版本的正文。 * 模板列表接口只给版本指针与哈希,不给正文, * 因此展示/编辑正文必须再取一次版本详情——这是文档库能否接入的关键一步。 */ export async function getTemplateContent(templateId: string, versionId?: string): Promise { if (useMockData) { return '' } const resolvedVersionId = versionId ?? (await getDocumentDetail(templateId))?.versionId if (!resolvedVersionId) { return '' } const version = await getTemplateVersionDetail(templateId, resolvedVersionId) return readVersionContentHtml(version) } /** * 按版本正文哈希换回正文 HTML。 * * 签署端拿不到正文:available-versions 只返回 contentSha256,sign-tasks 也只返回 * templateContentSha256。这里用哈希当 key 缓存"哈希 → 正文",命中就直接复用, * 不必重复穿透;同时哈希变了自然会取到新正文,不会出现「版本更新了还按旧正文签署」。 */ const contentCacheBySha = new Map() const contentRequestBySha = new Map>() export function readCachedTemplateContent(contentSha256: string | undefined): string { return contentSha256 ? (contentCacheBySha.get(contentSha256) ?? '') : '' } /** * 按模板版本 id 取正文并写进哈希缓存。 * 调用方(签署端)手里只有 versionId 与哈希,没有 templateId, * 因此这里先用版本详情换出 templateId,再按哈希登记。 */ export async function fetchTemplateContentByVersion( templateVersionId: string, contentSha256?: string, ): Promise { if (useMockData || !templateVersionId) { return '' } const cacheKey = contentSha256 || templateVersionId const cached = contentCacheBySha.get(cacheKey) if (cached !== undefined) { return cached } const pending = contentRequestBySha.get(cacheKey) if (pending) { return pending } const task = (async () => { // 版本详情接口挂在模板下,先反查模板 id。列表接口按 currentVersionId 过滤过于昂贵, // 这里直接走版本详情的「按版本 id 直查」路径:后端对未知 templateId 会 404, // 因此改用模板列表逐页找匹配版本(模板数量在权限范围内通常只有几十份)。 const content = await findContentByVersionId(templateVersionId) if (content) { contentCacheBySha.set(cacheKey, content) } return content })().finally(() => { contentRequestBySha.delete(cacheKey) }) contentRequestBySha.set(cacheKey, task) return task } /** 遍历权限范围内的模板,找到持有该版本的模板并取回正文 */ async function findContentByVersionId(templateVersionId: string): Promise { const firstPage = await getDocuments({ page: 1, pageSize: 200, status: 'all' }) const pageSize = Math.max(firstPage.pageSize, 1) const pageCount = Math.ceil(firstPage.total / pageSize) const remainingPages = pageCount > 1 ? await Promise.all( Array.from({ length: pageCount - 1 }, (_, index) => getDocuments({ page: index + 2, pageSize, status: 'all' }), ), ) : [] const candidates = [firstPage, ...remainingPages].flatMap((page) => page.records) const matched = candidates.find( (template) => template.versionId === templateVersionId || template.currentVersionId === templateVersionId, ) if (!matched) { return '' } const version = await getTemplateVersionDetail(matched.id, templateVersionId) return readVersionContentHtml(version) } /** 缓存里已有正文时跳过请求;供签署端按哈希判断是否需要补正文 */ export function hasCachedTemplateContent(contentSha256: string | undefined): boolean { return Boolean(contentSha256 && contentCacheBySha.has(contentSha256)) } function normalizePage( data: BackendPage | T[], fallbackPage: number, fallbackPageSize: number, ): PageResult { const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) const page = Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage) const pageSize = Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize) return { records, total: Array.isArray(data) ? records.length : (data.total ?? records.length), page, pageSize, } } function toBackendQuery(query: DocumentQuery) { const params: Record = { page: Math.max(query.page, 1), size: Math.min(Math.max(query.pageSize, 1), 200), } if (query.keyword?.trim()) { params.keyword = query.keyword.trim() } if (query.status && query.status !== 'all') { const statusMap: Record, string> = { draft: 'DRAFT', published: 'PUBLISHED', archived: 'ARCHIVED', review: 'PENDING_REVIEW', } params.status = statusMap[query.status] } if (query.campusId) { params.campusId = query.campusId } if (query.departmentId) { params.departmentId = query.departmentId } return params } function getRemoteDocuments(path: string, query: DocumentQuery): Promise { return request .get>>(path, { params: toBackendQuery(query), }) .then((response) => { const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize) return { ...page, records: page.records.map(mapDocument), } }) } export function getDocuments(query: DocumentQuery): Promise { if (!useMockData) { return getRemoteDocuments('/v1/templates', query) } const keyword = query.keyword?.trim().toLowerCase() const filteredRecords = mockRecords.filter((record) => { const matchesKeyword = !keyword || record.name.toLowerCase().includes(keyword) const matchesStatus = !query.status || query.status === 'all' || record.status === query.status return matchesKeyword && matchesStatus }) const page = Math.max(query.page, 1) const pageSize = Math.max(query.pageSize, 1) const start = (page - 1) * pageSize return Promise.resolve({ records: filteredRecords.slice(start, start + pageSize).map((record) => ({ ...record })), total: filteredRecords.length, page, pageSize, }) } export function getTemplatePermissionConfig(query: DocumentQuery): Promise { if (useMockData) { return getDocuments(query) } return getRemoteDocuments('/v1/templates/permission-config', query) } export async function getDocumentDetail(id: string): Promise { if (useMockData) { const record = mockRecords.find((item) => item.id === id) return record ? { ...record } : null } try { const response = await request.get>( `/v1/templates/${encodeURIComponent(id)}`, ) return mapDocument(unwrapApiResponse(response)) } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { return null } throw error } } export async function createTemplate(payload: CreateTemplateRequest): Promise { if (useMockData) { throw new Error('演示模式下请在文档库页面直接新建文书') } const response = await request.post>('/v1/templates', payload) return mapDocument(unwrapApiResponse(response)) } export async function updateTemplate( id: string, payload: UpdateTemplateRequest, ): Promise { if (useMockData) { throw new Error('演示模式下请在文档库页面直接编辑文书') } const response = await request.put>( `/v1/templates/${encodeURIComponent(id)}`, payload, ) return mapDocument(unwrapApiResponse(response)) } /** * 删除模板。 * 契约里模板没有 DELETE,归档由版本工作流的 archive 承担: * 「删除」在业务上等于「归档并停用」,不能真的抹掉已签署文书引用的模板版本, * 否则历史任务的 templateVersionId 会指向不存在的模板,留痕链断裂。 */ export async function deleteTemplate(id: string): Promise { if (useMockData) { throw new Error('演示模式下请在文档库页面直接删除文书') } const template = await getDocumentDetail(id) if (!template?.versionId) { throw new Error('该模板没有可归档的当前版本') } await updateTemplateVersionStatus(id, template.versionId, 'archive') } export async function createTemplateVersion( templateId: string, payload: CreateTemplateVersionRequest, ): Promise { if (useMockData) { throw new Error('演示模式下请在文档库页面直接编辑文书') } const response = await request.post>( `/v1/templates/${encodeURIComponent(templateId)}/versions`, payload, ) return unwrapApiResponse(response) } export async function getTemplateVersions( templateId: string, ): Promise { if (useMockData) { return [] } const response = await request.get>>( `/v1/templates/${encodeURIComponent(templateId)}/versions`, ) return normalizePage(unwrapApiResponse(response), 1, 200).records } export async function getTemplateVersionDetail( templateId: string, versionId: string, ): Promise { if (useMockData) { return null } try { const response = await request.get>( `/v1/templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(versionId)}`, ) return unwrapApiResponse(response) } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { return null } throw error } } export type TemplateVersionAction = 'submit-review' | 'reject' | 'approve' | 'publish' | 'enable' | 'disable' | 'archive' /** * 版本工作流动作。 * * 真实模式直接调用对应动作接口;文档库的「新增 / 编辑 / 归档」也走这里, * 保证模板状态只能经后端工作流流转,前端不自行改写 status。 */ export function updateTemplateVersionStatus( templateId: string, versionId: string, action: TemplateVersionAction, reason?: string, ): Promise { if (useMockData) { return Promise.reject(new Error('演示模式下请在文档库页面直接操作文书')) } const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined return request .post>( `/v1/templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(versionId)}/${action}`, requestData, ) .then(unwrapApiResponse) } /** * 创建模板并提交首个版本。 * * 「新增文书」在后端是两步:先建模板拿 id,再给该模板建一个版本把正文存进去。 * 只建模板不建版本会得到一个没有正文、无法发起签署的空壳模板, * 因此这里合成一个动作,调用方只需给一次表单。 */ export async function createTemplateWithContent( payload: CreateTemplateRequest, version: Omit, ): Promise { const template = await createTemplate(payload) await createTemplateVersion(template.id, { ...version, formFields: buildFormFieldsPayload(version.contentHtml), signatureFields: buildSignatureFieldsPayload(version.contentHtml), }) // 版本创建后模板的 latestVersionId/versionSequence 才更新,重新取一次拿到准确版本号 const detail = await getDocumentDetail(template.id) return detail ?? template } /** * 更新模板正文:为模板追加一个新版本。 * * 已发布的版本不可原地改写(已签署文书引用的是它),因此「编辑」落到新版本上, * 这也是后端版本模型的本意。 */ export async function updateTemplateContent( templateId: string, version: Omit, ): Promise { await createTemplateVersion(templateId, { ...version, formFields: buildFormFieldsPayload(version.contentHtml), signatureFields: buildSignatureFieldsPayload(version.contentHtml), }) }