feat(management): 接入文档用户权限与报表

This commit is contained in:
yelan
2026-09-01 10:13:11 +08:00
parent beeb98d18f
commit e467f03f23
12 changed files with 2361 additions and 41 deletions
@@ -7,6 +7,8 @@ import type { DocumentTemplate } from '../types'
const props = defineProps<{
visible: boolean
template: DocumentTemplate | null
loading: boolean
error: boolean
}>()
const emit = defineEmits<{
@@ -49,7 +51,18 @@ const statusLabel = computed(() => {
</header>
<div class="dialog-body">
<article class="preview-paper">
<div v-if="loading" class="preview-state">正在加载模板内容…</div>
<div v-else-if="error" class="preview-state preview-state--error">
模板内容加载失败,请关闭后重试。
</div>
<iframe
v-else-if="template.contentHtml"
class="preview-frame"
:srcdoc="template.contentHtml"
sandbox=""
title="文档模板内容预览"
/>
<article v-else class="preview-paper">
<h3>{{ template.name }}</h3>
<p class="paper-number">模板编号:{{ template.code }} · 版本 {{ template.version }}</p>
<p>
@@ -84,9 +97,21 @@ const statusLabel = computed(() => {
</div>
<footer class="dialog-footer">
<p>占位符由系统在签署时自动填充,当前仅用于原型演示。</p>
<p>
{{
template.contentHtml
? '模板内容来自服务端,预览区域已限制脚本执行。'
: '占位符由系统在签署时自动填充,当前仅用于原型演示。'
}}
</p>
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
<button type="button" class="footer-button primary" @click="emit('use', template)">
<button
type="button"
class="footer-button primary"
:disabled="template.status !== 'enabled'"
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
@click="emit('use', template)"
>
用此模板发起签署
</button>
</footer>
@@ -194,6 +219,26 @@ const statusLabel = computed(() => {
overflow-y: auto;
}
.preview-state {
display: grid;
min-height: 420px;
color: var(--mut);
font-size: 13px;
place-items: center;
}
.preview-state--error {
color: var(--err);
}
.preview-frame {
display: block;
width: 100%;
min-height: 560px;
background: #fff;
border: 1px solid #eef4f6;
}
.preview-paper {
min-height: 420px;
padding: 26px 34px;
@@ -299,6 +344,14 @@ const statusLabel = computed(() => {
filter: brightness(0.96);
}
.footer-button:disabled {
color: #aebbc0;
background: #f1f5f6;
border-color: #dce5e8;
cursor: not-allowed;
filter: none;
}
@media (max-width: 680px) {
.preview-paper {
padding: 22px 18px;
@@ -40,7 +40,13 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
<div class="template-actions">
<button type="button" class="card-button" @click="emit('preview', template)">预览</button>
<button type="button" class="card-button" @click="emit('edit', template)">编辑</button>
<button type="button" class="card-button primary" @click="emit('initiate', template)">
<button
type="button"
class="card-button primary"
:disabled="template.status !== 'enabled'"
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
@click="emit('initiate', template)"
>
发起
</button>
</div>
@@ -207,6 +213,17 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
background: var(--brand-d);
}
.card-button:disabled {
color: #aebbc0;
background: #f1f5f6;
border-color: #dce5e8;
cursor: not-allowed;
}
.card-button.primary:disabled:hover {
background: #f1f5f6;
}
@media (max-width: 680px) {
.template-footer {
align-items: flex-start;
@@ -0,0 +1,498 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import type { TemplateVersionAction } from '@/api/management/documents'
import type { DocumentTemplate, TemplateEditorForm } from '../types'
const props = defineProps<{
visible: boolean
template: DocumentTemplate | null
campuses: CampusRecord[]
departments: DepartmentRecord[]
saving: boolean
workflowSaving: boolean
}>()
const emit = defineEmits<{
close: []
save: [form: TemplateEditorForm]
workflow: [action: TemplateVersionAction, comment?: string]
}>()
const form = reactive<TemplateEditorForm>(createEmptyForm())
const error = reactive({ message: '' })
const rejectComment = reactive({ value: '' })
const isNew = computed(() => !form.id)
const availableDepartments = computed(() =>
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
)
const workflowActions = computed(() => {
if (!props.template || !form.id) {
return []
}
const actions: Array<{ action: TemplateVersionAction; label: string; tone: string }> = []
if (
props.template.backendStatus === 'DRAFT' ||
props.template.backendStatus === 'REJECTED' ||
(!props.template.backendStatus && props.template.status === 'draft')
) {
actions.push({ action: 'submit-review', label: '提交审核', tone: 'primary' })
}
if (
props.template.backendStatus === 'PENDING_REVIEW' ||
(!props.template.backendStatus && props.template.status === 'review')
) {
actions.push({ action: 'approve', label: '审核通过', tone: 'primary' })
actions.push({ action: 'reject', label: '驳回', tone: 'danger' })
}
if (props.template.backendStatus === 'APPROVED') {
actions.push({ action: 'publish', label: '发布版本', tone: 'primary' })
}
if (
props.template.backendStatus === 'PUBLISHED' ||
(!props.template.backendStatus && props.template.status === 'enabled')
) {
actions.push({ action: 'disable', label: '停用版本', tone: 'secondary' })
actions.push({ action: 'archive', label: '归档版本', tone: 'secondary' })
}
return actions
})
function createEmptyForm(): TemplateEditorForm {
return {
templateCode: '',
name: '',
description: '',
campusId: props.campuses[0]?.id ?? '',
departmentId: '',
category: '知情同意书',
versionNo: '',
contentHtml: '',
}
}
function resetForm() {
if (!props.template) {
Object.assign(form, createEmptyForm())
} else {
Object.assign(form, {
id: props.template.id,
templateCode: props.template.code,
name: props.template.name,
description: props.template.description,
campusId: props.template.campusId ?? props.campuses[0]?.id ?? '',
departmentId: props.template.departmentId ?? '',
category: props.template.category,
versionNo: '',
contentHtml: props.template.contentHtml ?? '',
})
}
error.message = ''
rejectComment.value = ''
}
function handleCampusChange() {
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
form.departmentId = ''
}
}
function submit() {
error.message = ''
if (!form.templateCode.trim()) {
error.message = '请输入模板编号'
return
}
if (!form.name.trim()) {
error.message = '请输入模板名称'
return
}
if (!form.campusId) {
error.message = '请选择院区'
return
}
if (!form.contentHtml.trim()) {
error.message = '请输入模板 HTML 内容'
return
}
emit('save', {
...form,
templateCode: form.templateCode.trim(),
name: form.name.trim(),
description: form.description.trim(),
category: form.category.trim() || '知情同意书',
versionNo: form.versionNo.trim(),
contentHtml: form.contentHtml.trim(),
})
}
function runWorkflow(action: TemplateVersionAction) {
if (action === 'reject' && !rejectComment.value.trim()) {
error.message = '请输入驳回原因'
return
}
error.message = ''
emit('workflow', action, rejectComment.value.trim() || undefined)
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
watch(
() => props.template,
() => {
if (props.visible) {
resetForm()
}
},
)
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
<section class="editor-dialog" role="dialog" aria-modal="true" aria-labelledby="editor-title">
<header class="dialog-header">
<div>
<strong id="editor-title">{{ isNew ? '新增文档模板' : '编辑模板版本' }}</strong>
<p>
{{
isNew
? '创建模板并同时保存首个版本。'
: '编辑操作会创建新的文书版本,已发布版本不会被直接覆盖。'
}}
</p>
</div>
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<form class="dialog-body" @submit.prevent="submit">
<div class="form-grid">
<label>
<span>模板编号 <em>*</em></span>
<input
v-model="form.templateCode"
:disabled="!isNew"
placeholder="如:TPL-RAD-MRI-001"
/>
</label>
<label>
<span>模板名称 <em>*</em></span>
<input v-model="form.name" :disabled="!isNew" placeholder="请输入模板名称" />
</label>
<label>
<span>院区 <em>*</em></span>
<select v-model="form.campusId" :disabled="!isNew" @change="handleCampusChange">
<option value="" disabled>请选择院区</option>
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</label>
<label>
<span>科室</span>
<select v-model="form.departmentId" :disabled="!isNew">
<option value="">全院通用</option>
<option
v-for="department in availableDepartments"
:key="department.id"
:value="department.id"
>
{{ department.name }}
</option>
</select>
</label>
<label>
<span>文档类别</span>
<input v-model="form.category" :disabled="!isNew" placeholder="如:知情同意书" />
</label>
<label>
<span>版本号</span>
<input v-model="form.versionNo" placeholder="如:V1.0,不填由服务端生成" />
</label>
</div>
<label class="full-field">
<span>模板说明</span>
<textarea
v-model="form.description"
:disabled="!isNew"
rows="2"
placeholder="请输入模板说明"
/>
</label>
<label class="full-field">
<span>模板 HTML <em>*</em></span>
<textarea
v-model="form.contentHtml"
rows="11"
placeholder="请输入服务端可渲染的 HTML 内容,不要包含脚本。"
/>
</label>
<p v-if="error.message" class="form-error">{{ error.message }}</p>
<p class="form-tip">
版本工作流由服务端校验权限和状态;此处不会将本地签名或患者数据写入模板内容。
</p>
</form>
<footer class="dialog-footer">
<div v-if="workflowActions.length" class="workflow-actions">
<span>当前版本:{{ template?.version }}</span>
<textarea
v-if="workflowActions.some((item) => item.action === 'reject')"
v-model="rejectComment.value"
rows="1"
placeholder="驳回原因(驳回时必填)"
/>
<button
v-for="item in workflowActions"
:key="item.action"
type="button"
class="footer-button"
:class="item.tone"
:disabled="saving || workflowSaving"
@click="runWorkflow(item.action)"
>
{{ item.label }}
</button>
</div>
<span class="footer-spacer" />
<button type="button" class="footer-button secondary" @click="emit('close')">取消</button>
<button type="button" class="footer-button primary" :disabled="saving" @click="submit">
{{ saving ? '保存中…' : isNew ? '创建模板' : '创建新版本' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 30;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.editor-dialog {
display: flex;
flex-direction: column;
width: 760px;
max-width: 96vw;
max-height: calc(100vh - 32px);
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
}
.dialog-header {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
color: var(--ink);
font-size: 15px;
}
.dialog-header p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12px;
}
.close-button {
flex-shrink: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.close-button:hover {
color: var(--ink);
}
.dialog-body {
min-height: 0;
padding: 16px 18px;
overflow-y: auto;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 14px;
}
.form-grid label,
.full-field {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--mut);
font-size: 12px;
}
.full-field {
margin-top: 12px;
}
label span em {
color: var(--err);
font-style: normal;
}
input,
select,
textarea {
width: 100%;
padding: 8px 10px;
color: var(--ink);
font: inherit;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgb(14 110 140 / 10%);
}
input:disabled,
select:disabled,
textarea:disabled {
color: var(--mut);
background: #f5f8f9;
cursor: not-allowed;
}
.form-error {
margin: 12px 0 0;
color: var(--err);
font-size: 12px;
}
.form-tip {
margin: 12px 0 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.6;
}
.dialog-footer {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.workflow-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
color: var(--mut);
font-size: 11.5px;
}
.workflow-actions textarea {
width: 170px;
min-height: 30px;
padding: 5px 8px;
}
.footer-spacer {
flex: 1;
}
.footer-button {
padding: 7px 13px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
border-radius: 7px;
}
.footer-button:disabled {
cursor: wait;
opacity: 0.55;
}
.footer-button.primary {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.footer-button.secondary {
color: var(--brand);
background: #fff;
border-color: var(--brand);
}
.footer-button.danger {
color: var(--err);
background: var(--err-l);
border-color: rgb(194 73 73 / 25%);
}
.footer-button:hover:not(:disabled) {
filter: brightness(0.96);
}
@media (max-width: 680px) {
.form-grid {
grid-template-columns: 1fr;
}
.footer-spacer {
display: none;
}
}
</style>
@@ -2,6 +2,18 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getCampuses, getDepartments } from '@/api/management/organization'
import {
createTemplate,
createTemplateVersion,
getDocumentDetail,
getDocuments,
getTemplateVersionDetail,
isDocumentMockEnabled,
updateTemplateVersionStatus,
} from '@/api/management/documents'
import type { TemplateVersionAction } from '@/api/management/documents'
import type { CampusRecord, DepartmentRecord, DocumentRecord } from '@/api/management/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import type { SigningTemplate } from '@/api/workbench/types'
@@ -9,20 +21,53 @@ import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
import TemplateEditorDialog from './components/TemplateEditorDialog.vue'
import { documentLibrary, documentStatusOptions } from './mock'
import type {
DepartmentTab,
DocumentStatus,
DocumentDepartment,
DocumentFilterForm,
DocumentTemplate,
StatusTab,
TemplateEditorForm,
} from './types'
const loading = ref(true)
const library = ref<DocumentDepartment[]>([])
const selectedTemplate = ref<DocumentTemplate | null>(null)
const signingTemplate = ref<SigningTemplate | null>(null)
const editorTemplate = ref<DocumentTemplate | null>(null)
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
const signingDialogVisible = ref(false)
const editorVisible = ref(false)
const editorSaving = ref(false)
const workflowSaving = ref(false)
const previewLoading = ref(false)
const previewError = ref(false)
let previewRequestId = 0
const mockCampuses: CampusRecord[] = [
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
{ id: 'mock-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
]
const mockDepartments: DepartmentRecord[] = [
'放射科',
'心内科',
'骨科',
'健康管理中心',
'全院通用',
].map((name, index) => ({
id: `mock-template-department-${index + 1}`,
campusId: index === 3 ? 'mock-east' : 'mock-main',
code: `MOCK-TPL-${index + 1}`,
name,
status: 'ENABLED',
sortNo: index + 1,
}))
const filters = reactive<DocumentFilterForm>({
keyword: '',
@@ -76,39 +121,362 @@ const filteredTemplates = computed(() => {
async function loadLibrary() {
loading.value = true
await Promise.resolve()
library.value = documentLibrary
loading.value = false
try {
if (isDocumentMockEnabled) {
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
departments.value = mockDepartments.map((department) => ({ ...department }))
library.value = documentLibrary.map((department) => ({
...department,
categories: department.categories.map((category) => ({
...category,
documents: category.documents.map((document) => ({ ...document })),
})),
}))
return
}
const [documentsResult, campusesResult, departmentsResult] = await Promise.allSettled([
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
getCampuses(),
getDepartments(),
])
if (documentsResult.status === 'rejected') {
throw documentsResult.reason
}
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
library.value = buildDocumentLibrary(
documentsResult.value.records,
new Map(departments.value.map((department) => [department.id, department.name])),
new Map(campuses.value.map((campus) => [campus.id, campus.name])),
)
} catch {
library.value = []
ElMessage.error('文档模板加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function handlePreview(template: DocumentTemplate) {
function mapDocumentStatus(status: DocumentRecord['status']): DocumentStatus {
if (status === 'published') {
return 'enabled'
}
if (status === 'archived') {
return 'archived'
}
if (status === 'review') {
return 'review'
}
return 'draft'
}
function buildDocumentLibrary(
records: DocumentRecord[],
departmentNames = new Map<string, string>(),
campusNames = new Map<string, string>(),
): DocumentDepartment[] {
const departments = new Map<string, DocumentDepartment>()
records.forEach((record) => {
const departmentName =
record.departmentName || departmentNames.get(record.departmentId ?? '') || '全院通用'
const departmentKey = record.departmentId || departmentName
const campusName = record.campusName || campusNames.get(record.campusId ?? '')
const department = departments.get(departmentKey) ?? {
key: departmentKey,
name: departmentName,
campus:
campusName === '本部院区' || campusName === '东院区' || campusName === '西院区'
? campusName
: 'all',
categories: [],
}
const category =
department.categories.find((item) => item.name === record.category) ??
(() => {
const nextCategory = { name: record.category, documents: [] }
department.categories.push(nextCategory)
return nextCategory
})()
category.documents.push({
id: record.id,
name: record.name,
code: record.code ?? record.id,
version: record.version,
status: mapDocumentStatus(record.status),
backendStatus: record.backendStatus,
description: record.description ?? '',
versionId: record.versionId,
departmentId: record.departmentId,
campusId: record.campusId,
contentHtml: record.contentHtml,
contentSha256: record.contentSha256,
})
departments.set(departmentKey, department)
})
return [...departments.values()]
}
async function handlePreview(template: DocumentTemplate) {
selectedTemplate.value = template
previewError.value = false
if (isDocumentMockEnabled) {
previewLoading.value = false
return
}
const requestId = ++previewRequestId
previewLoading.value = true
try {
const detail = await getDocumentDetail(template.id)
if (!detail) {
throw new Error('模板不存在')
}
let contentHtml = detail.contentHtml
const versionId = detail.versionId ?? template.versionId
if (!contentHtml && versionId) {
const version = await getTemplateVersionDetail(template.id, versionId)
contentHtml = version?.contentHtml ?? version?.content ?? undefined
}
if (requestId !== previewRequestId) {
return
}
selectedTemplate.value = {
...template,
code: detail.code ?? template.code,
version: detail.version || template.version,
versionId,
description: detail.description ?? template.description,
contentHtml,
contentSha256: detail.contentSha256,
}
} catch {
if (requestId === previewRequestId) {
previewError.value = true
ElMessage.error('模板内容加载失败,请稍后重试')
}
} finally {
if (requestId === previewRequestId) {
previewLoading.value = false
}
}
}
function closePreview() {
previewRequestId += 1
selectedTemplate.value = null
previewLoading.value = false
previewError.value = false
}
function showAddMessage() {
ElMessage.info('原型演示:新增文档模板(类 Word 在线编辑器)')
editorTemplate.value = null
editorVisible.value = true
}
function showExportMessage() {
ElMessage.info('原型演示:导出模板包(ZIP)')
}
function showEditMessage(template: DocumentTemplate) {
ElMessage.info('原型演示:编辑模板《' + template.name + '》')
async function showEditMessage(template: DocumentTemplate) {
editorTemplate.value = template
editorVisible.value = true
if (isDocumentMockEnabled || template.contentHtml) {
return
}
try {
const detail = await getDocumentDetail(template.id)
const versionId = detail?.versionId ?? template.versionId
let contentHtml = detail?.contentHtml
if (!contentHtml && versionId) {
const version = await getTemplateVersionDetail(template.id, versionId)
contentHtml = version?.contentHtml ?? version?.content ?? undefined
}
if (editorVisible.value && editorTemplate.value?.id === template.id) {
editorTemplate.value = {
...template,
versionId,
description: detail?.description ?? template.description,
contentHtml,
}
}
} catch {
ElMessage.warning('模板内容加载失败,可先关闭后重试')
}
}
function closeEditor() {
editorVisible.value = false
editorTemplate.value = null
}
function addMockTemplate(form: TemplateEditorForm) {
const department = departments.value.find((item) => item.id === form.departmentId)
const departmentName = department?.name ?? '全院通用'
const departmentKey = department?.id ?? 'mock-general'
let departmentGroup = library.value.find((item) => item.key === departmentKey)
if (!departmentGroup) {
departmentGroup = {
key: departmentKey,
name: departmentName,
campus: 'all',
categories: [],
}
library.value.push(departmentGroup)
}
let category = departmentGroup.categories.find((item) => item.name === form.category)
if (!category) {
category = { name: form.category, documents: [] }
departmentGroup.categories.push(category)
}
const timestamp = Date.now()
category.documents.push({
id: `mock-template-${timestamp}`,
name: form.name,
code: form.templateCode,
version: form.versionNo || 'v1',
status: 'draft',
description: form.description,
versionId: `mock-version-${timestamp}`,
departmentId: form.departmentId || null,
campusId: form.campusId,
contentHtml: form.contentHtml,
})
}
async function saveTemplate(form: TemplateEditorForm) {
editorSaving.value = true
try {
if (isDocumentMockEnabled) {
if (form.id) {
library.value.forEach((department) =>
department.categories.forEach((category) =>
category.documents.forEach((document) => {
if (document.id === form.id) {
document.version = form.versionNo || document.version
document.contentHtml = form.contentHtml
document.description = form.description
}
}),
),
)
} else {
addMockTemplate(form)
}
} else if (form.id) {
await createTemplateVersion(form.id, {
versionNo: form.versionNo || undefined,
contentHtml: form.contentHtml,
})
await loadLibrary()
} else {
const template = await createTemplate({
templateCode: form.templateCode,
name: form.name,
description: form.description || undefined,
campusId: form.campusId,
departmentId: form.departmentId || null,
category: form.category || undefined,
})
await createTemplateVersion(template.id, {
versionNo: form.versionNo || undefined,
contentHtml: form.contentHtml,
})
await loadLibrary()
}
closeEditor()
ElMessage.success(form.id ? '模板新版本已保存' : '模板已创建')
} catch {
ElMessage.error(form.id ? '模板版本保存失败,请稍后重试' : '模板创建失败,请稍后重试')
} finally {
editorSaving.value = false
}
}
function updateMockTemplateStatus(templateId: string, action: TemplateVersionAction) {
const nextStatus =
action === 'submit-review' || action === 'reject'
? 'review'
: action === 'disable' || action === 'archive'
? 'archived'
: 'enabled'
library.value.forEach((department) =>
department.categories.forEach((category) =>
category.documents.forEach((document) => {
if (document.id === templateId) {
document.status = nextStatus
}
}),
),
)
}
async function handleWorkflow(action: TemplateVersionAction, comment?: string) {
const template = editorTemplate.value
const versionId = template?.versionId
if (!template || (!versionId && !isDocumentMockEnabled)) {
ElMessage.error('当前模板没有可操作的版本')
return
}
workflowSaving.value = true
try {
if (isDocumentMockEnabled) {
updateMockTemplateStatus(template.id, action)
} else if (versionId) {
await updateTemplateVersionStatus(template.id, versionId, action, comment)
await loadLibrary()
} else {
throw new Error('当前模板没有可操作的版本')
}
closeEditor()
ElMessage.success('模板版本状态已更新')
} catch {
ElMessage.error('模板版本操作失败,请稍后重试')
} finally {
workflowSaving.value = false
}
}
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
return {
id: template.id,
versionId: template.id,
versionId: template.versionId ?? template.id,
name: template.name,
code: template.code,
version: template.version,
department: template.department,
departmentId: template.departmentId,
campusId: template.campusId ?? undefined,
category: template.category,
description: template.description,
supportedMethods: ['pad', 'sms'],
@@ -191,6 +559,8 @@ onMounted(loadLibrary)
<DocumentPreviewDialog
:visible="Boolean(selectedTemplate)"
:template="selectedTemplate"
:loading="previewLoading"
:error="previewError"
@close="closePreview"
@use="useTemplate"
/>
@@ -199,6 +569,17 @@ onMounted(loadLibrary)
:initial-template="signingTemplate"
@close="closeSigningDialog"
/>
<TemplateEditorDialog
:visible="editorVisible"
:template="editorTemplate"
:campuses="campuses"
:departments="departments"
:saving="editorSaving"
:workflow-saving="workflowSaving"
@close="closeEditor"
@save="saveTemplate"
@workflow="handleWorkflow"
/>
</section>
</template>
@@ -10,7 +10,13 @@ export interface LibraryDocument {
code: string
version: string
status: DocumentStatus
backendStatus?: string
description: string
versionId?: string
departmentId?: string | null
campusId?: string | null
contentHtml?: string
contentSha256?: string
}
export interface DocumentCategory {
@@ -31,6 +37,18 @@ export interface DocumentTemplate extends LibraryDocument {
category: string
}
export interface TemplateEditorForm {
id?: string
templateCode: string
name: string
description: string
campusId: string
departmentId: string
category: string
versionNo: string
contentHtml: string
}
export interface DocumentFilterForm {
keyword: string
department: string | null