Files
xh-medical-sign-web/clinical-web/src/views/management/documents/index.vue
T
yelan 97c5182ba4 feat(文书管理): 增加版本工作流与版本历史
- 模板状态按后端原始状态展示与筛选,待审核与已驳回分开展示
- 仅已发布模板可发起签署,新增提交审核/审核通过/驳回等动作
- 新增版本历史弹窗,复用统一的模板时间格式化方法
- 演示数据补齐已签署任务的签名图,并提供获取签名 dataURL 的接口
- 修复编辑器插入占位符时未定位光标导致内容落到文首的问题
2026-09-17 16:44:54 +08:00

1066 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>