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),
})
}