565 lines
18 KiB
TypeScript
565 lines
18 KiB
TypeScript
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),
|
||
})
|
||
}
|