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

1481 lines
46 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,
GENERAL_OWNERSHIP,
ownershipCampusId,
isGlobalOwnership,
CAMPUS_OWNERSHIP_PREFIX,
readLibraryStatus,
resolveOwnershipDepartmentId,
templateWorkflowAction,
templateWorkflowLabel,
} from './library'
import { loadDocumentLibrary, saveDocumentLibrary } from './mock'
import { DOC_TEMPLATE_PERMISSIONS, workflowPermission } from './permissions'
import type {
CampusTab,
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
/** 没有绑定科室的文书归到这一组(全院通用),不属于任何一个科室页签 */
const GENERAL_GROUP_KEY = '__all__'
const CAMPUS_GROUP_KEY = '__campus_common__'
/**
* 页面操作的服务端校验兜底。
*
* 按钮是否显示由模板上的 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 allCampusesSelected = ref(false)
const libraryCampusId = computed(() => (allCampusesSelected.value ? '' : appStore.selectedCampusId))
const filters = reactive<DocumentFilterForm>({
keyword: '',
department: null,
status: '启用中',
})
/** 预览弹窗 */
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,
})),
),
),
)
/**
* 院区页签:与「组织架构」共用同一份院区字典和同一个排序(sortNo),
* 因此这一行的顺序与架构树里的院区顺序一致。
*
* 演示文库没有院区维度(loadLibrary 直接读本地数据、不看院区),
* 渲染一行点了不生效的院区页签比不渲染更糟,所以演示模式返回空数组、整行隐藏。
*/
const campusTabs = computed<CampusTab[]>(() =>
useMockLibrary
? []
: [
{ key: '', label: '所有院区' },
...[...campuses.value]
.filter((campus) => campus.status !== 'DISABLED')
.sort(
(left, right) =>
left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
)
.map((campus) => ({ key: campus.id, label: campus.name })),
],
)
/**
* 科室页签:**与「组织架构」同一份字典** —— 当前院区下的全部科室,按 sortNo 排。
*
* 刻意不按「有没有文书」过滤:两个页面的科室清单必须一致。组织架构里有的科室在
* 文档库里查不到,科室会以为文书库漏了;反过来只显示有文书的科室,也看不出
* 「这个科室还没建过文书」。
*
* 「全院通用」**不是科室**(它不挂在任何院区下,是「没有归属科室」的文书),
* 因此不管当前选中哪个院区都必须有,固定排在「全部」之后。
*
* 字典里查不到的孤儿分组(科室被删掉、演示文库的占位键)补在最后 —— 不补的话
* 这些文书只能在「全部」里看到,点不到具体科室。
*/
const departmentTabs = computed<DepartmentTab[]>(() => {
const countOf = (key: string) =>
allTemplates.value.filter((template) => template.departmentKey === key).length
const campusId = libraryCampusId.value
const campusDepartments = [...departments.value]
.filter((department) => department.status !== 'DISABLED')
// 演示文库没有院区维度(科室字典的 campusId 是演示值,与全局院区对不上),不按院区过滤
.filter((department) => useMockLibrary || !campusId || department.campusId === campusId)
.sort(
(left, right) => left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
)
const knownKeys = new Set(campusDepartments.map((department) => department.id))
const orphans = library.value.filter(
(group) =>
group.key !== GENERAL_GROUP_KEY &&
group.key !== CAMPUS_GROUP_KEY &&
!knownKeys.has(group.key),
)
return [
{ key: null, label: '全部', count: allTemplates.value.length },
{ key: GENERAL_GROUP_KEY, label: '全院通用', count: countOf(GENERAL_GROUP_KEY) },
{ key: CAMPUS_GROUP_KEY, label: '院区通用', count: countOf(CAMPUS_GROUP_KEY) },
...campusDepartments.map((department) => ({
key: department.id,
label: allCampusesSelected.value
? `${campuses.value.find((campus) => campus.id === department.campusId)?.name ?? '未知院区'} · ${department.name}`
: department.name,
count: countOf(department.id),
})),
...orphans.map((group) => ({ key: group.key, label: group.name, count: countOf(group.key) })),
]
})
/**
* 状态页签只有两组(启用中 / 停用),外加「全部」。卡片按需显示待发布等具体版本状态;
* 不按完整后端枚举增加筛选页签。
* 计数按**全库**统计(不受科室页签影响),与原型 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 ?? (isGlobalOwnership(record) ? GENERAL_GROUP_KEY : CAMPUS_GROUP_KEY)
const departmentName =
record.departmentName ??
(record.departmentId
? (departmentNames.get(record.departmentId) ?? '未知科室')
: isGlobalOwnership(record)
? '全院通用'
: '院区通用')
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,
departmentName,
campusName: campuses.value.find((campus) => campus.id === record.campusId)?.name,
})
})
// 全院通用的模板排最前,其余按科室名排序,保证每次进页面分组顺序稳定
return [...grouped.values()].sort((left, right) => {
if (left.key === GENERAL_GROUP_KEY) {
return -1
}
if (right.key === GENERAL_GROUP_KEY) {
return 1
}
return left.name.localeCompare(right.name, 'zh-CN')
})
}
/**
* 读取权限范围内的全部模板。
* 权限范围内模板可能超过一页,这里翻完所有页;不翻页会静默漏掉后面的模板,
* 表现为"文档库里少了几份文书",很难排查。
*/
async function fetchAllDocuments(): Promise<DocumentRecord[]> {
const campusId = libraryCampusId.value || undefined
const pageSize = 200
const firstPage = await getDocuments({
page: 1,
pageSize,
status: 'all',
campusId,
})
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,
}),
),
)
return [firstPage, ...remaining].flatMap((page) => page.records)
}
let libraryRequestId = 0
async function loadLibrary() {
if (useMockLibrary) {
libraryError.value = ''
library.value = cloneLibrary(loadDocumentLibrary())
return
}
libraryLoading.value = true
libraryError.value = ''
const requestId = ++libraryRequestId
try {
const records = await fetchAllDocuments()
if (requestId !== libraryRequestId) return
library.value = groupDocuments(records, departments.value)
} catch {
if (requestId !== libraryRequestId) return
library.value = []
libraryError.value = '文书库加载失败,请确认后端模板接口可访问后重试'
} finally {
if (requestId === libraryRequestId) 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}`
}
/**
* 归属勾选结果 → 共享科室名清单(演示文库的 sharedDepartments 存的是科室名)。
*
* 勾选值现在是科室 id(同名科室跨院区会重名,名字定位不到唯一一条),
* 这里在落本地前换回名字;「通用(全院)」与主科室本身不进这份清单。
*/
function sharedDepartmentNames(ownership: string[], primaryDepartmentId: string | null): string[] {
return ownership
.filter(
(value) =>
value !== GENERAL_OWNERSHIP &&
!value.startsWith(CAMPUS_OWNERSHIP_PREFIX) &&
value !== primaryDepartmentId,
)
.map((id) => departments.value.find((department) => department.id === id)?.name)
.filter((name): name is string => Boolean(name))
}
/**
* 在本地文库里定位一份文书(连同它所在的分组与类别)。
*
* 属性更新与正文更新都要"先找到再改",且属性更新还可能需要把卡片挪到别的科室下,
* 所以这里返回三件套而不是只返回文书本身。
*/
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 global = form.ownership.includes(GENERAL_OWNERSHIP)
const campusId = global ? null : department?.campusId || ownershipCampusId(form.ownership)
const departmentName = department?.name ?? (global ? '全院通用' : '院区通用')
// 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义
const departmentKey = department?.id ?? (global ? GENERAL_GROUP_KEY : CAMPUS_GROUP_KEY)
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,
ownershipScope: global ? 'GLOBAL' : departmentId ? 'DEPARTMENT' : 'CAMPUS',
campusName: campuses.value.find((campus) => campus.id === campusId)?.name,
contentHtml: '',
sharedDepartments: sharedDepartmentNames(form.ownership, departmentId),
}
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 global = form.ownership.includes(GENERAL_OWNERSHIP)
const campusId = global ? null : target?.campusId || ownershipCampusId(form.ownership)
const targetName = target?.name ?? (global ? '全院通用' : '院区通用')
// 与 addLocalTemplate 同一套分组键口径:演示模式没有 UUID 时落 GENERAL_GROUP_KEY
const targetKey = target?.id ?? (global ? GENERAL_GROUP_KEY : CAMPUS_GROUP_KEY)
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
document.campusId = campusId
document.campusName = campuses.value.find((campus) => campus.id === campusId)?.name
document.ownershipScope = global ? 'GLOBAL' : departmentId ? 'DEPARTMENT' : 'CAMPUS'
// 主科室进 departmentId,其余勾选科室留在 sharedDepartments(与新建时同一口径)
document.sharedDepartments = sharedDepartmentNames(form.ownership, departmentId)
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)
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 {
const targetDepartment = departments.value.find((item) => item.id === departmentId)
if (departmentId && !targetDepartment) {
ElMessage.error('所选科室不存在,请刷新后重试')
return
}
const global = form.ownership.includes(GENERAL_OWNERSHIP)
const campusId = global ? null : targetDepartment?.campusId || ownershipCampusId(form.ownership)
const ownershipScope = global ? 'GLOBAL' : departmentId ? 'DEPARTMENT' : 'CAMPUS'
if (!global && !campusId) {
ElMessage.error('无法确定文档所属院区,请选择院区后重试')
return
}
if (form.id) {
await updateTemplate(form.id, {
name: form.name,
description: form.description,
category: form.category,
campusId,
departmentId,
ownershipScope,
})
closePropsDialog()
await loadLibrary()
ElMessage.success('文档属性已更新')
return
}
const created = await createTemplate({
templateCode: generateTemplateCode(form.name),
name: form.name,
description: form.description,
campusId,
departmentId,
ownershipScope,
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 action = templateWorkflowAction(template)
const next: Pick<DocumentRecord, 'status' | 'backendStatus'> =
action === 'disable'
? { status: 'archived', backendStatus: 'DISABLED' }
: { status: 'published', backendStatus: 'PUBLISHED' }
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() })
}
}),
),
)
}
/**
* 停用 / 启用。
*
* 真实模式走后端版本动作(草稿/停用 → enable,审核通过 → publish,已发布 → disable):
* 「谁在什么时候把这份文书放行到可签署 / 什么时候下线的」必须由后端留痕。
*
* 仅在后端支持的状态上显示动作;审核中、驳回和归档版本不能直接切换。
*/
async function handleToggleStatus(template: DocumentTemplate) {
const action = templateWorkflowAction(template)
const nextLabel = templateWorkflowLabel(template)
if (!action) {
ElMessage.info('该文书当前状态不可操作')
return
}
if (!canWriteDocument(workflowPermission(action))) {
ElMessage.warning(`当前账号没有${nextLabel}文书的权限`)
return
}
const confirmed = await ElMessageBox.confirm(
nextLabel === '发布'
? `确定发布「${template.name}」${template.version}?发布后该版本可用于发起签署。`
: nextLabel === '启用'
? `确定启用「${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
}
const versionId =
action === 'disable' ? (template.currentVersionId ?? template.versionId) : template.versionId
if (!versionId) {
ElMessage.error('该文书没有可操作的版本,请刷新后重试')
return
}
try {
await updateTemplateVersionStatus(template.id, 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
}
if (!useMockLibrary && !template.currentVersionId) {
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({ path: '/sign/workbench', 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
// 清除筛选后恢复默认的启用中状态。
filters.status = '启用中'
// 院区不在这里重置:它是页面的取数口径(文书库按院区向服务端取数),
// 不是「筛掉一部分已加载数据」的筛选条件,清掉它得重新拉一遍文库
}
/**
* 切换院区。
*
* 所有院区仅用于文档库查询;具体院区仍与全局选择器同步。
* 重新取数由下面的 watch 负责。
*/
function handleCampusSelect(campusId: string) {
if (!campusId) {
allCampusesSelected.value = true
return
}
allCampusesSelected.value = false
appStore.selectCampus(campusId)
}
function useTemplate(template: DocumentTemplate) {
void openSigningDialog(template)
}
let libraryInitialized = false
async function initializePage() {
if (useMockLibrary) {
await loadOrganizationOptions()
} else {
await appStore.loadCampuses()
await loadOrganizationOptions()
}
libraryInitialized = true
await loadLibrary()
}
watch(libraryCampusId, (campusId, previousCampusId) => {
if (campusId === previousCampusId) {
return
}
// 院区变了,原先选中的科室不一定属于新院区,清掉以免筛出一片空结果
filters.department = null
// 初始化由 initializePage 取数,演示文库没有院区维度。
if (useMockLibrary || !libraryInitialized) {
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"
:campus="libraryCampusId"
:campuses="campusTabs"
:departments="departmentTabs"
:statuses="statusTabs"
@select-campus="handleCampusSelect"
/>
<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 !== '启用中'"
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"
:campuses="campuses"
: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>