Files
xh-medical-sign-web/clinical-web/src/views/management/documents/index.vue
T

1309 lines
39 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 {
createTemplate,
getDocuments,
getTemplateContent,
getTemplateVersionDetail,
getTemplateVersions,
readVersionContentHtml,
updateTemplate,
updateTemplateContent,
updateTemplateVersionStatus,
} from '@/api/management/documents'
import { getAllDepartments, getCampuses } from '@/api/management/organization'
import type {
CampusRecord,
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 { useLoginStore } from '@/stores/login'
import { demoCampuses, demoDepartments } from '@/utils/mock-document-library'
import { mockFlags } from '@/utils/mock-flags'
import { clearMockState } from '@/utils/mock-storage'
import { ALL_SIGNING_METHODS } from '@/utils/signing-method'
import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
import DocumentHistoryDialog from './components/DocumentHistoryDialog.vue'
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
import DocumentPropsDialog from './components/DocumentPropsDialog.vue'
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
import WordEditorDialog from './components/WordEditorDialog.vue'
import { toHistoryRows } from './history'
import {
canInitiateSigning,
DOCUMENT_LIBRARY_STATUSES,
readLibraryStatus,
resolveOwnershipDepartmentId,
} from './library'
import { loadDocumentLibrary, saveDocumentLibrary } from './mock'
import { DOC_TEMPLATE_PERMISSIONS } from './permissions'
import type {
DepartmentTab,
DocumentContentForm,
DocumentDepartment,
DocumentFilterForm,
DocumentPropsForm,
DocumentTemplate,
StatusTab,
} from './types'
/**
* 文档库数据来源。
*
* - 演示模式(显式开启 VITE_MOCK_DOCUMENTS):读本地演示文书库,可随意增删改;
* - 真实模式:读模板接口。列表只给模板元数据(含发布/最新版本指针),
* 正文要再取一次版本详情——这是接入时最容易被忽略的一步,
* 缺了它文档库会显示一堆没有正文、无法预览的"空壳模板"。
*
* 两种模式共用同一套页面结构:模板按 科室 → 分类 → 文书 三级分组渲染,
* 因此真实模式也需要把扁平的模板列表组织成同样的树。
*/
const router = useRouter()
const appStore = useAppStore()
const loginStore = useLoginStore()
const useMockLibrary = mockFlags.documents
/**
* 页面操作的服务端校验兜底。
*
* 按钮是否显示由模板上的 v-permission 决定,这里必须用同一套权限码再判一次:
* 快捷键、回车提交和程序化调用不会经过按钮,只靠隐藏按钮挡不住。
*/
function canWriteDocument(permission: string) {
return !loginStore.isReadOnly && (useMockLibrary || loginStore.hasWritePermission(permission))
}
const canCreateTemplate = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.create))
const canEditTemplate = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.edit))
const canToggleTemplate = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.stop))
const canInitiateSigningTask = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.initiate))
/**
* 能不能把文档设为「通用(全院)」。
*
* 原型 canSetGeneral() 只放给系统管理员 / 医务管理。这里用「有没有停用/发布权限」
* 近似判断:全院通用文书一改就影响所有科室,属于管理动作,与停用同级的账号才该动它。
* 判据留在页面层,属性弹窗只收结果,不自己去读角色。
*/
const canSetGeneral = computed(() => canToggleTemplate.value)
/** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
const libraryLoading = ref(false)
const libraryError = ref('')
const library = ref<DocumentDepartment[]>([])
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
const filters = reactive<DocumentFilterForm>({
keyword: '',
department: null,
status: 'all',
})
/** 预览弹窗 */
const previewTemplate = ref<DocumentTemplate | null>(null)
const previewLoading = ref(false)
const previewError = ref(false)
/** 属性弹窗 */
const propsDialogVisible = ref(false)
const propsTemplate = ref<DocumentTemplate | null>(null)
const propsSaving = ref(false)
/** 正文弹窗 */
const contentDialogVisible = ref(false)
const contentTemplate = ref<DocumentTemplate | null>(null)
const contentSaving = ref(false)
/** 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 */
const contentLoading = ref(false)
/** 历史记录弹窗 */
const historyDialogVisible = ref(false)
const historyTemplate = ref<DocumentTemplate | null>(null)
const historyRows = ref<ReturnType<typeof toHistoryRows>>([])
const historyLoading = ref(false)
const historyError = ref(false)
/** 发起签署弹窗 */
const signingDialogVisible = ref(false)
const signingTemplate = ref<SigningTemplate | null>(null)
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,
})),
])
/**
* 状态页签只有两档(启用中 / 停用),外加「全部」。
*
* 不再有草稿 / 归档 / 需复审:审核链路已废弃,后端状态也会重写。
* 也不按后端模板状态枚举做页签——枚举摆到界面上,科室会看到一堆看不懂也点不动的档位。
* 计数按**全库**统计(不受科室页签影响),与原型 libCountByStatus 一致。
*/
const statusTabs = computed<StatusTab[]>(() => [
{ key: 'all', label: '全部', count: allTemplates.value.length },
...DOCUMENT_LIBRARY_STATUSES.map((status) => ({
key: status,
label: status,
count: allTemplates.value.filter((template) => readLibraryStatus(template) === status).length,
})),
])
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' || readLibraryStatus(template) === filters.status
// 搜索范围照原型:模板名 / 编号 / 科室 / 分类,外加检查类型与关联项目编码。
// 后两者是卡片上看得见的字段,搜不到会让人以为这份文书不存在。
const matchesKeyword =
!keyword ||
[
template.name,
template.code ?? '',
template.department,
template.category,
template.examType ?? '',
(template.projectCodes ?? []).join(','),
]
.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[],
departmentOptions: DepartmentRecord[],
): DocumentDepartment[] {
const grouped = new Map<string, DocumentDepartment>()
const departmentNames = new Map(
departmentOptions.map((department) => [department.id, department.name]),
)
records.forEach((record) => {
const departmentKey = record.departmentId ?? '__all__'
const departmentName =
record.departmentName ??
(record.departmentId ? (departmentNames.get(record.departmentId) ?? '未知科室') : '全院通用')
let department = grouped.get(departmentKey)
if (!department) {
department = { key: departmentKey, name: departmentName, campus: 'all', categories: [] }
grouped.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 [...grouped.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, departments.value)
} catch {
library.value = []
libraryError.value = '文书库加载失败,请确认后端模板接口可访问后重试'
} finally {
libraryLoading.value = false
}
}
/**
* 读取组织字典。
* 真实模式取真实院区/科室;演示模式用演示字典,键名与接口同构,
* 因此「文档归属」在两种模式下走同一段模板代码。
*/
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(),
getAllDepartments(),
])
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
if (campusesResult.status === 'rejected') {
ElMessage.error('院区字典加载失败,新增文书时无法选择所属院区')
}
if (departmentsResult.status === 'rejected') {
ElMessage.error('科室字典加载失败,文书所属科室暂时无法正确显示')
}
}
// 演示数据落本地:刷新后文书库还在,不用每次重新建文书。
// 关闭演示库时不写本地,否则会把之前保存的演示文书覆盖成空库。
watch(
library,
(value) => {
if (useMockLibrary && !libraryLoading.value) {
saveDocumentLibrary(value)
}
},
{ deep: true },
)
/* ---------------- 预览 ---------------- */
function closePreview() {
previewTemplate.value = null
previewError.value = false
}
/**
* 预览模板。
* 真实模式的列表接口不返回正文,这里按需取回再打开预览;
* 取不到正文时如实报错,让科室知道是接口/数据问题,而不是看到一个空预览。
*/
async function handlePreview(template: DocumentTemplate) {
previewTemplate.value = template
if (useMockLibrary || template.contentHtml || !template.versionId) {
previewLoading.value = false
previewError.value = false
return
}
previewLoading.value = true
previewError.value = false
try {
const contentHtml = await getTemplateContent(template.id, template.versionId)
// 用户在等待期间可能已经切到别的文书,回填前确认仍是同一份
if (previewTemplate.value?.id === template.id) {
previewTemplate.value = { ...template, contentHtml }
previewError.value = !contentHtml
}
} catch {
if (previewTemplate.value?.id === template.id) {
previewError.value = true
}
} finally {
if (previewTemplate.value?.id === template.id) {
previewLoading.value = false
}
}
}
function showExportMessage() {
ElMessage.info('导出模板包待接入:需要后端提供模板包导出接口')
}
/* ---------------- 新增 / 编辑属性 ---------------- */
function openCreateDialog() {
if (!canCreateTemplate.value) {
ElMessage.warning('当前账号没有新增文档模板的权限')
return
}
propsTemplate.value = null
propsDialogVisible.value = true
}
function openPropsDialog(template: DocumentTemplate) {
if (!canWriteDocument(DOC_TEMPLATE_PERMISSIONS.props)) {
ElMessage.warning('当前账号没有编辑文档属性的权限')
return
}
propsTemplate.value = template
propsDialogVisible.value = true
}
function closePropsDialog() {
propsDialogVisible.value = false
propsTemplate.value = null
}
/**
* 生成文书编号。
*
* 属性弹窗按原型口径不收编号(原型的「编辑属性」只有名称 / 类型 / 版本 / 归属 / 说明),
* 但后端 CreateTemplateRequest 的 templateCode 是必填,所以这里替它生成一个。
* 用名称哈希做前缀是为了同一个名字多次创建时编号可读、可追溯,
* 后面再接时间片保证唯一——纯哈希在文书多起来之后必然撞号。
*/
function generateTemplateCode(name: string) {
let hash = 0
for (let index = 0; index < name.length; index += 1) {
hash = (hash * 31 + name.charCodeAt(index)) | 0
}
const suffix = (Math.abs(hash) % 9000) + 1000
const stamp = Date.now().toString(36).slice(-4).toUpperCase()
return `TPL-ED-${suffix}-${stamp}`
}
/**
* 在本地文库里定位一份文书(连同它所在的分组与类别)。
*
* 属性更新与正文更新都要"先找到再改",且属性更新还可能需要把卡片挪到别的科室下,
* 所以这里返回三件套而不是只返回文书本身。
*/
function locateLocalDocument(id: string) {
for (const group of library.value) {
for (const category of group.categories) {
for (const document of category.documents) {
if (document.id === id) {
return { group, category, document }
}
}
}
}
return null
}
/** 演示模式:把新文书追加进本地文库(分组按科室名匹配已有分组) */
function addLocalTemplate(form: DocumentPropsForm, departmentId: string | null): DocumentTemplate {
const department = departments.value.find((item) => item.id === 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()
const record: DocumentRecord = {
id: `local-template-${timestamp}`,
name: form.name,
code: generateTemplateCode(form.name),
version: form.versionNo || 'v1.0.0',
// 新建即「启用中」:状态只剩两档,没有草稿这一档了。
// 正文还没写时不该发起签署,但这道闸改由正文校验把守(见 toSigningTemplate),
// 不再靠状态去挡——否则新建的文书会先显示成「停用」,科室以为是被人下线的。
status: 'published',
backendStatus: 'PUBLISHED',
updatedBy: '本地演示',
updatedAt: new Date().toISOString(),
category: form.category,
description: form.description,
versionId: `local-version-${timestamp}`,
departmentId: departmentId,
departmentName,
campusId: campuses.value[0]?.id ?? '',
contentHtml: '',
sharedDepartments: form.ownership.filter((name) => name !== departmentName),
}
category.documents.push(record)
return { ...record, departmentKey, department: departmentName }
}
/**
* 演示模式:就地更新属性(正文不动,两件事分开维护)。
*
* 归属是可以改的,所以这里还要负责「把卡片挪到新科室下」——本地文库是按
* 科室 → 类别 → 文书 三层组织的,只改 document.departmentName 而不挪位置,
* 卡片会留在原科室分组里、显示的和属性弹窗里填的对不上。
*/
function updateLocalProps(form: DocumentPropsForm, departmentId: string | null) {
const target = departments.value.find((item) => item.id === departmentId)
const targetName = target?.name ?? '全院通用'
// 与 addLocalTemplate 同一套分组键口径:演示模式没有 UUID 时落 __all__
const targetKey = target?.id ?? '__all__'
const located = locateLocalDocument(form.id ?? '')
if (!located) {
return null
}
const { group, category, document } = located
document.name = form.name
document.category = form.category
document.version = form.versionNo || document.version
document.description = form.description
document.updatedAt = new Date().toISOString()
document.departmentId = departmentId
document.departmentName = targetName
// 主科室进 departmentId,其余勾选科室留在 sharedDepartments(与新建时同一口径)
document.sharedDepartments = form.ownership.filter((name) => name !== targetName)
if (group.key !== targetKey) {
category.documents = category.documents.filter((item) => item.id !== document.id)
let nextGroup = library.value.find((item) => item.key === targetKey)
if (!nextGroup) {
nextGroup = { key: targetKey, name: targetName, campus: 'all', categories: [] }
library.value.push(nextGroup)
}
let nextCategory = nextGroup.categories.find((item) => item.name === form.category)
if (!nextCategory) {
nextCategory = { name: form.category, documents: [] }
nextGroup.categories.push(nextCategory)
}
nextCategory.documents.push(document)
}
return {
...document,
departmentKey: targetKey,
department: targetName,
category: form.category,
} satisfies DocumentTemplate
}
async function handlePropsSubmit(form: DocumentPropsForm) {
const departmentId = resolveOwnershipDepartmentId(form.ownership, departments.value)
if (useMockLibrary) {
if (form.id) {
updateLocalProps(form, departmentId)
closePropsDialog()
ElMessage.success('文档属性已更新')
// 属性与正文分开:改完属性停在这里,正文从卡片上的「编辑正文」进
return
}
const created = addLocalTemplate(form, departmentId)
closePropsDialog()
ElMessage.success('文档已创建,接着编写正文')
// 原型 openDocCreate:属性存完直接进正文编辑器,不用回列表再点一次
void openContentEditor(created)
return
}
propsSaving.value = true
try {
if (form.id) {
await updateTemplate(form.id, {
name: form.name,
description: form.description,
category: form.category,
})
closePropsDialog()
await loadLibrary()
ElMessage.success('文档属性已更新')
return
}
const created = await createTemplate({
templateCode: generateTemplateCode(form.name),
name: form.name,
description: form.description,
campusId: appStore.selectedCampusId || campuses.value[0]?.id || '',
departmentId,
category: form.category,
})
closePropsDialog()
await loadLibrary()
const target = allTemplates.value.find((item) => item.id === created.id)
if (target) {
await openContentEditor(target)
} else {
ElMessage.success('文档已创建,请在卡片上点「编辑正文」继续')
}
} catch {
ElMessage.error(form.id ? '文档属性更新失败,请稍后重试' : '文档创建失败,请稍后重试')
} finally {
propsSaving.value = false
}
}
/* ---------------- 编辑正文 ---------------- */
/**
* 打开正文编辑器。
*
* 真实模式的列表接口不返回正文,必须先取回——直接用列表里的空 contentHtml 打开,
* 科室会看到"文书内容为空",保存后还会把模板正文冲掉。
* 但**新建的文书还没有版本**,正文本来就是空的,那种情况不该报错。
*/
async function openContentEditor(template: DocumentTemplate) {
if (!canEditTemplate.value) {
ElMessage.warning('当前账号没有编辑文档正文的权限')
return
}
if (useMockLibrary || template.contentHtml || !template.versionId) {
contentTemplate.value = template
contentDialogVisible.value = true
return
}
contentLoading.value = true
try {
const contentHtml = await getTemplateContent(template.id, template.versionId)
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,请确认模板版本内容接口可访问')
return
}
contentTemplate.value = { ...template, contentHtml }
contentDialogVisible.value = true
} catch {
ElMessage.error('文书正文加载失败,请稍后重试')
} finally {
contentLoading.value = false
}
}
function closeContentDialog() {
contentDialogVisible.value = false
contentTemplate.value = null
}
/** 演示模式:就地更新正文(属性不动) */
function updateLocalContent(form: DocumentContentForm) {
const located = locateLocalDocument(form.id)
if (!located) {
return
}
located.document.contentHtml = form.contentHtml
located.document.needConfirm = form.needConfirm
located.document.updatedAt = new Date().toISOString()
}
/**
* 保存正文。
*
* 已发布的版本不可原地改写(已签署文书引用它),因此编辑落到**新版本**上,
* 这也是后端版本模型的本意。前端不在这里"顺带发布"——发布是需要留痕的正式动作,
* 由卡片上的「启用」显式触发。
*/
async function handleContentSubmit(form: DocumentContentForm) {
if (!canEditTemplate.value) {
ElMessage.warning('当前账号没有保存文档正文的权限')
return
}
if (useMockLibrary) {
updateLocalContent(form)
closeContentDialog()
ElMessage.success('正文已保存')
return
}
contentSaving.value = true
try {
// 新建的文书还没有任何版本:第一个版本用 createTemplateWithContent 的版本那一步等价路径,
// 这里统一走 createTemplateVersion(见 updateTemplateContent),
// 后端会把它当成该模板的第一个版本
await updateTemplateContent(form.id, {
contentHtml: form.contentHtml,
needConfirm: form.needConfirm,
})
closeContentDialog()
await loadLibrary()
ElMessage.success('正文已保存为新版本')
} catch {
ElMessage.error('正文保存失败,请稍后重试')
} finally {
contentSaving.value = false
}
}
/* ---------------- 停用 / 启用 ---------------- */
/**
* 演示模式:就地翻转状态(停用 ↔ 启用中)。
*
* 停用一侧落 backendStatus = 'DISABLED'(后端真实的停用取值)。
* 归一的 status 只能写 'archived' —— api 层的 DocumentStatus 没有 disabled 这一项,
* 而 readLibraryStatus 优先读 backendStatus,所以显示口径仍然是准确的。
*/
function toggleLocalStatus(template: DocumentTemplate) {
const next: Pick<DocumentRecord, 'status' | 'backendStatus'> =
readLibraryStatus(template) === '停用'
? { status: 'published', backendStatus: 'PUBLISHED' }
: { status: 'archived', backendStatus: 'DISABLED' }
library.value.forEach((department) =>
department.categories.forEach((category) =>
category.documents.forEach((document) => {
if (document.id === template.id) {
Object.assign(document, next, { updatedAt: new Date().toISOString() })
}
}),
),
)
}
/**
* 停用 / 启用。
*
* 真实模式走后端版本动作(停用 → disable,启用 → publish),前端不自行改写 status:
* 「谁在什么时候把这份文书放行到可签署 / 什么时候下线的」必须由后端留痕。
*
* 这是页面上**唯一**的状态动作:状态只有两档,两档之间都能来回切,因此没有
* 「某些档位不能停用 / 启用」这种分支了。
*/
async function handleToggleStatus(template: DocumentTemplate) {
const status = readLibraryStatus(template)
if (!canToggleTemplate.value) {
ElMessage.warning('当前账号没有停用 / 启用文书的权限')
return
}
const nextLabel = status === '停用' ? '启用' : '停用'
const action = status === '停用' ? 'publish' : 'disable'
const confirmed = await ElMessageBox.confirm(
status === '停用'
? `确定启用「${template.name}」?启用后各科室可见,并可发起签署。`
: `确定停用「${template.name}」?停用后不能再发起新的签署,已签署的记录不受影响。`,
`确认${nextLabel}`,
{ type: 'warning', confirmButtonText: nextLabel, cancelButtonText: '取消' },
)
.then(() => true)
.catch(() => false)
if (!confirmed) {
return
}
if (useMockLibrary) {
toggleLocalStatus(template)
ElMessage.success(`已${nextLabel}`)
return
}
if (!template.versionId) {
ElMessage.error('该文书没有可操作的版本,请刷新后重试')
return
}
try {
await updateTemplateVersionStatus(template.id, template.versionId, action)
ElMessage.success(`已${nextLabel}`)
await loadLibrary()
} catch {
ElMessage.error(`${nextLabel}失败,请稍后重试`)
}
}
/* ---------------- 历史记录 ---------------- */
/**
* 查看历史记录。
*
* 原型 V10.2 的历史记录表是 版本 / 操作人 / 时间 / 操作 / 说明 / 操作,
* 后端 TemplateVersionResponseDto 没有"操作名"字段,只能由状态与各阶段时间戳归纳
* (口径集中在 history.ts,避免表格与其它入口各写一份)。
*
* 演示文库只保存当前版本,因此演示模式合成一条并标注,不假装有一份完整历史。
*/
async function openHistory(template: DocumentTemplate) {
historyTemplate.value = template
historyDialogVisible.value = true
historyRows.value = []
historyError.value = false
if (useMockLibrary) {
historyLoading.value = false
historyRows.value = template.versionId
? toHistoryRows(
[
{
id: template.versionId,
templateId: template.id,
versionNo: template.version,
contentHtml: template.contentHtml,
contentSha256: template.contentSha256,
status: template.backendStatus,
createdAt: template.updatedAt,
createdBy: template.updatedBy,
},
],
template.versionId,
)
: []
return
}
historyLoading.value = true
try {
const versions = await getTemplateVersions(template.id)
historyRows.value = toHistoryRows(versions, template.versionId)
} catch {
historyError.value = true
} finally {
historyLoading.value = false
}
}
function closeHistory() {
historyDialogVisible.value = false
historyTemplate.value = null
historyRows.value = []
historyError.value = false
}
/**
* 只读查看某个历史版本的正文。
* 已发布版本按任务固化,历史任务仍按当时版本渲染,所以这条链路是必要的——
* 不能只给一个版本号列表,让人看不出那一版到底写了什么。
*/
async function viewHistoryVersion(versionId: string) {
const template = historyTemplate.value
if (!template) {
return
}
try {
const version: TemplateVersionResponseDto | null = await getTemplateVersionDetail(
template.id,
versionId,
)
const contentHtml = readVersionContentHtml(version)
if (!contentHtml) {
ElMessage.error('未能取到该版本的正文内容')
return
}
closeHistory()
previewTemplate.value = {
...template,
contentHtml,
version: version?.versionNo ?? template.version,
versionId,
}
previewError.value = false
previewLoading.value = false
} catch {
ElMessage.error('历史版本正文加载失败,请稍后重试')
}
}
/* ---------------- 发起签署 ---------------- */
/**
* 把文书转成签署模板。
*
* 转之前要确保有正文:真实模式的列表接口不返回正文,缺正文时发起签署会签出一份
* 与模板无关的空文书。因此这里在缺正文时先取回,取不到就拒绝发起;
* 演示模式没有「取回」这一步,但同样在缺正文时拒绝——新建的文书正是这种。
*/
async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTemplate | null> {
if (!useMockLibrary && !template.currentVersionId) {
ElMessage.error('该文书还没有已发布版本,无法发起签署')
return null
}
let contentHtml = template.contentHtml
let versionNo = template.version
let contentSha256 = template.contentSha256
const signingVersionId = useMockLibrary
? (template.versionId ?? template.id)
: template.currentVersionId!
if (!useMockLibrary) {
const version = await getTemplateVersionDetail(template.id, signingVersionId)
contentHtml = readVersionContentHtml(version)
versionNo = version?.versionNo ?? versionNo
contentSha256 = version?.contentSha256 ?? contentSha256
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,无法发起签署')
return null
}
} else if (!contentHtml) {
// 演示模式:状态只剩两档,新建的文书直接落在「启用中」,
// 于是「有没有正文」成了唯一那道闸——空正文签出来的是一份与模板无关的白纸。
ElMessage.error('该文书还没有正文,请先点「编辑正文」编写内容后再发起签署')
return null
}
return {
id: template.id,
versionId: signingVersionId,
name: template.name,
code: template.code ?? '',
version: versionNo,
department: template.department,
departmentId: template.departmentId,
campusId: template.campusId ?? undefined,
category: template.category,
description: template.description ?? '',
supportedMethods: [...ALL_SIGNING_METHODS],
needConfirm: template.needConfirm ?? false,
contentHtml,
contentSha256,
}
}
async function openSigningDialog(template: DocumentTemplate) {
if (!canInitiateSigningTask.value) {
ElMessage.warning('当前账号没有发起签署任务的权限')
return
}
if (!canInitiateSigning(readLibraryStatus(template))) {
ElMessage.info('只有「启用中」的文书可以发起签署')
return
}
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="openCreateDialog"
@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"
@preview="handlePreview"
@edit-props="openPropsDialog"
@edit-content="openContentEditor"
@history="openHistory"
@toggle-status="handleToggleStatus"
@initiate="openSigningDialog"
/>
</div>
<div v-else class="empty-state">
<div class="empty-icon" aria-hidden="true">⌕</div>
<template v-if="allTemplates.length === 0">
<p>文书库为空,点击右上角「新增文档」创建第一份文书</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(previewTemplate)"
:template="previewTemplate"
:loading="previewLoading"
:error="previewError"
@close="closePreview"
@use="useTemplate"
/>
<DocumentPropsDialog
:visible="propsDialogVisible"
:template="propsTemplate"
:departments="departments"
:can-set-general="canSetGeneral"
:is-mock="useMockLibrary"
:saving="propsSaving"
@close="closePropsDialog"
@submit="handlePropsSubmit"
/>
<WordEditorDialog
:visible="contentDialogVisible"
:template="contentTemplate"
:saving="contentSaving"
@close="closeContentDialog"
@save="handleContentSubmit"
/>
<DocumentHistoryDialog
:visible="historyDialogVisible"
:template="historyTemplate"
:rows="historyRows"
:loading="historyLoading"
:error="historyError"
:is-mock="useMockLibrary"
@close="closeHistory"
@view-version="viewHistoryVersion"
/>
<NewSigningTaskDialog
:visible="signingDialogVisible"
:initial-template="signingTemplate"
@close="closeSigningDialog"
@created="handleTaskCreated"
/>
<!-- 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 -->
<div v-if="contentLoading" 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>