Files
xh-medical-sign-web/clinical-web/src/api/management/documents.ts
T
2026-09-29 18:49:45 +08:00

565 lines
18 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 { 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<string> {
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<string, string>()
const contentRequestBySha = new Map<string, Promise<string>>()
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<string> {
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<string> {
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<T>(
data: BackendPage<T> | T[],
fallbackPage: number,
fallbackPageSize: number,
): PageResult<T> {
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<string, string | number> = {
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<Exclude<DocumentQuery['status'], undefined | 'all'>, 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<DocumentListResponse> {
return request
.get<ApiResponse<BackendCollection<TemplateResponseDto>>>(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<DocumentListResponse> {
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<DocumentListResponse> {
if (useMockData) {
return getDocuments(query)
}
return getRemoteDocuments('/v1/templates/permission-config', query)
}
export async function getDocumentDetail(id: string): Promise<DocumentRecord | null> {
if (useMockData) {
const record = mockRecords.find((item) => item.id === id)
return record ? { ...record } : null
}
try {
const response = await request.get<ApiResponse<TemplateResponseDto>>(
`/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<DocumentRecord> {
if (useMockData) {
throw new Error('演示模式下请在文档库页面直接新建文书')
}
const response = await request.post<ApiResponse<TemplateResponseDto>>('/v1/templates', payload)
return mapDocument(unwrapApiResponse(response))
}
export async function updateTemplate(
id: string,
payload: UpdateTemplateRequest,
): Promise<DocumentRecord> {
if (useMockData) {
throw new Error('演示模式下请在文档库页面直接编辑文书')
}
const response = await request.put<ApiResponse<TemplateResponseDto>>(
`/v1/templates/${encodeURIComponent(id)}`,
payload,
)
return mapDocument(unwrapApiResponse(response))
}
/**
* 删除模板。
* 契约里模板没有 DELETE,归档由版本工作流的 archive 承担:
* 「删除」在业务上等于「归档并停用」,不能真的抹掉已签署文书引用的模板版本,
* 否则历史任务的 templateVersionId 会指向不存在的模板,留痕链断裂。
*/
export async function deleteTemplate(id: string): Promise<void> {
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<TemplateVersionResponseDto> {
if (useMockData) {
throw new Error('演示模式下请在文档库页面直接编辑文书')
}
const response = await request.post<ApiResponse<TemplateVersionResponseDto>>(
`/v1/templates/${encodeURIComponent(templateId)}/versions`,
payload,
)
return unwrapApiResponse(response)
}
export async function getTemplateVersions(
templateId: string,
): Promise<TemplateVersionResponseDto[]> {
if (useMockData) {
return []
}
const response = await request.get<ApiResponse<BackendCollection<TemplateVersionResponseDto>>>(
`/v1/templates/${encodeURIComponent(templateId)}/versions`,
)
return normalizePage(unwrapApiResponse(response), 1, 200).records
}
export async function getTemplateVersionDetail(
templateId: string,
versionId: string,
): Promise<TemplateVersionResponseDto | null> {
if (useMockData) {
return null
}
try {
const response = await request.get<ApiResponse<TemplateVersionResponseDto>>(
`/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<TemplateVersionResponseDto> {
if (useMockData) {
return Promise.reject(new Error('演示模式下请在文档库页面直接操作文书'))
}
const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined
return request
.post<ApiResponse<TemplateVersionResponseDto>>(
`/v1/templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(versionId)}/${action}`,
requestData,
)
.then(unwrapApiResponse)
}
/**
* 创建模板并提交首个版本。
*
* 「新增文书」在后端是两步:先建模板拿 id,再给该模板建一个版本把正文存进去。
* 只建模板不建版本会得到一个没有正文、无法发起签署的空壳模板,
* 因此这里合成一个动作,调用方只需给一次表单。
*/
export async function createTemplateWithContent(
payload: CreateTemplateRequest,
version: Omit<CreateTemplateVersionRequest, 'signatureFields'>,
): Promise<DocumentRecord> {
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<CreateTemplateVersionRequest, 'signatureFields'>,
): Promise<void> {
await createTemplateVersion(templateId, {
...version,
formFields: buildFormFieldsPayload(version.contentHtml),
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
}