feat(文书管理): 增加版本工作流与版本历史

- 模板状态按后端原始状态展示与筛选,待审核与已驳回分开展示
- 仅已发布模板可发起签署,新增提交审核/审核通过/驳回等动作
- 新增版本历史弹窗,复用统一的模板时间格式化方法
- 演示数据补齐已签署任务的签名图,并提供获取签名 dataURL 的接口
- 修复编辑器插入占位符时未定位光标导致内容落到文首的问题
This commit is contained in:
yelan
2026-09-17 16:44:54 +08:00
parent 81922d774f
commit 97c5182ba4
13 changed files with 982 additions and 64 deletions
@@ -8,8 +8,11 @@ import {
deleteTemplate,
getDocuments,
getTemplateContent,
getTemplateVersions,
updateTemplate,
updateTemplateContent,
updateTemplateVersionStatus,
type TemplateVersionAction,
} from '@/api/management/documents'
import { getCampuses, getDepartments } from '@/api/management/organization'
import type {
@@ -17,6 +20,7 @@ import type {
CreateTemplateRequest,
DocumentRecord,
DepartmentRecord,
TemplateVersionResponseDto,
} from '@/api/management/types'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
@@ -29,10 +33,18 @@ import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
import DocumentVersionDialog from './components/DocumentVersionDialog.vue'
import WordEditorDialog from './components/WordEditorDialog.vue'
import { documentStatusOptions, loadDocumentLibrary, saveDocumentLibrary } from './mock'
import {
backendStatusLabels,
backendStatusOrder,
loadDocumentLibrary,
readBackendStatus,
saveDocumentLibrary,
} from './mock'
import type {
DepartmentTab,
DocumentBackendStatus,
DocumentDepartment,
DocumentFilterForm,
DocumentTemplate,
@@ -70,6 +82,13 @@ const wordEditorLoading = ref(false)
const previewLoading = ref(false)
const previewError = ref(false)
/** 版本历史弹窗 */
const versionDialogVisible = ref(false)
const versionDialogTemplate = ref<DocumentTemplate | null>(null)
const versionRecords = ref<TemplateVersionResponseDto[]>([])
const versionLoading = ref(false)
const versionError = ref(false)
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
@@ -102,20 +121,42 @@ const departmentTabs = computed<DepartmentTab[]>(() => [
})),
])
const statusTabs = computed<StatusTab[]>(() => [
{ key: 'all', label: '全部', count: allTemplates.value.length },
...documentStatusOptions.map((option) => ({
...option,
count: allTemplates.value.filter((template) => template.status === option.key).length,
})),
])
/**
* 状态页签按后端原始状态归类,而不是归一后的 draft / review / published / archived。
*
* 归一会把 PENDING_REVIEW(待审核)和 REJECTED(已驳回)并成 review,
* 页签上只能显示一个含糊的「需复审」:一个是等着审、一个是被打回,
* 科室该做的事完全不同,混在一个页签里就分不出来了。
*
* 只列出库里实际存在的状态:7 个状态全摆出来,多数时候是 0 条的噪声。
*/
const statusTabs = computed<StatusTab[]>(() => {
const counts = new Map<DocumentBackendStatus, number>()
allTemplates.value.forEach((template) => {
const status = readBackendStatus(template)
counts.set(status, (counts.get(status) ?? 0) + 1)
})
return [
{ key: 'all', label: '全部', count: allTemplates.value.length },
...backendStatusOrder
.filter((status) => counts.has(status))
.map((status) => ({
key: status,
label: backendStatusLabels[status],
count: counts.get(status) ?? 0,
})),
]
})
const filteredTemplates = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
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' || readBackendStatus(template) === filters.status
const matchesKeyword =
!keyword ||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
@@ -461,28 +502,181 @@ async function saveTemplate(form: TemplateEditorForm) {
}
try {
let savedId = form.id
if (form.id) {
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上;
// 新版本默认是草稿,这里顺带发布,保证编辑完就能直接发起签署
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上。
// 新版本的状态由后端决定,前端不在这里「顺带发布」——发布是需要留痕的正式动作,
// 要由人显式触发(见 handleWorkflow),否则「谁把这份文书放行到可签署」无从追溯。
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('文书已创建')
const created = await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
savedId = created.id
}
closeWordEditor()
await loadLibrary()
// 提示按后端返回的真实状态给:新版本/新模板若是草稿,还需要走工作流才能签署。
// 之前这里无条件说「已发布新版本」,但代码里并没有发布动作,属于假提示。
const saved = savedId ? allTemplates.value.find((item) => item.id === savedId) : undefined
const canSignNow = !saved || saved.status === 'published'
if (canSignNow) {
ElMessage.success(form.id ? '文书已更新并发布新版本' : '文书已创建')
} else {
ElMessage.success(
form.id
? '文书已保存为新版本,提交审核并通过后才能发起签署'
: '文书已创建,提交审核并通过后才能发起签署',
)
}
} catch {
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
}
}
/** 版本工作流动作的中文名,用于确认框与结果提示 */
const workflowActionLabels: Record<TemplateVersionAction, string> = {
'submit-review': '提交审核',
reject: '驳回',
approve: '审核通过',
publish: '发布',
disable: '停用',
archive: '归档',
}
/**
* 收集驳回原因。
*
* 驳回是要留痕的正式动作:只把状态改成「已驳回」而不写为什么,科室拿到驳回
* 也不知道该改哪里,只能重开一版碰运气。因此原因必填,并随动作提交给后端存档。
*/
async function promptRejectReason(template: DocumentTemplate): Promise<string | null> {
return ElMessageBox.prompt(
`请说明驳回「${template.name}」的原因,科室会按这条意见修改后重新提交。`,
'驳回文书',
{
type: 'warning',
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputPlaceholder: '如:缺少麻醉方式选择项,风险告知段落需补充',
inputValidator: (value: string) => (value?.trim() ? true : '驳回原因不能为空'),
},
)
.then((result) => result.value.trim())
.catch(() => null)
}
/**
* 推进版本工作流。
*
* 模板状态只能由后端工作流改写,前端不自行设置 status——这是留痕要求:
* 「谁在什么时候把这份文书放行到可签署」必须由后端记录,不能由前端拼一个状态提交。
* 因此「提交审核 / 审核通过 / 驳回 / 重新启用」这类动作统一走这里。
*/
async function handleWorkflow(template: DocumentTemplate, action: TemplateVersionAction) {
if (useMockLibrary) {
ElMessage.info('演示模式没有审核流程,文书可直接发起签署')
return
}
if (!template.versionId) {
ElMessage.error('该文书没有可操作的版本,请刷新后重试')
return
}
const label = workflowActionLabels[action]
let reason: string | undefined
if (action === 'reject') {
const rejectReason = await promptRejectReason(template)
if (!rejectReason) {
return
}
reason = rejectReason
} else {
const confirmed = await ElMessageBox.confirm(
`确定对文书「${template.name}」执行「${label}」?`,
`确认${label}`,
{ type: 'warning', confirmButtonText: label, cancelButtonText: '取消' },
)
.then(() => true)
.catch(() => false)
if (!confirmed) {
return
}
}
try {
await updateTemplateVersionStatus(template.id, template.versionId, action, reason)
ElMessage.success(`已${label}`)
await loadLibrary()
} catch {
ElMessage.error(`${label}失败,请稍后重试`)
}
}
/**
* 查看版本历史。
*
* 模板列表只显示当前版本号(V2.1 这类),「这份文书改过几版、每版谁在什么时候
* 提交/通过/驳回」此前没有任何入口——被驳回后科室只能看到状态变了,看不到为什么。
* 这里补上版本列表,驳回意见也一并展示。
*
* 演示文库只保存当前版本,因此演示模式合成一条并标注,不假装有一份完整历史。
*/
async function openVersions(template: DocumentTemplate) {
versionDialogTemplate.value = template
versionDialogVisible.value = true
versionRecords.value = []
versionError.value = false
if (useMockLibrary) {
versionLoading.value = false
versionRecords.value = template.versionId
? [
{
id: template.versionId,
templateId: template.id,
versionNo: template.version,
contentHtml: template.contentHtml,
contentSha256: template.contentSha256,
status: template.backendStatus,
createdAt: template.updatedAt,
createdBy: template.updatedBy,
},
]
: []
return
}
versionLoading.value = true
try {
versionRecords.value = await getTemplateVersions(template.id)
} catch {
versionError.value = true
} finally {
versionLoading.value = false
}
}
function closeVersions() {
versionDialogVisible.value = false
versionDialogTemplate.value = null
versionRecords.value = []
versionError.value = false
}
/**
* 删除文书。
* 模板没有物理删除接口:它可能已被历史签署任务引用,删掉会让留痕链断裂。
@@ -620,6 +814,8 @@ async function resetMockData() {
function clearFilters() {
filters.keyword = ''
filters.department = null
// status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」
filters.status = 'all'
}
function useTemplate(template: DocumentTemplate) {
@@ -684,10 +880,13 @@ onMounted(() => {
v-for="template in filteredTemplates"
:key="template.id"
:template="template"
:is-mock="useMockLibrary"
@preview="handlePreview"
@edit="showEditMessage"
@versions="openVersions"
@initiate="openSigningDialog"
@remove="removeTemplate"
@workflow="handleWorkflow"
/>
</div>
@@ -699,7 +898,7 @@ onMounted(() => {
<template v-else>
<p>没有符合条件的文书</p>
<button
v-if="filters.keyword || filters.department"
v-if="filters.keyword || filters.department || filters.status !== 'all'"
type="button"
class="reset-button"
@click="clearFilters"
@@ -731,6 +930,15 @@ onMounted(() => {
@close="wordEditorVisible = false"
@save="saveTemplate"
/>
<DocumentVersionDialog
:visible="versionDialogVisible"
:template="versionDialogTemplate"
:versions="versionRecords"
:loading="versionLoading"
:error="versionError"
:is-mock="useMockLibrary"
@close="closeVersions"
/>
<!-- 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 -->
<div v-if="wordEditorLoading" class="editor-loading" role="status">