- 模板状态按后端原始状态展示与筛选,待审核与已驳回分开展示 - 仅已发布模板可发起签署,新增提交审核/审核通过/驳回等动作 - 新增版本历史弹窗,复用统一的模板时间格式化方法 - 演示数据补齐已签署任务的签名图,并提供获取签名 dataURL 的接口 - 修复编辑器插入占位符时未定位光标导致内容落到文首的问题
1066 lines
31 KiB
Vue
1066 lines
31 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
import {
|
||
createTemplateWithContent,
|
||
deleteTemplate,
|
||
getDocuments,
|
||
getTemplateContent,
|
||
getTemplateVersions,
|
||
updateTemplate,
|
||
updateTemplateContent,
|
||
updateTemplateVersionStatus,
|
||
type TemplateVersionAction,
|
||
} from '@/api/management/documents'
|
||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||
import type {
|
||
CampusRecord,
|
||
CreateTemplateRequest,
|
||
DocumentRecord,
|
||
DepartmentRecord,
|
||
TemplateVersionResponseDto,
|
||
} from '@/api/management/types'
|
||
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/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'
|
||
|
||
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 {
|
||
backendStatusLabels,
|
||
backendStatusOrder,
|
||
loadDocumentLibrary,
|
||
readBackendStatus,
|
||
saveDocumentLibrary,
|
||
} from './mock'
|
||
import type {
|
||
DepartmentTab,
|
||
DocumentBackendStatus,
|
||
DocumentDepartment,
|
||
DocumentFilterForm,
|
||
DocumentTemplate,
|
||
StatusTab,
|
||
TemplateEditorForm,
|
||
} from './types'
|
||
|
||
/**
|
||
* 文档库数据来源。
|
||
*
|
||
* - 演示模式(VITE_MOCK_DOCUMENTS 开启,缺省开启):读本地演示文书库,可随意增删改;
|
||
* - 真实模式:读模板接口。列表只给模板元数据(含 currentVersionId),
|
||
* 正文要再取一次版本详情——这是接入时最容易被忽略的一步,
|
||
* 缺了它文档库会显示一堆没有正文、无法预览的"空壳模板"。
|
||
*
|
||
* 两种模式共用同一套页面结构:模板按 科室 → 分类 → 文书 三级分组渲染,
|
||
* 因此真实模式也需要把扁平的模板列表组织成同样的树。
|
||
*/
|
||
const router = useRouter()
|
||
const appStore = useAppStore()
|
||
|
||
const useMockLibrary = mockFlags.documents
|
||
/** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
|
||
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 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[]>([])
|
||
|
||
const filters = reactive<DocumentFilterForm>({
|
||
keyword: '',
|
||
department: null,
|
||
status: 'all',
|
||
})
|
||
|
||
const allTemplates = computed<DocumentTemplate[]>(() =>
|
||
library.value.flatMap((department) =>
|
||
department.categories.flatMap((category) =>
|
||
category.documents.map((template) => ({
|
||
...template,
|
||
departmentKey: department.key,
|
||
department: department.name,
|
||
category: category.name,
|
||
})),
|
||
),
|
||
),
|
||
)
|
||
|
||
const departmentTabs = computed<DepartmentTab[]>(() => [
|
||
{ key: null, label: '全部', count: allTemplates.value.length },
|
||
...library.value.map((department) => ({
|
||
key: department.key,
|
||
label: department.name,
|
||
count: allTemplates.value.filter((template) => template.departmentKey === department.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' || readBackendStatus(template) === filters.status
|
||
const matchesKeyword =
|
||
!keyword ||
|
||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
||
|
||
return matchesDepartment && matchesStatus && matchesKeyword
|
||
})
|
||
})
|
||
|
||
function cloneLibrary(source: DocumentDepartment[]): DocumentDepartment[] {
|
||
return source.map((department) => ({
|
||
...department,
|
||
categories: department.categories.map((category) => ({
|
||
...category,
|
||
documents: category.documents.map((document) => ({ ...document })),
|
||
})),
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* 把扁平的模板列表组织成页面需要的 科室 → 分类 → 文书 树。
|
||
*
|
||
* 分组键用 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
|
||
}
|
||
|
||
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('院区字典加载失败,新增文书时无法选择所属院区')
|
||
}
|
||
}
|
||
|
||
// 演示数据落本地:刷新后文书库还在,不用每次重新建文书。
|
||
// 关闭演示库时不写本地,否则会把之前保存的演示文书覆盖成空库。
|
||
watch(
|
||
library,
|
||
(value) => {
|
||
if (useMockLibrary && !libraryLoading.value) {
|
||
saveDocumentLibrary(value)
|
||
}
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
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() {
|
||
// Word 自维护方案:新增文书直接进入 Word 编辑器(粘贴/导入 .docx)。
|
||
wordEditingTemplate.value = null
|
||
wordEditorVisible.value = true
|
||
}
|
||
|
||
function showExportMessage() {
|
||
ElMessage.info('导出模板包待接入:需要后端提供模板包导出接口')
|
||
}
|
||
|
||
function closeWordEditor() {
|
||
wordEditorVisible.value = false
|
||
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.value.find((item) => item.id === form.departmentId)
|
||
const departmentName = department?.name ?? '全院通用'
|
||
// 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义
|
||
const departmentKey = department?.id ?? '__all__'
|
||
let departmentGroup = library.value.find((item) => item.key === departmentKey)
|
||
|
||
if (!departmentGroup) {
|
||
departmentGroup = {
|
||
key: departmentKey,
|
||
name: departmentName,
|
||
campus: 'all',
|
||
categories: [],
|
||
}
|
||
library.value.push(departmentGroup)
|
||
}
|
||
|
||
let category = departmentGroup.categories.find((item) => item.name === form.category)
|
||
|
||
if (!category) {
|
||
category = { name: form.category, documents: [] }
|
||
departmentGroup.categories.push(category)
|
||
}
|
||
|
||
const timestamp = Date.now()
|
||
category.documents.push({
|
||
id: `local-template-${timestamp}`,
|
||
name: form.name,
|
||
code: form.templateCode,
|
||
version: form.versionNo || 'v1',
|
||
// 演示模式直接落成已发布:演示库没有审核流,落在草稿会没法发起签署
|
||
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 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 {
|
||
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,
|
||
})
|
||
} else {
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 删除文书。
|
||
* 模板没有物理删除接口:它可能已被历史签署任务引用,删掉会让留痕链断裂。
|
||
* 因此「删除」在这里表达为归档版本(后端 archive 动作),
|
||
* 语义上等于下架,已签署文书仍可追溯。
|
||
*/
|
||
async function removeTemplate(template: DocumentTemplate) {
|
||
const confirmed = await ElMessageBox.confirm(
|
||
useMockLibrary
|
||
? `确定删除文书「${template.name}」?删除后不可恢复。`
|
||
: `确定下架文书「${template.name}」?下架后不能再用于发起签署,已签署的记录不受影响。`,
|
||
useMockLibrary ? '删除文书' : '下架文书',
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: useMockLibrary ? '删除' : '下架',
|
||
cancelButtonText: '取消',
|
||
},
|
||
)
|
||
.then(() => true)
|
||
.catch(() => false)
|
||
|
||
if (!confirmed) {
|
||
return
|
||
}
|
||
|
||
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('文书已删除')
|
||
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
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: template.id,
|
||
versionId: template.versionId ?? template.id,
|
||
name: template.name,
|
||
code: template.code ?? '',
|
||
version: template.version,
|
||
department: template.department,
|
||
departmentId: template.departmentId,
|
||
campusId: template.campusId ?? undefined,
|
||
category: template.category,
|
||
description: template.description ?? '',
|
||
supportedMethods: ['pad', 'sms'],
|
||
contentHtml,
|
||
contentSha256: template.contentSha256,
|
||
}
|
||
}
|
||
|
||
async function openSigningDialog(template: DocumentTemplate) {
|
||
closePreview()
|
||
|
||
try {
|
||
const signing = await toSigningTemplate(template)
|
||
|
||
if (!signing) {
|
||
return
|
||
}
|
||
|
||
signingTemplate.value = signing
|
||
signingDialogVisible.value = true
|
||
} catch {
|
||
ElMessage.error('文书正文加载失败,无法发起签署')
|
||
}
|
||
}
|
||
|
||
function closeSigningDialog() {
|
||
signingDialogVisible.value = false
|
||
signingTemplate.value = null
|
||
}
|
||
|
||
// 闭环:文档库发起签署后跳到工作台并选中该任务,否则任务创建成功但用户看不到后续
|
||
function handleTaskCreated(task: SigningTaskRecord) {
|
||
closeSigningDialog()
|
||
void router.push({ name: 'workbench-signing', query: { taskId: task.id } })
|
||
}
|
||
|
||
// 自测用:一键清掉本地保存的演示文书与签署任务,回到初始状态。
|
||
// 只在演示模式下展示(模板里有 v-if),真实环境不该出现这个入口。
|
||
async function resetMockData() {
|
||
const confirmed = await ElMessageBox.confirm(
|
||
'将清除本地保存的演示文书与签署任务,恢复到初始状态。',
|
||
'重置演示数据',
|
||
{ type: 'warning', confirmButtonText: '重置', cancelButtonText: '取消' },
|
||
)
|
||
.then(() => true)
|
||
.catch(() => false)
|
||
|
||
if (!confirmed) {
|
||
return
|
||
}
|
||
|
||
clearMockState()
|
||
window.location.reload()
|
||
}
|
||
|
||
function clearFilters() {
|
||
filters.keyword = ''
|
||
filters.department = null
|
||
// status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」
|
||
filters.status = 'all'
|
||
}
|
||
|
||
function useTemplate(template: DocumentTemplate) {
|
||
void openSigningDialog(template)
|
||
}
|
||
|
||
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>
|
||
<section class="documents-page">
|
||
<DocumentLibraryHeader
|
||
v-model="filters.keyword"
|
||
:total="filteredTemplates.length"
|
||
:show-reset="useMockLibrary"
|
||
@add="showAddMessage"
|
||
@export="showExportMessage"
|
||
@reset="resetMockData"
|
||
/>
|
||
|
||
<DocumentFilterTabs
|
||
v-model:department="filters.department"
|
||
v-model:status="filters.status"
|
||
:departments="departmentTabs"
|
||
:statuses="statusTabs"
|
||
/>
|
||
|
||
<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
|
||
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>
|
||
|
||
<div v-else class="empty-state">
|
||
<div class="empty-icon" aria-hidden="true">⌕</div>
|
||
<template v-if="allTemplates.length === 0">
|
||
<p>文书库为空,点击右上角「新增文档」用 Word 创建第一份文书</p>
|
||
</template>
|
||
<template v-else>
|
||
<p>没有符合条件的文书</p>
|
||
<button
|
||
v-if="filters.keyword || filters.department || filters.status !== 'all'"
|
||
type="button"
|
||
class="reset-button"
|
||
@click="clearFilters"
|
||
>
|
||
清除筛选
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
<DocumentPreviewDialog
|
||
:visible="Boolean(selectedTemplate)"
|
||
:template="selectedTemplate"
|
||
:loading="previewLoading"
|
||
:error="previewError"
|
||
@close="closePreview"
|
||
@use="useTemplate"
|
||
/>
|
||
<NewSigningTaskDialog
|
||
:visible="signingDialogVisible"
|
||
:initial-template="signingTemplate"
|
||
@close="closeSigningDialog"
|
||
@created="handleTaskCreated"
|
||
/>
|
||
<WordEditorDialog
|
||
:visible="wordEditorVisible"
|
||
:template="wordEditingTemplate"
|
||
:campuses="campuses"
|
||
:departments="departments"
|
||
@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">
|
||
<span>正在加载文书正文…</span>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.documents-page {
|
||
width: 100%;
|
||
max-width: 1440px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.template-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.library-state {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
margin: 0 0 16px;
|
||
color: var(--mut);
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
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);
|
||
text-align: center;
|
||
background: var(--card);
|
||
border-radius: var(--r);
|
||
box-shadow: var(--sh);
|
||
}
|
||
|
||
.empty-icon {
|
||
margin-bottom: 8px;
|
||
color: var(--brand);
|
||
font-size: 38px;
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.empty-state p {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.reset-button {
|
||
padding: 6px 12px;
|
||
margin-top: 14px;
|
||
color: var(--brand);
|
||
font-size: 12px;
|
||
background: #fff;
|
||
border: 1px solid var(--brand);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.reset-button:hover {
|
||
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));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.template-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 680px) {
|
||
.template-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|