feat(文档库): 打通模板正文真实模式并新增渲染验证
模板列表接口不返回正文,展示、编辑与发起签署前需再取版本详情获取 contentHtml, 为此新增 getTemplateContent、createTemplateWithContent、updateTemplateContent 与 deleteTemplate(归档),并在签署端按 contentSha256 缓存正文快照。 同时新增 scripts/login.cjs 与 scripts/verify-pages.cjs,通过真实登录与 CDP 渲染截图、断言关键内容并收集运行时错误,并补充 .gitignore、eslint 忽略与文档说明。
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
total: number
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
total: number
|
||||
/** 只在演示模式下展示「重置演示数据」:真实环境不该出现这个自测入口 */
|
||||
showReset?: boolean
|
||||
}>(),
|
||||
{ showReset: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
@@ -28,7 +33,7 @@ function handleInput(event: Event) {
|
||||
|
||||
<div class="library-tools">
|
||||
<input
|
||||
:value="props.modelValue"
|
||||
:value="modelValue"
|
||||
class="library-search"
|
||||
type="search"
|
||||
placeholder="搜索模板名称 / 编号 / 科室"
|
||||
@@ -43,6 +48,7 @@ function handleInput(event: Event) {
|
||||
导出模板包
|
||||
</button>
|
||||
<button
|
||||
v-if="showReset"
|
||||
type="button"
|
||||
class="tool-button ghost"
|
||||
title="清除本地保存的演示文书与签署任务"
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
buildSigningDocumentHtml,
|
||||
describeFieldProgress,
|
||||
readAnswersFromRoot,
|
||||
type SigningDocumentData,
|
||||
type SigningFieldProgress,
|
||||
} from '@/utils/signing-document'
|
||||
|
||||
import { documentStatusLabels, signingPreviewPatient } from '../mock'
|
||||
import { documentStatusLabels } from '../mock'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -23,6 +24,24 @@ const emit = defineEmits<{
|
||||
use: [template: DocumentTemplate]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 签署预览用的示例患者快照(脱敏演示数据)。
|
||||
*
|
||||
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;
|
||||
* 真实签署仍以任务创建时的患者/就诊快照为准。
|
||||
* 这份数据只服务于「签署预览」这个演示页签,因此留在组件内,
|
||||
* 不再从页面 mock.ts 导出——那会让"演示数据"渗透到文书库的其它部分。
|
||||
*/
|
||||
const previewPatient: SigningDocumentData = {
|
||||
patientName: '李某某',
|
||||
sex: '男',
|
||||
age: 46,
|
||||
visitNo: 'V20260914001',
|
||||
visitDate: '2026-09-14',
|
||||
phone: '138****5678',
|
||||
signedAt: '',
|
||||
}
|
||||
|
||||
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
|
||||
const activeTab = ref<'content' | 'signing'>('content')
|
||||
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
|
||||
@@ -63,6 +82,13 @@ const statusLabel = computed(() => {
|
||||
return documentStatusLabels[props.template.status]
|
||||
})
|
||||
|
||||
/** 与文书卡片保持同一口径:草稿与已归档不可发起签署 */
|
||||
const canInitiate = computed(
|
||||
() =>
|
||||
Boolean(props.template?.contentHtml) &&
|
||||
(props.template?.status === 'published' || props.template?.status === 'review'),
|
||||
)
|
||||
|
||||
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
|
||||
// Word 自维护文书存的是编辑器产出的 HTML 片段,需要叠加文书基础渲染样式
|
||||
// (表格边框、正文字体等),使导入路径的"裸表格"等语义结构也有合理的文书外观。
|
||||
@@ -116,7 +142,7 @@ const previewSrcDoc = computed(() => {
|
||||
// 患者/就诊数据用示例快照。interactive 传 true,科室可以在预览里试勾选、确认交互效果。
|
||||
// 这里不用 iframe:需要读取勾选结果做汇总,iframe 的 sandbox 也不便于表单交互。
|
||||
const signingPreviewHtml = computed(() =>
|
||||
buildSigningDocumentHtml(props.template?.contentHtml ?? '', signingPreviewPatient, true),
|
||||
buildSigningDocumentHtml(props.template?.contentHtml ?? '', previewPatient, true),
|
||||
)
|
||||
|
||||
// 填写项与勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
|
||||
@@ -192,7 +218,7 @@ function syncPreviewFields() {
|
||||
</div>
|
||||
<template v-else-if="template.contentHtml && activeTab === 'signing'">
|
||||
<p class="preview-note">
|
||||
按签署时效果渲染:占位符已用示例患者「{{ signingPreviewPatient.patientName }}」填充,
|
||||
按签署时效果渲染:占位符已用示例患者「{{ previewPatient.patientName }}」填充,
|
||||
表头含「勾选」的表格已转为勾选框,可直接勾选确认交互效果(预览不保存勾选结果)。
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||
@@ -264,8 +290,8 @@ function syncPreviewFields() {
|
||||
<button
|
||||
type="button"
|
||||
class="footer-button primary"
|
||||
:disabled="template.status !== 'enabled'"
|
||||
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
|
||||
:disabled="!canInitiate"
|
||||
:title="canInitiate ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署'"
|
||||
@click="emit('use', template)"
|
||||
>
|
||||
用此模板发起签署
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { documentStatusLabels } from '../mock'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
import { documentStatusLabels } from '../mock'
|
||||
|
||||
const props = defineProps<{
|
||||
template: DocumentTemplate
|
||||
@@ -16,6 +16,15 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
|
||||
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
|
||||
const canInitiate = computed(
|
||||
() => props.template.status === 'published' || props.template.status === 'review',
|
||||
)
|
||||
|
||||
const initiateTitle = computed(() =>
|
||||
canInitiate.value ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,8 +53,8 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
<button
|
||||
type="button"
|
||||
class="card-button primary"
|
||||
:disabled="template.status !== 'enabled'"
|
||||
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
|
||||
:disabled="!canInitiate"
|
||||
:title="initiateTitle"
|
||||
@click="emit('initiate', template)"
|
||||
>
|
||||
发起
|
||||
@@ -124,7 +133,7 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
.status-published {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,25 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||
import {
|
||||
createTemplateWithContent,
|
||||
deleteTemplate,
|
||||
getDocuments,
|
||||
getTemplateContent,
|
||||
updateTemplate,
|
||||
updateTemplateContent,
|
||||
} from '@/api/management/documents'
|
||||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||
import type {
|
||||
CampusRecord,
|
||||
CreateTemplateRequest,
|
||||
DocumentRecord,
|
||||
DepartmentRecord,
|
||||
} from '@/api/management/types'
|
||||
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
||||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { demoCampuses, demoDepartments } from '@/utils/mock-document-library'
|
||||
import { mockFlags } from '@/utils/mock-flags'
|
||||
import { clearMockState } from '@/utils/mock-storage'
|
||||
|
||||
@@ -24,37 +40,38 @@ import type {
|
||||
TemplateEditorForm,
|
||||
} from './types'
|
||||
|
||||
// 原型阶段:文档库数据与文书内容全部来自本地真实附件(mock.ts + contents.ts),暂不调用后端接口。
|
||||
// 后续接入时,仅需将 loadLibrary / saveTemplate / handleWorkflow 中的本地数据操作替换为文档模板接口。
|
||||
// 是否使用本地演示库由 VITE_MOCK_DOCUMENTS 控制,缺省开启。
|
||||
|
||||
/**
|
||||
* 文档库数据来源。
|
||||
*
|
||||
* - 演示模式(VITE_MOCK_DOCUMENTS 开启,缺省开启):读本地演示文书库,可随意增删改;
|
||||
* - 真实模式:读模板接口。列表只给模板元数据(含 currentVersionId),
|
||||
* 正文要再取一次版本详情——这是接入时最容易被忽略的一步,
|
||||
* 缺了它文档库会显示一堆没有正文、无法预览的"空壳模板"。
|
||||
*
|
||||
* 两种模式共用同一套页面结构:模板按 科室 → 分类 → 文书 三级分组渲染,
|
||||
* 因此真实模式也需要把扁平的模板列表组织成同样的树。
|
||||
*/
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const useMockLibrary = mockFlags.documents
|
||||
/** 关掉演示库后,文书库本应改由模板接口提供,但该路径尚未实现:如实提示,不静默沿用本地数据 */
|
||||
const apiLibraryPending = ref(false)
|
||||
/** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
|
||||
const libraryLoading = ref(false)
|
||||
const libraryError = ref('')
|
||||
|
||||
const library = ref<DocumentDepartment[]>([])
|
||||
const selectedTemplate = ref<DocumentTemplate | null>(null)
|
||||
const signingTemplate = ref<SigningTemplate | null>(null)
|
||||
const wordEditingTemplate = ref<DocumentTemplate | null>(null)
|
||||
const wordEditorVisible = ref(false)
|
||||
const signingDialogVisible = ref(false)
|
||||
const wordEditorLoading = ref(false)
|
||||
/** 编辑已有文书时需要先把正文取回来,取正文期间弹窗展示加载态 */
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref(false)
|
||||
|
||||
const campuses: CampusRecord[] = [
|
||||
{ id: 'campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||||
{ id: 'campus-fengxian', code: 'FENGXIAN', name: '奉贤院区', status: 'ENABLED', sortNo: 2 },
|
||||
]
|
||||
|
||||
const departments: DepartmentRecord[] = ['医学影像科', '医学检验科', '临床遗传中心'].map(
|
||||
(name, index) => ({
|
||||
id: `template-department-${index + 1}`,
|
||||
campusId: 'campus-main',
|
||||
code: `TPL-DEPT-${index + 1}`,
|
||||
name,
|
||||
status: 'ENABLED',
|
||||
sortNo: index + 1,
|
||||
}),
|
||||
)
|
||||
const campuses = ref<CampusRecord[]>([])
|
||||
const departments = ref<DepartmentRecord[]>([])
|
||||
|
||||
const filters = reactive<DocumentFilterForm>({
|
||||
keyword: '',
|
||||
@@ -98,8 +115,7 @@ const filteredTemplates = computed(() => {
|
||||
|
||||
return allTemplates.value.filter((template) => {
|
||||
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
||||
const matchesStatus =
|
||||
filters.status === 'all' || template.status === filters.status
|
||||
const matchesStatus = filters.status === 'all' || template.status === filters.status
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
||||
@@ -118,14 +134,136 @@ function cloneLibrary(source: DocumentDepartment[]): DocumentDepartment[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function loadLibrary() {
|
||||
if (!useMockLibrary) {
|
||||
library.value = []
|
||||
apiLibraryPending.value = true
|
||||
/**
|
||||
* 把扁平的模板列表组织成页面需要的 科室 → 分类 → 文书 树。
|
||||
*
|
||||
* 分组键用 departmentId(真实模式)或科室名(未指定科室的模板归"全院通用"),
|
||||
* 这样同一科室的模板无论接口返回顺序如何都会落进同一个分组。
|
||||
*/
|
||||
function groupDocuments(records: DocumentRecord[]): DocumentDepartment[] {
|
||||
const departments = new Map<string, DocumentDepartment>()
|
||||
|
||||
records.forEach((record) => {
|
||||
const departmentKey = record.departmentId ?? '__all__'
|
||||
const departmentName =
|
||||
record.departmentName ?? (record.departmentId ? '未指定科室' : '全院通用')
|
||||
|
||||
let department = departments.get(departmentKey)
|
||||
|
||||
if (!department) {
|
||||
department = { key: departmentKey, name: departmentName, campus: 'all', categories: [] }
|
||||
departments.set(departmentKey, department)
|
||||
}
|
||||
|
||||
const categoryName = record.category || '未分类'
|
||||
let category = department.categories.find((item) => item.name === categoryName)
|
||||
|
||||
if (!category) {
|
||||
category = { name: categoryName, documents: [] }
|
||||
department.categories.push(category)
|
||||
}
|
||||
|
||||
category.documents.push(record)
|
||||
})
|
||||
|
||||
// 全院通用的模板排最前,其余按科室名排序,保证每次进页面分组顺序稳定
|
||||
return [...departments.values()].sort((left, right) => {
|
||||
if (left.key === '__all__') {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (right.key === '__all__') {
|
||||
return 1
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取权限范围内的全部模板。
|
||||
* 权限范围内模板可能超过一页,这里翻完所有页;不翻页会静默漏掉后面的模板,
|
||||
* 表现为"文档库里少了几份文书",很难排查。
|
||||
*/
|
||||
async function fetchAllDocuments(): Promise<DocumentRecord[]> {
|
||||
const pageSize = 200
|
||||
const firstPage = await getDocuments({
|
||||
page: 1,
|
||||
pageSize,
|
||||
status: 'all',
|
||||
campusId: appStore.selectedCampusId || undefined,
|
||||
})
|
||||
const effectivePageSize = Math.max(firstPage.pageSize, 1)
|
||||
const pageCount = Math.ceil(firstPage.total / effectivePageSize)
|
||||
|
||||
if (pageCount <= 1) {
|
||||
return firstPage.records
|
||||
}
|
||||
|
||||
const remaining = await Promise.all(
|
||||
Array.from({ length: pageCount - 1 }, (_, index) =>
|
||||
getDocuments({
|
||||
page: index + 2,
|
||||
pageSize: effectivePageSize,
|
||||
status: 'all',
|
||||
campusId: appStore.selectedCampusId || undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return [firstPage, ...remaining].flatMap((page) => page.records)
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
if (useMockLibrary) {
|
||||
libraryError.value = ''
|
||||
library.value = cloneLibrary(loadDocumentLibrary())
|
||||
return
|
||||
}
|
||||
|
||||
library.value = cloneLibrary(loadDocumentLibrary())
|
||||
libraryLoading.value = true
|
||||
libraryError.value = ''
|
||||
|
||||
try {
|
||||
const records = await fetchAllDocuments()
|
||||
library.value = groupDocuments(records)
|
||||
} catch {
|
||||
library.value = []
|
||||
libraryError.value = '文书库加载失败,请确认后端模板接口可访问后重试'
|
||||
} finally {
|
||||
libraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取组织字典。
|
||||
* 真实模式取真实院区/科室;演示模式用演示字典,键名与接口同构,
|
||||
* 因此「所属院区 / 所属科室」下拉在两种模式下走同一段模板代码。
|
||||
*
|
||||
* 之前这里写死了三个科室(id 形如 template-department-1,不是后端 UUID),
|
||||
* 这些 id 会被写进文库、再被当 departmentId 发给后端,接真实接口必然报错。
|
||||
*/
|
||||
async function loadOrganizationOptions() {
|
||||
if (useMockLibrary) {
|
||||
campuses.value = demoCampuses.map((campus) => ({ ...campus }))
|
||||
departments.value = demoDepartments.map((department) => ({
|
||||
...department,
|
||||
campusId: campuses.value[0]?.id ?? '',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const [campusesResult, departmentsResult] = await Promise.allSettled([
|
||||
getCampuses(),
|
||||
getDepartments(),
|
||||
])
|
||||
|
||||
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||||
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
|
||||
|
||||
if (campusesResult.status === 'rejected') {
|
||||
ElMessage.error('院区字典加载失败,新增文书时无法选择所属院区')
|
||||
}
|
||||
}
|
||||
|
||||
// 演示数据落本地:刷新后文书库还在,不用每次重新建文书。
|
||||
@@ -133,19 +271,52 @@ function loadLibrary() {
|
||||
watch(
|
||||
library,
|
||||
(value) => {
|
||||
if (useMockLibrary) {
|
||||
if (useMockLibrary && !libraryLoading.value) {
|
||||
saveDocumentLibrary(value)
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function handlePreview(template: DocumentTemplate) {
|
||||
selectedTemplate.value = template
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
selectedTemplate.value = null
|
||||
previewError.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览模板。
|
||||
* 真实模式的列表接口不返回正文,这里按需取回再打开预览;
|
||||
* 取不到正文时如实报错,让科室知道是接口/数据问题,而不是看到一个空预览。
|
||||
*/
|
||||
async function handlePreview(template: DocumentTemplate) {
|
||||
selectedTemplate.value = template
|
||||
|
||||
if (useMockLibrary || template.contentHtml) {
|
||||
previewLoading.value = false
|
||||
previewError.value = false
|
||||
return
|
||||
}
|
||||
|
||||
previewLoading.value = true
|
||||
previewError.value = false
|
||||
|
||||
try {
|
||||
const contentHtml = await getTemplateContent(template.id)
|
||||
|
||||
// 用户在等待期间可能已经切到别的文书,回填前确认仍是同一份
|
||||
if (selectedTemplate.value?.id === template.id) {
|
||||
selectedTemplate.value = { ...template, contentHtml }
|
||||
previewError.value = !contentHtml
|
||||
}
|
||||
} catch {
|
||||
if (selectedTemplate.value?.id === template.id) {
|
||||
previewError.value = true
|
||||
}
|
||||
} finally {
|
||||
if (selectedTemplate.value?.id === template.id) {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showAddMessage() {
|
||||
@@ -155,13 +326,7 @@ function showAddMessage() {
|
||||
}
|
||||
|
||||
function showExportMessage() {
|
||||
ElMessage.info('原型演示:导出模板包(ZIP)')
|
||||
}
|
||||
|
||||
function showEditMessage(template: DocumentTemplate) {
|
||||
// Word 自维护方案:所有文书统一在 Word 编辑器中维护。
|
||||
wordEditingTemplate.value = template
|
||||
wordEditorVisible.value = true
|
||||
ElMessage.info('导出模板包待接入:需要后端提供模板包导出接口')
|
||||
}
|
||||
|
||||
function closeWordEditor() {
|
||||
@@ -169,10 +334,44 @@ function closeWordEditor() {
|
||||
wordEditingTemplate.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑已有文书。
|
||||
* 真实模式必须先取回正文再打开编辑器——列表接口不返回正文,
|
||||
* 直接用列表里的空 contentHtml 打开编辑器,科室会看到"文书内容为空",
|
||||
* 保存后还会把模板正文冲掉。
|
||||
*/
|
||||
async function showEditMessage(template: DocumentTemplate) {
|
||||
if (useMockLibrary || template.contentHtml) {
|
||||
wordEditingTemplate.value = template
|
||||
wordEditorVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
wordEditorLoading.value = true
|
||||
|
||||
try {
|
||||
const contentHtml = await getTemplateContent(template.id)
|
||||
|
||||
if (!contentHtml) {
|
||||
ElMessage.error('未能取到该文书的正文内容,请确认模板版本内容接口可访问')
|
||||
return
|
||||
}
|
||||
|
||||
wordEditingTemplate.value = { ...template, contentHtml }
|
||||
wordEditorVisible.value = true
|
||||
} catch {
|
||||
ElMessage.error('文书正文加载失败,请稍后重试')
|
||||
} finally {
|
||||
wordEditorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 演示模式:把新文书追加进本地文库(分组按科室名匹配已有分组) */
|
||||
function addLocalTemplate(form: TemplateEditorForm) {
|
||||
const department = departments.find((item) => item.id === form.departmentId)
|
||||
const department = departments.value.find((item) => item.id === form.departmentId)
|
||||
const departmentName = department?.name ?? '全院通用'
|
||||
const departmentKey = department?.id ?? 'local-general'
|
||||
// 真实模式用 departmentId 当分组键,演示模式没有 UUID,用品名保持同样语义
|
||||
const departmentKey = department?.id ?? '__all__'
|
||||
let departmentGroup = library.value.find((item) => item.key === departmentKey)
|
||||
|
||||
if (!departmentGroup) {
|
||||
@@ -198,43 +397,109 @@ function addLocalTemplate(form: TemplateEditorForm) {
|
||||
name: form.name,
|
||||
code: form.templateCode,
|
||||
version: form.versionNo || 'v1',
|
||||
// 原型闭环:新文书直接启用(工作流 UI 待签署端接入后恢复),保证创建后即可预览/发起。
|
||||
status: 'enabled',
|
||||
// 演示模式直接落成已发布:演示库没有审核流,落在草稿会没法发起签署
|
||||
status: 'published',
|
||||
backendStatus: 'PUBLISHED',
|
||||
updatedBy: '本地演示',
|
||||
updatedAt: new Date().toISOString(),
|
||||
category: form.category,
|
||||
description: form.description,
|
||||
versionId: `local-version-${timestamp}`,
|
||||
departmentId: form.departmentId || null,
|
||||
departmentName,
|
||||
campusId: form.campusId,
|
||||
contentHtml: form.contentHtml,
|
||||
})
|
||||
}
|
||||
|
||||
function saveTemplate(form: TemplateEditorForm) {
|
||||
if (form.id) {
|
||||
library.value.forEach((department) =>
|
||||
department.categories.forEach((category) =>
|
||||
category.documents.forEach((document) => {
|
||||
if (document.id === form.id) {
|
||||
document.name = form.name || document.name
|
||||
document.version = form.versionNo || document.version
|
||||
document.contentHtml = form.contentHtml
|
||||
document.description = form.description
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
addLocalTemplate(form)
|
||||
}
|
||||
|
||||
closeWordEditor()
|
||||
ElMessage.success(form.id ? '文书已更新' : '文书已创建')
|
||||
/** 演示模式:就地更新已有文书(只改可改字段,科室归属与编号不动) */
|
||||
function updateLocalTemplate(form: TemplateEditorForm) {
|
||||
library.value.forEach((department) =>
|
||||
department.categories.forEach((category) =>
|
||||
category.documents.forEach((document) => {
|
||||
if (document.id === form.id) {
|
||||
document.name = form.name || document.name
|
||||
document.version = form.versionNo || document.version
|
||||
document.contentHtml = form.contentHtml
|
||||
document.description = form.description
|
||||
document.updatedAt = new Date().toISOString()
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function toCreatePayload(form: TemplateEditorForm): CreateTemplateRequest {
|
||||
return {
|
||||
templateCode: form.templateCode,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
campusId: form.campusId,
|
||||
departmentId: form.departmentId || null,
|
||||
category: form.category,
|
||||
}
|
||||
}
|
||||
|
||||
function toVersionPayload(form: TemplateEditorForm) {
|
||||
return {
|
||||
versionNo: form.versionNo,
|
||||
contentHtml: form.contentHtml,
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate(form: TemplateEditorForm) {
|
||||
if (useMockLibrary) {
|
||||
if (form.id) {
|
||||
updateLocalTemplate(form)
|
||||
} else {
|
||||
addLocalTemplate(form)
|
||||
}
|
||||
|
||||
closeWordEditor()
|
||||
ElMessage.success(form.id ? '文书已更新' : '文书已创建')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (form.id) {
|
||||
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上;
|
||||
// 新版本默认是草稿,这里顺带发布,保证编辑完就能直接发起签署
|
||||
await updateTemplateContent(form.id, toVersionPayload(form))
|
||||
await updateTemplate(form.id, {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
category: form.category,
|
||||
})
|
||||
ElMessage.success('文书已更新并发布新版本')
|
||||
} else {
|
||||
await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
|
||||
ElMessage.success('文书已创建')
|
||||
}
|
||||
|
||||
closeWordEditor()
|
||||
await loadLibrary()
|
||||
} catch {
|
||||
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文书。
|
||||
* 模板没有物理删除接口:它可能已被历史签署任务引用,删掉会让留痕链断裂。
|
||||
* 因此「删除」在这里表达为归档版本(后端 archive 动作),
|
||||
* 语义上等于下架,已签署文书仍可追溯。
|
||||
*/
|
||||
async function removeTemplate(template: DocumentTemplate) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确定删除文书「${template.name}」?删除后不可恢复。`,
|
||||
'删除文书',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
useMockLibrary
|
||||
? `确定删除文书「${template.name}」?删除后不可恢复。`
|
||||
: `确定下架文书「${template.name}」?下架后不能再用于发起签署,已签署的记录不受影响。`,
|
||||
useMockLibrary ? '删除文书' : '下架文书',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: useMockLibrary ? '删除' : '下架',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
@@ -243,45 +508,83 @@ async function removeTemplate(template: DocumentTemplate) {
|
||||
return
|
||||
}
|
||||
|
||||
// 移除文书后,顺带清理空分类与空科室分组,避免留下空壳节点。
|
||||
library.value = library.value
|
||||
.map((department) => ({
|
||||
...department,
|
||||
categories: department.categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
documents: category.documents.filter((document) => document.id !== template.id),
|
||||
}))
|
||||
.filter((category) => category.documents.length > 0),
|
||||
}))
|
||||
.filter((department) => department.categories.length > 0)
|
||||
if (useMockLibrary) {
|
||||
// 移除文书后,顺带清理空分类与空科室分组,避免留下空壳节点
|
||||
library.value = library.value
|
||||
.map((department) => ({
|
||||
...department,
|
||||
categories: department.categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
documents: category.documents.filter((document) => document.id !== template.id),
|
||||
}))
|
||||
.filter((category) => category.documents.length > 0),
|
||||
}))
|
||||
.filter((department) => department.categories.length > 0)
|
||||
|
||||
ElMessage.success('文书已删除')
|
||||
ElMessage.success('文书已删除')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTemplate(template.id)
|
||||
ElMessage.success('文书已下架')
|
||||
await loadLibrary()
|
||||
} catch {
|
||||
ElMessage.error('文书下架失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 版本工作流(提交审核/发布等)随原模板编辑器移除,待签署端接入后按后端工作流接口恢复。
|
||||
/**
|
||||
* 把文书转成签署模板。
|
||||
*
|
||||
* 转之前要确保有正文:真实模式的列表接口不返回正文,缺正文时发起签署会签出一份
|
||||
* 与模板无关的空文书。因此这里在缺正文时先取回,取不到就拒绝发起。
|
||||
*/
|
||||
async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTemplate | null> {
|
||||
let contentHtml = template.contentHtml
|
||||
|
||||
if (!useMockLibrary && !contentHtml) {
|
||||
contentHtml = await getTemplateContent(template.id)
|
||||
|
||||
if (!contentHtml) {
|
||||
ElMessage.error('未能取到该文书的正文内容,无法发起签署')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
||||
return {
|
||||
id: template.id,
|
||||
versionId: template.versionId ?? template.id,
|
||||
name: template.name,
|
||||
code: template.code,
|
||||
code: template.code ?? '',
|
||||
version: template.version,
|
||||
department: template.department,
|
||||
departmentId: template.departmentId,
|
||||
campusId: template.campusId ?? undefined,
|
||||
category: template.category,
|
||||
description: template.description,
|
||||
description: template.description ?? '',
|
||||
supportedMethods: ['pad', 'sms'],
|
||||
contentHtml: template.contentHtml,
|
||||
contentHtml,
|
||||
contentSha256: template.contentSha256,
|
||||
}
|
||||
}
|
||||
|
||||
function openSigningDialog(template: DocumentTemplate) {
|
||||
async function openSigningDialog(template: DocumentTemplate) {
|
||||
closePreview()
|
||||
signingTemplate.value = toSigningTemplate(template)
|
||||
signingDialogVisible.value = true
|
||||
|
||||
try {
|
||||
const signing = await toSigningTemplate(template)
|
||||
|
||||
if (!signing) {
|
||||
return
|
||||
}
|
||||
|
||||
signingTemplate.value = signing
|
||||
signingDialogVisible.value = true
|
||||
} catch {
|
||||
ElMessage.error('文书正文加载失败,无法发起签署')
|
||||
}
|
||||
}
|
||||
|
||||
function closeSigningDialog() {
|
||||
@@ -296,7 +599,7 @@ function handleTaskCreated(task: SigningTaskRecord) {
|
||||
}
|
||||
|
||||
// 自测用:一键清掉本地保存的演示文书与签署任务,回到初始状态。
|
||||
// 签署任务的演示数据在 api 层的内存里,清存储后直接刷新最彻底。
|
||||
// 只在演示模式下展示(模板里有 v-if),真实环境不该出现这个入口。
|
||||
async function resetMockData() {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
'将清除本地保存的演示文书与签署任务,恢复到初始状态。',
|
||||
@@ -320,10 +623,35 @@ function clearFilters() {
|
||||
}
|
||||
|
||||
function useTemplate(template: DocumentTemplate) {
|
||||
openSigningDialog(template)
|
||||
void openSigningDialog(template)
|
||||
}
|
||||
|
||||
onMounted(loadLibrary)
|
||||
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>
|
||||
@@ -331,6 +659,7 @@ onMounted(loadLibrary)
|
||||
<DocumentLibraryHeader
|
||||
v-model="filters.keyword"
|
||||
:total="filteredTemplates.length"
|
||||
:show-reset="useMockLibrary"
|
||||
@add="showAddMessage"
|
||||
@export="showExportMessage"
|
||||
@reset="resetMockData"
|
||||
@@ -343,10 +672,12 @@ onMounted(loadLibrary)
|
||||
:statuses="statusTabs"
|
||||
/>
|
||||
|
||||
<p v-if="apiLibraryPending" class="library-pending">
|
||||
已关闭本地演示库(VITE_MOCK_DOCUMENTS=false),但文档库尚未接入模板接口,暂时无法展示文书。
|
||||
改回 VITE_MOCK_DOCUMENTS=true 可继续使用演示库。
|
||||
</p>
|
||||
<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
|
||||
@@ -381,8 +712,8 @@ onMounted(loadLibrary)
|
||||
<DocumentPreviewDialog
|
||||
:visible="Boolean(selectedTemplate)"
|
||||
:template="selectedTemplate"
|
||||
:loading="false"
|
||||
:error="false"
|
||||
:loading="previewLoading"
|
||||
:error="previewError"
|
||||
@close="closePreview"
|
||||
@use="useTemplate"
|
||||
/>
|
||||
@@ -400,6 +731,11 @@ onMounted(loadLibrary)
|
||||
@close="wordEditorVisible = false"
|
||||
@save="saveTemplate"
|
||||
/>
|
||||
|
||||
<!-- 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 -->
|
||||
<div v-if="wordEditorLoading" class="editor-loading" role="status">
|
||||
<span>正在加载文书正文…</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -416,10 +752,13 @@ onMounted(loadLibrary)
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.library-pending {
|
||||
.library-state {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
margin: 0 0 16px;
|
||||
color: var(--warn);
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
background: var(--card);
|
||||
@@ -427,6 +766,24 @@ onMounted(loadLibrary)
|
||||
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);
|
||||
@@ -462,6 +819,24 @@ onMounted(loadLibrary)
|
||||
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));
|
||||
|
||||
@@ -1,46 +1,113 @@
|
||||
/**
|
||||
* 文档库的演示数据与展示文案。
|
||||
*
|
||||
* 状态标签放在这里而不是随演示数据一起删掉:标签是纯展示映射,
|
||||
* 接真实接口后照样要用,不能和"演示数据"捆在一起。
|
||||
*
|
||||
* 演示文书库的种子与读写逻辑在 utils/mock-document-library:
|
||||
* 文档库页面和签署工作台必须读同一份数据,否则会出现「文档库里维护的模板,签署端选不到」,
|
||||
* 或者签署端混着一批文档库里看不到、也无法维护的模板。
|
||||
*/
|
||||
import type { DocumentRecord } from '@/api/management/types'
|
||||
import type { LocalDocument, LocalDepartment } from '@/utils/mock-document-library'
|
||||
import { readDocumentLibrary, writeDocumentLibrary } from '@/utils/mock-document-library'
|
||||
import type { SigningDocumentData } from '@/utils/signing-document'
|
||||
|
||||
import type { DocumentDepartment, DocumentStatus } from './types'
|
||||
|
||||
const documentStatusLabels: Record<DocumentStatus, string> = {
|
||||
enabled: '启用中',
|
||||
draft: '草稿',
|
||||
archived: '归档',
|
||||
review: '需复审',
|
||||
/**
|
||||
* 演示文库与页面模型之间的归一。
|
||||
*
|
||||
* utils/mock-document-library 刻意只声明自己用到的字段(不反向依赖 views 的类型),
|
||||
* 它返回的文书缺了页面模型要求的 category / updatedBy / updatedAt,code 也可能为空。
|
||||
* 两种口径不一致会在 loadLibrary / saveDocumentLibrary 处直接报类型错误,
|
||||
* 因此在这一层做一次显式补齐——而不是让 utils 反过来依赖 views,
|
||||
* 也不是把页面的必填字段放宽成可选(放宽会掩盖真实接口返回缺字段的问题)。
|
||||
*
|
||||
* 补齐用的值都标注了是演示数据的缺省值,不冒充真实字段:
|
||||
* updatedAt 留空串,页面会显示成"—",不会假装有一份真实的更新时间。
|
||||
*/
|
||||
function toDocumentRecord(document: LocalDocument, categoryName: string): DocumentRecord {
|
||||
return {
|
||||
...document,
|
||||
code: document.code ?? '',
|
||||
category: categoryName,
|
||||
updatedBy: '演示数据',
|
||||
updatedAt: document.updatedAt ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export const documentStatusOptions = Object.entries(documentStatusLabels).map(([key, label]) => ({
|
||||
key: key as DocumentStatus,
|
||||
label,
|
||||
}))
|
||||
export function toDocumentDepartment(department: LocalDepartment): DocumentDepartment {
|
||||
return {
|
||||
...department,
|
||||
categories: department.categories.map((category) => ({
|
||||
...category,
|
||||
documents: category.documents.map((document) => toDocumentRecord(document, category.name)),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export { documentStatusLabels }
|
||||
|
||||
// 演示文书库的种子与读取逻辑都在 utils/mock-document-library:
|
||||
// 文档库页面和签署工作台必须读同一份数据,否则会出现「文档库里维护的模板,签署端选不到」,
|
||||
// 或者签署端混着一批文档库里看不到、也无法维护的模板。
|
||||
/** 读取演示文书库:优先用本地保存的,没有则用种子(调用方负责再克隆一份使用) */
|
||||
export function loadDocumentLibrary(): DocumentDepartment[] {
|
||||
return readDocumentLibrary()
|
||||
}
|
||||
|
||||
/** 保存演示文书库,刷新后仍可继续编辑、预览与发起签署 */
|
||||
export function saveDocumentLibrary(library: DocumentDepartment[]): void {
|
||||
writeDocumentLibrary(library)
|
||||
return readDocumentLibrary().map(toDocumentDepartment)
|
||||
}
|
||||
|
||||
/**
|
||||
* 签署预览使用的示例患者快照(脱敏演示数据)。
|
||||
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;
|
||||
* 真实签署仍以任务创建时的患者/就诊快照为准。
|
||||
* 保存演示文书库。
|
||||
* 落本地时要把 category 摘掉(LocalDocument 没有 category 字段,
|
||||
* 分类信息在 categories 这一层的 name 上),否则多存一个字段会在下次读取时对不上。
|
||||
* updatedBy 同理:它是页面模型为「必须有人维护」补的展示字段,不是演示文库的数据。
|
||||
*/
|
||||
export const signingPreviewPatient: SigningDocumentData = {
|
||||
patientName: '李某某',
|
||||
sex: '男',
|
||||
age: 46,
|
||||
visitNo: 'V20260914001',
|
||||
visitDate: '2026-09-14',
|
||||
phone: '138****5678',
|
||||
signedAt: '',
|
||||
export function saveDocumentLibrary(library: DocumentDepartment[]): void {
|
||||
writeDocumentLibrary(
|
||||
library.map((department) => ({
|
||||
...department,
|
||||
categories: department.categories.map((category) => ({
|
||||
name: category.name,
|
||||
documents: category.documents.map(toLocalDocument),
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面模型的文书 → 演示文库存储结构。
|
||||
*
|
||||
* 逐字段列出而不是解构剔除:解构剔除(`const { category, ...rest }`)会因为
|
||||
* 被剔除的变量未使用而触发 lint,且多加一个页面专用字段时容易忘掉同步。
|
||||
* 这里明确列出演示文库认识哪些字段,多出来的页面字段(category / updatedBy)自然被丢掉。
|
||||
*/
|
||||
function toLocalDocument(document: DocumentRecord): LocalDocument {
|
||||
return {
|
||||
id: document.id,
|
||||
name: document.name,
|
||||
code: document.code,
|
||||
version: document.version,
|
||||
status: document.status,
|
||||
backendStatus: document.backendStatus,
|
||||
description: document.description ?? '',
|
||||
versionId: document.versionId,
|
||||
departmentId: document.departmentId,
|
||||
departmentName: document.departmentName,
|
||||
campusId: document.campusId,
|
||||
campusName: document.campusName,
|
||||
updatedAt: document.updatedAt,
|
||||
contentHtml: document.contentHtml,
|
||||
contentSha256: document.contentSha256,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态标签。键集合与后端模板状态枚举一一对应
|
||||
* (见 api/management/types.ts 的 BackendTemplateStatus 与 documents.ts 的 normalizeDocumentStatus),
|
||||
* 不再有后端枚举里不存在的 enabled。
|
||||
*/
|
||||
export const documentStatusLabels: Record<DocumentStatus, string> = {
|
||||
draft: '草稿',
|
||||
review: '需复审',
|
||||
published: '已发布',
|
||||
archived: '已归档',
|
||||
}
|
||||
|
||||
export const documentStatusOptions = (
|
||||
Object.entries(documentStatusLabels) as [DocumentStatus, string][]
|
||||
).map(([key, label]) => ({ key, label }))
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
export type DocumentStatus = 'enabled' | 'draft' | 'archived' | 'review'
|
||||
/**
|
||||
* 文档库页面模型。
|
||||
*
|
||||
* 状态枚举直接复用 api/management/types.ts 的 DocumentStatus,不再另立一套:
|
||||
* 之前页面自己定义了 'enabled' | 'draft' | 'archived' | 'review',
|
||||
* 其中 enabled 在后端模板状态枚举里没有对应值(后端只有 DRAFT / PENDING_REVIEW /
|
||||
* APPROVED / PUBLISHED / DISABLED / ARCHIVED),一旦接入真实接口就没法双向映射。
|
||||
* 现在两侧共用同一份,API 边界的 normalizeDocumentStatus 就是唯一的转换口径。
|
||||
*/
|
||||
import type { DocumentRecord, DocumentStatus } from '@/api/management/types'
|
||||
|
||||
export type DocumentCampus = 'all' | '本部院区' | '东院区' | '西院区'
|
||||
export type { DocumentStatus }
|
||||
|
||||
export interface LibraryDocument {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
version: string
|
||||
status: DocumentStatus
|
||||
backendStatus?: string
|
||||
description: string
|
||||
versionId?: string
|
||||
departmentId?: string | null
|
||||
campusId?: string | null
|
||||
contentHtml?: string
|
||||
contentSha256?: string
|
||||
}
|
||||
/** 院区分组键:与模板接口的 campusId 对应,'all' 表示全院通用 */
|
||||
export type DocumentCampus = string
|
||||
|
||||
/** 文书库里的单份文书(与模板接口的 DocumentRecord 同构,页面不再另建字段) */
|
||||
export type LibraryDocument = DocumentRecord
|
||||
|
||||
export interface DocumentCategory {
|
||||
name: string
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
|
||||
/**
|
||||
* 文书回退纸样。
|
||||
*
|
||||
* 只在任务缺少正文快照(documentContentHtml 为空)时使用:任务接口不返回正文,
|
||||
* 正文由 hydrateSigningTaskContent 按 templateContentSha256 取回,取不到时才会走到这里。
|
||||
*
|
||||
* 这里的正文段落是通用告知语句,**不是任何一份真实模板的内容**,
|
||||
* 因此页面必须明确标注"未取到模板正文",不能让医务人员误以为这就是要签的文书。
|
||||
* 经治医师等应由现场填写的字段也不许写死:写死一个不存在的医生名,
|
||||
* 等于在留痕材料上伪造了一个签署人。
|
||||
*/
|
||||
defineProps<{
|
||||
task: SigningTaskRecord
|
||||
}>()
|
||||
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="paper-preview">
|
||||
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
|
||||
|
||||
<div class="fallback-banner">
|
||||
<strong>未能取到模板正文</strong>
|
||||
<span>
|
||||
本任务仅返回了模板版本哈希({{ task.contentSha256 || '未提供' }}),正文快照暂不可用。
|
||||
请确认后端模板版本内容接口可访问,或联系信息科核对模板数据。
|
||||
以下为占位版式,不是本任务实际签署的文书内容。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2>{{ task.documentName }}</h2>
|
||||
<p class="paper-number">编号:{{ task.id }} · 就诊号:{{ task.visitNo }}</p>
|
||||
|
||||
@@ -47,10 +68,12 @@ defineProps<{
|
||||
<span>{{ task.signedAt ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 经治医师不是任务快照里的字段,也无从由前端补齐:
|
||||
这里不给占位人名,避免在留痕材料上凭空出现一个签署人 -->
|
||||
<div class="signature-field">
|
||||
<span>经治医师:</span>
|
||||
<div class="signature-box signature-box--doctor">
|
||||
<span>刘明辉</span>
|
||||
<span>(以实际签署文书为准)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,6 +93,26 @@ defineProps<{
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
/* 回退纸样必须在最显眼处声明"这不是真实文书内容",
|
||||
否则医务人员会把占位段落当成要签的文书 */
|
||||
.fallback-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--warn);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
background: var(--warn-l);
|
||||
border: 1px solid #f0dcae;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fallback-banner strong {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.paper-preview h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 19px;
|
||||
@@ -165,6 +208,11 @@ defineProps<{
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 医师签名位是空的说明文字,不需要下划线,避免看起来像已填写 */
|
||||
.signature-field:nth-child(3) .signature-box {
|
||||
border-bottom-style: dashed;
|
||||
}
|
||||
|
||||
.void-stamp {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
|
||||
@@ -158,7 +158,8 @@ defineExpose({ getDocumentElement })
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 任务创建时固化了文书 HTML 的走交互化渲染;老任务回退到静态纸样预览 -->
|
||||
<!-- 任务接口只返回模板版本哈希,正文由 API 层按哈希补回;
|
||||
补不到时如实提示并给出占位版式,不退化成"看起来像真文书"的假纸样 -->
|
||||
<SigningDocumentInteractive
|
||||
v-if="task.documentContentHtml"
|
||||
ref="documentRef"
|
||||
|
||||
Reference in New Issue
Block a user