feat(文书管理): 增加版本工作流与版本历史
- 模板状态按后端原始状态展示与筛选,待审核与已驳回分开展示 - 仅已发布模板可发起签署,新增提交审核/审核通过/驳回等动作 - 新增版本历史弹窗,复用统一的模板时间格式化方法 - 演示数据补齐已签署任务的签名图,并提供获取签名 dataURL 的接口 - 修复编辑器插入占位符时未定位光标导致内容落到文首的问题
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user