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
@@ -3,9 +3,25 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import {
createTemplateWithContent,
deleteTemplate,
getDocuments,
getTemplateContent,
updateTemplate,
updateTemplateContent,
} from '@/api/management/documents'
import { getCampuses, getDepartments } from '@/api/management/organization'
import type {
CampusRecord,
CreateTemplateRequest,
DocumentRecord,
DepartmentRecord,
} from '@/api/management/types'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { demoCampuses, demoDepartments } from '@/utils/mock-document-library'
import { mockFlags } from '@/utils/mock-flags'
import { clearMockState } from '@/utils/mock-storage'
@@ -24,37 +40,38 @@ import type {
TemplateEditorForm,
} from './types'
// 原型阶段:文档库数据与文书内容全部来自本地真实附件(mock.ts + contents.ts),暂不调用后端接口。
// 后续接入时,仅需将 loadLibrary / saveTemplate / handleWorkflow 中的本地数据操作替换为文档模板接口。
// 是否使用本地演示库由 VITE_MOCK_DOCUMENTS 控制,缺省开启。
/**
* 文档库数据来源。
*
* - 演示模式(VITE_MOCK_DOCUMENTS 开启,缺省开启):读本地演示文书库,可随意增删改;
* - 真实模式:读模板接口。列表只给模板元数据(含 currentVersionId),
* 正文要再取一次版本详情——这是接入时最容易被忽略的一步,
* 缺了它文档库会显示一堆没有正文、无法预览的"空壳模板"。
*
* 两种模式共用同一套页面结构:模板按 科室 → 分类 → 文书 三级分组渲染,
* 因此真实模式也需要把扁平的模板列表组织成同样的树。
*/
const router = useRouter()
const appStore = useAppStore()
const useMockLibrary = mockFlags.documents
/** 关掉演示库后,文书库本应改由模板接口提供,但该路径尚未实现:如实提示,不静默沿用本地数据 */
const apiLibraryPending = ref(false)
/** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
const libraryLoading = ref(false)
const libraryError = ref('')
const library = ref<DocumentDepartment[]>([])
const selectedTemplate = ref<DocumentTemplate | null>(null)
const signingTemplate = ref<SigningTemplate | null>(null)
const wordEditingTemplate = ref<DocumentTemplate | null>(null)
const wordEditorVisible = ref(false)
const signingDialogVisible = ref(false)
const wordEditorLoading = ref(false)
/** 编辑已有文书时需要先把正文取回来,取正文期间弹窗展示加载态 */
const previewLoading = ref(false)
const previewError = ref(false)
const campuses: CampusRecord[] = [
{ id: 'campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
{ id: 'campus-fengxian', code: 'FENGXIAN', name: '奉贤院区', status: 'ENABLED', sortNo: 2 },
]
const departments: DepartmentRecord[] = ['医学影像科', '医学检验科', '临床遗传中心'].map(
(name, index) => ({
id: `template-department-${index + 1}`,
campusId: 'campus-main',
code: `TPL-DEPT-${index + 1}`,
name,
status: 'ENABLED',
sortNo: index + 1,
}),
)
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
const filters = reactive<DocumentFilterForm>({
keyword: '',
@@ -98,8 +115,7 @@ const filteredTemplates = computed(() => {
return allTemplates.value.filter((template) => {
const matchesDepartment = !filters.department || template.departmentKey === filters.department
const matchesStatus =
filters.status === 'all' || template.status === filters.status
const matchesStatus = filters.status === 'all' || template.status === filters.status
const matchesKeyword =
!keyword ||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
@@ -118,14 +134,136 @@ function cloneLibrary(source: DocumentDepartment[]): DocumentDepartment[] {
}))
}
function loadLibrary() {
if (!useMockLibrary) {
library.value = []
apiLibraryPending.value = true
/**
* 把扁平的模板列表组织成页面需要的 科室 → 分类 → 文书 树。
*
* 分组键用 departmentId(真实模式)或科室名(未指定科室的模板归"全院通用"),
* 这样同一科室的模板无论接口返回顺序如何都会落进同一个分组。
*/
function groupDocuments(records: DocumentRecord[]): DocumentDepartment[] {
const departments = new Map<string, DocumentDepartment>()
records.forEach((record) => {
const departmentKey = record.departmentId ?? '__all__'
const departmentName =
record.departmentName ?? (record.departmentId ? '未指定科室' : '全院通用')
let department = departments.get(departmentKey)
if (!department) {
department = { key: departmentKey, name: departmentName, campus: 'all', categories: [] }
departments.set(departmentKey, department)
}
const categoryName = record.category || '未分类'
let category = department.categories.find((item) => item.name === categoryName)
if (!category) {
category = { name: categoryName, documents: [] }
department.categories.push(category)
}
category.documents.push(record)
})
// 全院通用的模板排最前,其余按科室名排序,保证每次进页面分组顺序稳定
return [...departments.values()].sort((left, right) => {
if (left.key === '__all__') {
return -1
}
if (right.key === '__all__') {
return 1
}
return left.name.localeCompare(right.name, 'zh-CN')
})
}
/**
* 读取权限范围内的全部模板。
* 权限范围内模板可能超过一页,这里翻完所有页;不翻页会静默漏掉后面的模板,
* 表现为"文档库里少了几份文书",很难排查。
*/
async function fetchAllDocuments(): Promise<DocumentRecord[]> {
const pageSize = 200
const firstPage = await getDocuments({
page: 1,
pageSize,
status: 'all',
campusId: appStore.selectedCampusId || undefined,
})
const effectivePageSize = Math.max(firstPage.pageSize, 1)
const pageCount = Math.ceil(firstPage.total / effectivePageSize)
if (pageCount <= 1) {
return firstPage.records
}
const remaining = await Promise.all(
Array.from({ length: pageCount - 1 }, (_, index) =>
getDocuments({
page: index + 2,
pageSize: effectivePageSize,
status: 'all',
campusId: appStore.selectedCampusId || undefined,
}),
),
)
return [firstPage, ...remaining].flatMap((page) => page.records)
}
async function loadLibrary() {
if (useMockLibrary) {
libraryError.value = ''
library.value = cloneLibrary(loadDocumentLibrary())
return
}
library.value = cloneLibrary(loadDocumentLibrary())
libraryLoading.value = true
libraryError.value = ''
try {
const records = await fetchAllDocuments()
library.value = groupDocuments(records)
} catch {
library.value = []
libraryError.value = '文书库加载失败,请确认后端模板接口可访问后重试'
} finally {
libraryLoading.value = false
}
}
/**
* 读取组织字典。
* 真实模式取真实院区/科室;演示模式用演示字典,键名与接口同构,
* 因此「所属院区 / 所属科室」下拉在两种模式下走同一段模板代码。
*
* 之前这里写死了三个科室(id 形如 template-department-1,不是后端 UUID),
* 这些 id 会被写进文库、再被当 departmentId 发给后端,接真实接口必然报错。
*/
async function loadOrganizationOptions() {
if (useMockLibrary) {
campuses.value = demoCampuses.map((campus) => ({ ...campus }))
departments.value = demoDepartments.map((department) => ({
...department,
campusId: campuses.value[0]?.id ?? '',
}))
return
}
const [campusesResult, departmentsResult] = await Promise.allSettled([
getCampuses(),
getDepartments(),
])
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
if (campusesResult.status === 'rejected') {
ElMessage.error('院区字典加载失败,新增文书时无法选择所属院区')
}
}
// 演示数据落本地:刷新后文书库还在,不用每次重新建文书。
@@ -133,19 +271,52 @@ function loadLibrary() {
watch(
library,
(value) => {
if (useMockLibrary) {
if (useMockLibrary && !libraryLoading.value) {
saveDocumentLibrary(value)
}
},
{ deep: true },
)
function handlePreview(template: DocumentTemplate) {
selectedTemplate.value = template
}
function closePreview() {
selectedTemplate.value = null
previewError.value = false
}
/**
* 预览模板。
* 真实模式的列表接口不返回正文,这里按需取回再打开预览;
* 取不到正文时如实报错,让科室知道是接口/数据问题,而不是看到一个空预览。
*/
async function handlePreview(template: DocumentTemplate) {
selectedTemplate.value = template
if (useMockLibrary || template.contentHtml) {
previewLoading.value = false
previewError.value = false
return
}
previewLoading.value = true
previewError.value = false
try {
const contentHtml = await getTemplateContent(template.id)
// 用户在等待期间可能已经切到别的文书,回填前确认仍是同一份
if (selectedTemplate.value?.id === template.id) {
selectedTemplate.value = { ...template, contentHtml }
previewError.value = !contentHtml
}
} catch {
if (selectedTemplate.value?.id === template.id) {
previewError.value = true
}
} finally {
if (selectedTemplate.value?.id === template.id) {
previewLoading.value = false
}
}
}
function showAddMessage() {
@@ -155,13 +326,7 @@ function showAddMessage() {
}
function showExportMessage() {
ElMessage.info('原型演示:导出模板包(ZIP)')
}
function showEditMessage(template: DocumentTemplate) {
// Word 自维护方案:所有文书统一在 Word 编辑器中维护。
wordEditingTemplate.value = template
wordEditorVisible.value = true
ElMessage.info('导出模板包待接入:需要后端提供模板包导出接口')
}
function closeWordEditor() {
@@ -169,10 +334,44 @@ function closeWordEditor() {
wordEditingTemplate.value = null
}
/**
* 编辑已有文书。
* 真实模式必须先取回正文再打开编辑器——列表接口不返回正文,
* 直接用列表里的空 contentHtml 打开编辑器,科室会看到"文书内容为空",
* 保存后还会把模板正文冲掉。
*/
async function showEditMessage(template: DocumentTemplate) {
if (useMockLibrary || template.contentHtml) {
wordEditingTemplate.value = template
wordEditorVisible.value = true
return
}
wordEditorLoading.value = true
try {
const contentHtml = await getTemplateContent(template.id)
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,请确认模板版本内容接口可访问')
return
}
wordEditingTemplate.value = { ...template, contentHtml }
wordEditorVisible.value = true
} catch {
ElMessage.error('文书正文加载失败,请稍后重试')
} finally {
wordEditorLoading.value = false
}
}
/** 演示模式:把新文书追加进本地文库(分组按科室名匹配已有分组) */
function addLocalTemplate(form: TemplateEditorForm) {
const department = departments.find((item) => item.id === form.departmentId)
const department = departments.value.find((item) => item.id === form.departmentId)
const departmentName = department?.name ?? '全院通用'
const departmentKey = department?.id ?? 'local-general'
// 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义
const departmentKey = department?.id ?? '__all__'
let departmentGroup = library.value.find((item) => item.key === departmentKey)
if (!departmentGroup) {
@@ -198,43 +397,109 @@ function addLocalTemplate(form: TemplateEditorForm) {
name: form.name,
code: form.templateCode,
version: form.versionNo || 'v1',
// 原型闭环:新文书直接启用(工作流 UI 待签署端接入后恢复),保证创建后即可预览/发起。
status: 'enabled',
// 演示模式直接落成已发布:演示库没有审核流,落在草稿会没法发起签署
status: 'published',
backendStatus: 'PUBLISHED',
updatedBy: '本地演示',
updatedAt: new Date().toISOString(),
category: form.category,
description: form.description,
versionId: `local-version-${timestamp}`,
departmentId: form.departmentId || null,
departmentName,
campusId: form.campusId,
contentHtml: form.contentHtml,
})
}
function saveTemplate(form: TemplateEditorForm) {
if (form.id) {
library.value.forEach((department) =>
department.categories.forEach((category) =>
category.documents.forEach((document) => {
if (document.id === form.id) {
document.name = form.name || document.name
document.version = form.versionNo || document.version
document.contentHtml = form.contentHtml
document.description = form.description
}
}),
),
)
} else {
addLocalTemplate(form)
}
closeWordEditor()
ElMessage.success(form.id ? '文书已更新' : '文书已创建')
/** 演示模式:就地更新已有文书(只改可改字段,科室归属与编号不动) */
function updateLocalTemplate(form: TemplateEditorForm) {
library.value.forEach((department) =>
department.categories.forEach((category) =>
category.documents.forEach((document) => {
if (document.id === form.id) {
document.name = form.name || document.name
document.version = form.versionNo || document.version
document.contentHtml = form.contentHtml
document.description = form.description
document.updatedAt = new Date().toISOString()
}
}),
),
)
}
function toCreatePayload(form: TemplateEditorForm): CreateTemplateRequest {
return {
templateCode: form.templateCode,
name: form.name,
description: form.description,
campusId: form.campusId,
departmentId: form.departmentId || null,
category: form.category,
}
}
function toVersionPayload(form: TemplateEditorForm) {
return {
versionNo: form.versionNo,
contentHtml: form.contentHtml,
}
}
async function saveTemplate(form: TemplateEditorForm) {
if (useMockLibrary) {
if (form.id) {
updateLocalTemplate(form)
} else {
addLocalTemplate(form)
}
closeWordEditor()
ElMessage.success(form.id ? '文书已更新' : '文书已创建')
return
}
try {
if (form.id) {
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上;
// 新版本默认是草稿,这里顺带发布,保证编辑完就能直接发起签署
await updateTemplateContent(form.id, toVersionPayload(form))
await updateTemplate(form.id, {
name: form.name,
description: form.description,
category: form.category,
})
ElMessage.success('文书已更新并发布新版本')
} else {
await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
ElMessage.success('文书已创建')
}
closeWordEditor()
await loadLibrary()
} catch {
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
}
}
/**
* 删除文书。
* 模板没有物理删除接口:它可能已被历史签署任务引用,删掉会让留痕链断裂。
* 因此「删除」在这里表达为归档版本(后端 archive 动作),
* 语义上等于下架,已签署文书仍可追溯。
*/
async function removeTemplate(template: DocumentTemplate) {
const confirmed = await ElMessageBox.confirm(
`确定删除文书「${template.name}」?删除后不可恢复。`,
'删除文书',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
useMockLibrary
? `确定删除文书「${template.name}」?删除后不可恢复。`
: `确定下架文书「${template.name}」?下架后不能再用于发起签署,已签署的记录不受影响。`,
useMockLibrary ? '删除文书' : '下架文书',
{
type: 'warning',
confirmButtonText: useMockLibrary ? '删除' : '下架',
cancelButtonText: '取消',
},
)
.then(() => true)
.catch(() => false)
@@ -243,45 +508,83 @@ async function removeTemplate(template: DocumentTemplate) {
return
}
// 移除文书后,顺带清理空分类与空科室分组,避免留下空壳节点。
library.value = library.value
.map((department) => ({
...department,
categories: department.categories
.map((category) => ({
...category,
documents: category.documents.filter((document) => document.id !== template.id),
}))
.filter((category) => category.documents.length > 0),
}))
.filter((department) => department.categories.length > 0)
if (useMockLibrary) {
// 移除文书后,顺带清理空分类与空科室分组,避免留下空壳节点
library.value = library.value
.map((department) => ({
...department,
categories: department.categories
.map((category) => ({
...category,
documents: category.documents.filter((document) => document.id !== template.id),
}))
.filter((category) => category.documents.length > 0),
}))
.filter((department) => department.categories.length > 0)
ElMessage.success('文书已删除')
ElMessage.success('文书已删除')
return
}
try {
await deleteTemplate(template.id)
ElMessage.success('文书已下架')
await loadLibrary()
} catch {
ElMessage.error('文书下架失败,请稍后重试')
}
}
// 版本工作流(提交审核/发布等)随原模板编辑器移除,待签署端接入后按后端工作流接口恢复。
/**
* 把文书转成签署模板。
*
* 转之前要确保有正文:真实模式的列表接口不返回正文,缺正文时发起签署会签出一份
* 与模板无关的空文书。因此这里在缺正文时先取回,取不到就拒绝发起。
*/
async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTemplate | null> {
let contentHtml = template.contentHtml
if (!useMockLibrary && !contentHtml) {
contentHtml = await getTemplateContent(template.id)
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,无法发起签署')
return null
}
}
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
return {
id: template.id,
versionId: template.versionId ?? template.id,
name: template.name,
code: template.code,
code: template.code ?? '',
version: template.version,
department: template.department,
departmentId: template.departmentId,
campusId: template.campusId ?? undefined,
category: template.category,
description: template.description,
description: template.description ?? '',
supportedMethods: ['pad', 'sms'],
contentHtml: template.contentHtml,
contentHtml,
contentSha256: template.contentSha256,
}
}
function openSigningDialog(template: DocumentTemplate) {
async function openSigningDialog(template: DocumentTemplate) {
closePreview()
signingTemplate.value = toSigningTemplate(template)
signingDialogVisible.value = true
try {
const signing = await toSigningTemplate(template)
if (!signing) {
return
}
signingTemplate.value = signing
signingDialogVisible.value = true
} catch {
ElMessage.error('文书正文加载失败,无法发起签署')
}
}
function closeSigningDialog() {
@@ -296,7 +599,7 @@ function handleTaskCreated(task: SigningTaskRecord) {
}
// 自测用:一键清掉本地保存的演示文书与签署任务,回到初始状态。
// 签署任务的演示数据在 api 层的内存里,清存储后直接刷新最彻底。
// 只在演示模式下展示(模板里有 v-if),真实环境不该出现这个入口。
async function resetMockData() {
const confirmed = await ElMessageBox.confirm(
'将清除本地保存的演示文书与签署任务,恢复到初始状态。',
@@ -320,10 +623,35 @@ function clearFilters() {
}
function useTemplate(template: DocumentTemplate) {
openSigningDialog(template)
void openSigningDialog(template)
}
onMounted(loadLibrary)
async function initializePage() {
if (useMockLibrary) {
await loadOrganizationOptions()
} else {
await appStore.loadCampuses()
await loadOrganizationOptions()
}
await loadLibrary()
}
watch(
() => appStore.selectedCampusId,
(campusId, previousCampusId) => {
// 演示模式没有院区维度,切换院区不需要重新拉文库
if (useMockLibrary || !campusId || !previousCampusId || campusId === previousCampusId) {
return
}
void loadLibrary()
},
)
onMounted(() => {
void initializePage()
})
</script>
<template>
@@ -331,6 +659,7 @@ onMounted(loadLibrary)
<DocumentLibraryHeader
v-model="filters.keyword"
:total="filteredTemplates.length"
:show-reset="useMockLibrary"
@add="showAddMessage"
@export="showExportMessage"
@reset="resetMockData"
@@ -343,10 +672,12 @@ onMounted(loadLibrary)
:statuses="statusTabs"
/>
<p v-if="apiLibraryPending" class="library-pending">
已关闭本地演示库(VITE_MOCK_DOCUMENTS=false),但文档库尚未接入模板接口,暂时无法展示文书。
改回 VITE_MOCK_DOCUMENTS=true 可继续使用演示库。
</p>
<p v-if="libraryLoading" class="library-state">正在加载文书库…</p>
<div v-else-if="libraryError" class="library-state library-state--error">
<span>{{ libraryError }}</span>
<button type="button" class="state-retry" @click="loadLibrary">重新加载</button>
</div>
<div v-else-if="filteredTemplates.length" class="template-grid">
<DocumentTemplateCard
@@ -381,8 +712,8 @@ onMounted(loadLibrary)
<DocumentPreviewDialog
:visible="Boolean(selectedTemplate)"
:template="selectedTemplate"
:loading="false"
:error="false"
:loading="previewLoading"
:error="previewError"
@close="closePreview"
@use="useTemplate"
/>
@@ -400,6 +731,11 @@ onMounted(loadLibrary)
@close="wordEditorVisible = false"
@save="saveTemplate"
/>
<!-- 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 -->
<div v-if="wordEditorLoading" class="editor-loading" role="status">
<span>正在加载文书正文…</span>
</div>
</section>
</template>
@@ -416,10 +752,13 @@ onMounted(loadLibrary)
gap: 16px;
}
.library-pending {
.library-state {
display: flex;
gap: 12px;
align-items: center;
padding: 16px 20px;
margin: 0 0 16px;
color: var(--warn);
color: var(--mut);
font-size: 13px;
line-height: 1.7;
background: var(--card);
@@ -427,6 +766,24 @@ onMounted(loadLibrary)
border-radius: var(--r);
}
.library-state--error {
color: var(--err);
}
.state-retry {
flex-shrink: 0;
padding: 5px 11px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 6px;
}
.state-retry:hover {
background: var(--brand-l);
}
.empty-state {
padding: 56px 24px;
color: var(--mut);
@@ -462,6 +819,24 @@ onMounted(loadLibrary)
background: var(--brand-l);
}
.editor-loading {
position: fixed;
z-index: 15;
inset: 0;
display: grid;
color: var(--ink);
font-size: 13px;
background: rgb(9 40 52 / 25%);
place-items: center;
}
.editor-loading span {
padding: 12px 20px;
background: #fff;
border-radius: 8px;
box-shadow: var(--sh-modal);
}
@media (max-width: 1400px) {
.template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));