1309 lines
39 KiB
Vue
1309 lines
39 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 {
|
||
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>
|