feat(文档库): 打通模板正文真实模式并新增渲染验证

模板列表接口不返回正文,展示、编辑与发起签署前需再取版本详情获取 contentHtml,
为此新增 getTemplateContent、createTemplateWithContent、updateTemplateContent 与
deleteTemplate(归档),并在签署端按 contentSha256 缓存正文快照。

同时新增 scripts/login.cjs 与 scripts/verify-pages.cjs,通过真实登录与 CDP
渲染截图、断言关键内容并收集运行时错误,并补充 .gitignore、eslint 忽略与文档说明。
This commit is contained in:
yelan
2026-09-17 14:10:45 +08:00
parent 0d271d90be
commit 81922d774f
20 changed files with 1640 additions and 187 deletions
+224 -3
View File
@@ -3,6 +3,7 @@ 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 {
@@ -102,6 +103,146 @@ function mapDocument(dto: TemplateResponseDto): DocumentRecord {
}
}
/**
* 从模板版本 DTO 里取正文 HTML。
* 契约给了 contentHtml 与 content 两个别名(见 TemplateVersionResponseDto),
* 这里按 contentHtml → content 的顺序取,两个都没有才返回空。
*/
export function readVersionContentHtml(dto: TemplateVersionResponseDto | null): string {
if (!dto) {
return ''
}
return dto.contentHtml || dto.content || ''
}
/**
* 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。
*
* 后端版本 DTO 的 signatureFields 字段承载「这份模板有哪些填写项」,
* 正文本身仍以 contentHtml 提交。不提交这个字段的话,后端拿不到字段口径,
* 也就无法在签署侧做必填校验与证据固化。
*/
export function buildSignatureFieldsPayload(contentHtml: string): unknown[] {
return collectFieldDefs(contentHtml).map((field) => ({
id: field.id,
label: field.label,
kind: field.kind,
required: field.required,
options: field.options,
...(field.risk ? { risk: field.risk } : {}),
}))
}
/**
* 读取模板当前版本的正文。
* 模板列表接口只给 currentVersionId 与哈希,不给正文,
* 因此展示/编辑正文必须再取一次版本详情——这是文档库能否接入的关键一步。
*/
export async function getTemplateContent(templateId: string): Promise<string> {
if (useMockData) {
return ''
}
const template = await getDocumentDetail(templateId)
if (!template?.versionId) {
return ''
}
const version = await getTemplateVersionDetail(templateId, template.versionId)
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)
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,
@@ -219,19 +360,54 @@ export async function getDocumentDetail(id: string): Promise<DocumentRecord | nu
export async function createTemplate(payload: CreateTemplateRequest): Promise<DocumentRecord> {
if (useMockData) {
throw new Error('Mock 模式不执行模板写操作')
throw new Error('演示模式下请在文档库页面直接新建文书')
}
const response = await request.post<ApiResponse<TemplateResponseDto>>('/v1/templates', payload)
return mapDocument(unwrapApiResponse(response))
}
export async function updateTemplate(
id: string,
payload: Partial<CreateTemplateRequest>,
): 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('Mock 模式不执行模板写操作')
throw new Error('演示模式下请在文档库页面直接编辑文书')
}
const response = await request.post<ApiResponse<TemplateVersionResponseDto>>(
@@ -279,6 +455,12 @@ export async function getTemplateVersionDetail(
export type TemplateVersionAction =
'submit-review' | 'reject' | 'approve' | 'publish' | 'disable' | 'archive'
/**
* 版本工作流动作。
*
* 真实模式直接调用对应动作接口;文档库的「新增 / 编辑 / 归档」也走这里,
* 保证模板状态只能经后端工作流流转,前端不自行改写 status。
*/
export function updateTemplateVersionStatus(
templateId: string,
versionId: string,
@@ -286,7 +468,7 @@ export function updateTemplateVersionStatus(
reason?: string,
): Promise<TemplateVersionResponseDto> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行模板版本工作流'))
return Promise.reject(new Error('演示模式下请在文档库页面直接操作文书'))
}
const requestData = action === 'reject' && reason?.trim() ? { comment: reason.trim() } : undefined
@@ -298,3 +480,42 @@ export function updateTemplateVersionStatus(
)
.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,
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
// 版本创建后模板的 currentVersionId/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,
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
}
+104 -2
View File
@@ -1,5 +1,9 @@
import axios from 'axios'
import {
fetchTemplateContentByVersion,
readCachedTemplateContent,
} from '@/api/management/documents'
import { unwrapApiResponse } from '@/utils/api-response'
import { mockFlags } from '@/utils/mock-flags'
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
@@ -471,6 +475,8 @@ function mapVisit(dto: VisitResponseDto): PatientProfile['visits'][number] {
}
function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): SigningTemplate {
const cachedContent = readCachedTemplateContent(dto.contentSha256)
return {
id: dto.templateId,
versionId: dto.templateVersionId,
@@ -484,9 +490,71 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing
description: `已发布版本 ${dto.versionNo}`,
// 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。
supportedMethods: ['pad', 'sms'],
// 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回
contentSha256: dto.contentSha256,
contentHtml: cachedContent || undefined,
}
}
/**
* 给可用模板补正文快照。
*
* available-versions 只返回 contentSha256,不返回正文,因此真实模式下
* 「发起签署」弹窗选中的模板没有正文,签出来的文书详情会退化成前端写死的回退纸样。
* 这里在拿到模板清单后按版本 id 把正文取回来(内部按哈希缓存,同一版本只取一次),
* 取不到的保持空,由调用方按"后端未提供正文"如实提示,绝不伪造正文。
*
* 逐份补而不是全量并发:模板数量有限,且正文对签署是必需的,
* 用 allSettled 隔离单份失败,避免一份模板取不到正文就拖垮整个弹窗。
*/
async function hydrateTemplateContents(templates: SigningTemplate[]): Promise<SigningTemplate[]> {
const pending = templates.filter((template) => !template.contentHtml && template.versionId)
if (!pending.length) {
return templates
}
const contents = await Promise.allSettled(
pending.map((template) =>
fetchTemplateContentByVersion(template.versionId, template.contentSha256),
),
)
const contentByVersionId = new Map<string, string>()
contents.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
contentByVersionId.set(pending[index].versionId, result.value)
}
})
if (!contentByVersionId.size) {
return templates
}
return templates.map((template) => {
if (template.contentHtml) {
return template
}
const contentHtml = contentByVersionId.get(template.versionId)
return contentHtml ? { ...template, contentHtml } : template
})
}
/** 单份模板补正文:签署弹窗从外部(文档库)带进来的模板走这里 */
export async function hydrateSigningTemplate(template: SigningTemplate): Promise<SigningTemplate> {
if (template.contentHtml || !template.versionId || useMockData) {
return template
}
const contentHtml = await fetchTemplateContentByVersion(
template.versionId,
template.contentSha256,
)
return contentHtml ? { ...template, contentHtml } : template
}
function findTemplate(templates: SigningTemplate[] | undefined, versionId: string) {
return templates?.find((template) => template.versionId === versionId)
}
@@ -524,9 +592,38 @@ function mapSigningTask(
backendStatus: dto.status,
signedAt: formatApiDateTime(dto.signedAt) || undefined,
voidReason: dto.voidReason ?? undefined,
// 任务接口不返回正文,只有 templateContentSha256;正文由 hydrateTaskContent 按需补回。
// 带上哈希是为了让"任务固化的到底是哪一版正文"可校验,不拿别的版本顶替。
contentSha256: dto.templateContentSha256,
documentContentHtml: readCachedTemplateContent(dto.templateContentSha256) || undefined,
}
}
/**
* 给签署任务补正文快照。
*
* sign-tasks 只返回 templateContentSha256,不返回正文;缺正文时任务详情会退化成
* SigningDocumentPreview(一份与模板无关的回退纸样)。这里按任务的模板版本取回正文,
* 内部按哈希缓存,同一版本的任务只穿透一次。
*
* 取不到时保持 undefined,由详情组件如实显示"后端未提供正文",
* 不用前端拼一份假文书充数——那会让医务人员看到与真实模板无关的内容。
*/
export async function hydrateSigningTaskContent(
task: SigningTaskRecord,
): Promise<SigningTaskRecord> {
if (task.documentContentHtml || useMockData || !task.templateVersionId) {
return task
}
const contentHtml = await fetchTemplateContentByVersion(
task.templateVersionId,
task.contentSha256,
)
return contentHtml ? { ...task, documentContentHtml: contentHtml } : task
}
function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate {
return {
id: input.documentId,
@@ -750,7 +847,10 @@ export async function getSigningTaskDetail(
const response = await request.get<ApiResponseOf<SignTaskResponseDto>>(
`/v1/sign-tasks/${encodeURIComponent(id)}`,
)
return mapSigningTask(unwrapApiResponse(response), options)
const task = mapSigningTask(unwrapApiResponse(response), options)
// 任务接口不返回正文,这里按哈希把正文快照补回;取不到就保持空,
// 由详情组件提示"后端未提供正文",不退化成前端写死的回退纸样
return hydrateSigningTaskContent(task)
} catch (error) {
if (isNotFoundError(error)) {
return null
@@ -867,7 +967,9 @@ export async function getSigningTemplates(
const data = unwrapApiResponse(response)
const records = Array.isArray(data) ? data : data.records
return records.map(mapAvailableTemplate)
// 可用模板接口只给哈希不给正文,这里按版本补回正文快照,
// 否则发起签署时文书没有正文,签出来的详情会退化成前端写死的回退纸样
return hydrateTemplateContents(records.map(mapAvailableTemplate))
}
// 文档库是模板的唯一来源,两侧读同一份数据:
+12
View File
@@ -62,6 +62,12 @@ export interface SigningTemplate {
supportedMethods: SigningMethod[]
/** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */
contentHtml?: string
/**
* 模板版本的正文哈希。
* 真实模式下可用模板接口只返回它、不返回正文(见 AvailableTemplateVersionResponseDto),
* 签署端要靠它判断本地缓存的正文快照是否还是同一份,避免版本更新后仍按旧正文签署。
*/
contentSha256?: string
}
export interface WorkbenchSummary {
@@ -215,6 +221,12 @@ export interface SigningTaskRecord extends WorkbenchTask {
voidReason?: string
/** 原型:任务创建时固化的文书 HTML(对应真实实现的模板版本快照 + contentSha256)。 */
documentContentHtml?: string
/**
* 任务固化的模板版本正文哈希。
* 真实模式的任务接口只返回它、不返回正文(见 SignTaskResponseDto 的 templateContentSha256),
* 前端按它把正文快照取回来并校验,确保展示的正文就是签署时固化的那一版。
*/
contentSha256?: string
/** 原型:患者勾选结果,随签署一起留存(对应真实实现里随签署证据提交的勾选摘要)。 */
selectedItems?: string[]
/**