feat(演示): 持久化 Mock 数据并打通文档库发起签署闭环

This commit is contained in:
yelan
2026-09-14 14:28:51 +08:00
parent f4e2671da6
commit d34ef0a0ce
8 changed files with 260 additions and 9 deletions
+33 -2
View File
@@ -1,6 +1,8 @@
import axios from 'axios'
import { unwrapApiResponse } from '@/utils/api-response'
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
import { request } from '@/utils/request'
import { resendSigningSms as resendSigningSmsDelivery } from './deliveries'
@@ -215,7 +217,7 @@ const mockTemplates: SigningTemplate[] = [
},
]
const mockRecords: SigningTaskRecord[] = [
const defaultMockRecords: SigningTaskRecord[] = [
{
id: 'task-001',
templateVersionId: 'tpl-001-v2',
@@ -325,6 +327,17 @@ const mockRecords: SigningTaskRecord[] = [
},
]
/**
* 演示任务记录:优先用本地保存的,刷新后仍保留(任务固化的文书 HTML、签名图都在里面)。
* 所有写操作都要调用 persistMockRecords 落盘,否则刷新会回到切换前的状态。
*/
const mockRecords: SigningTaskRecord[] =
readMockState<SigningTaskRecord[]>(MOCK_STORAGE_KEYS.signingTasks) ?? defaultMockRecords
function persistMockRecords() {
writeMockState(MOCK_STORAGE_KEYS.signingTasks, mockRecords)
}
function clonePatient(patient: PatientProfile): PatientProfile {
return {
...patient,
@@ -371,6 +384,11 @@ function addHours(value: string, hours: number) {
export const isSigningMockEnabled = useMockData
/** 演示患者清单(仅 Mock 模式):给签署弹窗做“点一下就能定位”的输入提示 */
export function getMockPatientHints(): { id: string; name: string }[] {
return useMockData ? mockPatients.map((item) => ({ id: item.id, name: item.name })) : []
}
function isNotFoundError(error: unknown) {
return axios.isAxiosError(error) && error.response?.status === 404
}
@@ -852,6 +870,8 @@ export async function getPatientProfile(keyword: string): Promise<PatientProfile
const patient = mockPatients.find(
(item) =>
item.id === normalizedKeyword ||
// 演示数据里记不住患者 ID 是常态,允许直接按姓名定位
item.name === normalizedKeyword ||
item.visits.some((visit) => visit.visitNo === normalizedKeyword),
)
@@ -876,7 +896,12 @@ export async function getSigningTemplates(
return records.map(mapAvailableTemplate)
}
return Promise.resolve(mockTemplates.map((template) => ({ ...template })))
// 文档库本地新建的文书只存在 localStorage,必须一起返回;
// 否则文档库刚建好的文书在签署侧选不到(内置 Mock 模板是写死的演示数据)。
return Promise.resolve([
...readLocalSigningTemplates(),
...mockTemplates.map((template) => ({ ...template })),
])
}
export async function prepareSigningTask(
@@ -950,6 +975,7 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise<
}
mockRecords.unshift(task)
persistMockRecords()
return Promise.resolve(cloneTask(task))
}
@@ -969,6 +995,7 @@ export function updateSigningTaskMethod(
task.method = method
task.updatedAt = formatMockDate()
persistMockRecords()
return Promise.resolve(cloneTask(task))
}
@@ -990,6 +1017,7 @@ export function completeSigningTask(
task.signatureDataUrl = signatureDataUrl
task.signedAt = formatMockDate()
task.updatedAt = task.signedAt
persistMockRecords()
return Promise.resolve(cloneTask(task))
}
@@ -1015,6 +1043,7 @@ export async function reopenSigningTask(
task.signatureDataUrl = undefined
task.signedAt = undefined
task.updatedAt = formatMockDate()
persistMockRecords()
return Promise.resolve(cloneTask(task))
}
@@ -1041,6 +1070,7 @@ export async function voidSigningTask(
task.voidReason = reason
task.signatureDataUrl = undefined
task.updatedAt = formatMockDate()
persistMockRecords()
return Promise.resolve(cloneTask(task))
}
@@ -1063,6 +1093,7 @@ export async function resendSigningSms(
task.status = 'pending'
task.expiresAt = addHours('2026-08-27 12:00', 48)
task.updatedAt = formatMockDate()
persistMockRecords()
return Promise.resolve(cloneTask(task))
}
@@ -2,7 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { isSigningMockEnabled } from '@/api/workbench/signing'
import { getMockPatientHints, isSigningMockEnabled } from '@/api/workbench/signing'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
import { useAppStore } from '@/stores/app'
@@ -20,6 +20,14 @@ const emit = defineEmits<{
const appStore = useAppStore()
const patientInput = ref<HTMLInputElement | null>(null)
const initialTemplate = computed(() => props.initialTemplate ?? null)
// 演示模式下列出可用的 Mock 患者,点一下直接定位,省得记患者 ID(真实模式为空数组)
const patientHints = getMockPatientHints()
function usePatientHint(id: string) {
patientId.value = id
errors.patient = ''
void locatePatient()
}
const {
documentSearch,
@@ -135,6 +143,18 @@ watch(
{{ locating ? '定位中…' : '定位患者' }}
</button>
</div>
<div v-if="patientHints.length" class="demo-patients">
<span class="demo-patients__label">演示患者</span>
<button
v-for="hint in patientHints"
:key="hint.id"
type="button"
class="demo-patients__chip"
@click="usePatientHint(hint.id)"
>
{{ hint.name }} · {{ hint.id }}
</button>
</div>
<div v-if="patient" class="patient-card">
<span class="patient-avatar" aria-hidden="true">人</span>
<strong>{{ patient.name }}</strong>
@@ -962,4 +982,29 @@ watch(
width: 100%;
}
}
.demo-patients {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 8px;
}
.demo-patients__label {
color: var(--mut);
font-size: 11.5px;
}
.demo-patients__chip {
padding: 3px 8px;
color: var(--brand);
font-size: 11.5px;
background: var(--brand-l);
border: none;
border-radius: 10px;
}
.demo-patients__chip:hover {
filter: brightness(0.95);
}
</style>
@@ -0,0 +1,62 @@
/**
* 本地演示文书库的读取与转换(仅 Mock 模式)。
*
* 管理端文档库的数据只存在前端:科室在 Word 编辑器里建好文书后写进 localStorage。
* 文档库页面和签署侧的模板列表都要读这份数据,共享同一份读取逻辑,
* 否则会出现「文档库刚建好文书,签署工作台却选不到」。
*
* 这里只声明实际用到的字段,不绑定文档库页面的类型,避免 api 层反向依赖 views。
*/
import type { SigningMethod, SigningTemplate } from '@/api/workbench/types'
import { MOCK_STORAGE_KEYS, readMockState } from './mock-storage'
interface LocalDocument {
id: string
name: string
code: string
version: string
description?: string
versionId?: string
departmentId?: string | null
campusId?: string | null
contentHtml?: string
}
interface LocalCategory {
name: string
documents: LocalDocument[]
}
interface LocalDepartment {
key: string
name: string
categories: LocalCategory[]
}
/**
* 把本地保存的文档库摊平成签署模板。
* 科室刚建好的文书因此能直接在签署工作台、文档库「发起」里选到。
*/
export function readLocalSigningTemplates(): SigningTemplate[] {
const departments = readMockState<LocalDepartment[]>(MOCK_STORAGE_KEYS.documentLibrary) ?? []
return departments.flatMap((department) =>
(department.categories ?? []).flatMap((category) =>
(category.documents ?? []).map((document) => ({
id: document.id,
versionId: document.versionId ?? document.id,
name: document.name,
code: document.code,
version: document.version,
department: department.name,
departmentId: document.departmentId ?? null,
campusId: document.campusId ?? undefined,
category: category.name,
description: document.description ?? '',
supportedMethods: ['pad', 'sms'] as SigningMethod[],
contentHtml: document.contentHtml,
})),
),
)
}
+46
View File
@@ -0,0 +1,46 @@
/**
* 演示数据的本地持久化(仅 Mock 模式使用)。
*
* 文档库与签署任务的演示数据原本只存在模块内存里,刷新页面就回到初始状态,
* 自测「文档库建文书 → 发起签署 → 签署 → 查看留痕」每次都要重来一遍。
* 这里把这份数据镜像到 localStorage,刷新后可以接着测。
*
* 只存演示数据,不涉及登录态、权限或真实患者信息;写入失败(容量超限、
* 浏览器禁用存储)时静默降级为纯内存数据,不打断正在进行的操作。
*/
const STORAGE_PREFIX = 'medical-sign:mock:'
/** 演示数据键名:清空演示数据时按这里的清单统一清理 */
export const MOCK_STORAGE_KEYS = {
documentLibrary: `${STORAGE_PREFIX}document-library`,
signingTasks: `${STORAGE_PREFIX}signing-tasks`,
} as const
export function readMockState<T>(key: string): T | null {
try {
const raw = window.localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : null
} catch {
// 存储被禁用,或存量数据与当前结构不兼容:按「没有本地数据」处理,页面照常初始化
return null
}
}
export function writeMockState(key: string, value: unknown): void {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
// 容量超限或存储被禁用:退化为纯内存演示数据,不打扰使用者
}
}
/** 清空全部演示数据,调用方负责重新加载数据或刷新页面 */
export function clearMockState(): void {
try {
Object.values(MOCK_STORAGE_KEYS).forEach((key) => window.localStorage.removeItem(key))
} catch {
// 存储不可用时本来就无需清理
}
}
@@ -8,6 +8,7 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
add: []
export: []
reset: []
}>()
function handleInput(event: Event) {
@@ -41,6 +42,14 @@ function handleInput(event: Event) {
<button type="button" class="tool-button secondary" @click="emit('export')">
导出模板包
</button>
<button
type="button"
class="tool-button ghost"
title="清除本地保存的演示文书与签署任务"
@click="emit('reset')"
>
重置演示数据
</button>
</div>
</div>
</template>
@@ -135,6 +144,17 @@ function handleInput(event: Event) {
background: var(--brand-l);
}
.tool-button.ghost {
color: var(--mut);
background: #fff;
border: 1px solid var(--line);
}
.tool-button.ghost:hover {
color: var(--ink);
border-color: var(--mut);
}
@media (max-width: 760px) {
.library-tools {
width: 100%;
@@ -1,17 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
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 type { SigningTemplate } from '@/api/workbench/types'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import { clearMockState } from '@/utils/mock-storage'
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 WordEditorDialog from './components/WordEditorDialog.vue'
import { documentLibrary, documentStatusOptions } from './mock'
import { documentStatusOptions, loadDocumentLibrary, saveDocumentLibrary } from './mock'
import type {
DepartmentTab,
DocumentDepartment,
@@ -24,6 +26,8 @@ import type {
// 原型阶段:文档库数据与文书内容全部来自本地真实附件(mock.ts + contents.ts),暂不调用后端接口。
// 后续接入时,仅需将 loadLibrary / saveTemplate / handleWorkflow 中的本地数据操作替换为文档模板接口。
const router = useRouter()
const library = ref<DocumentDepartment[]>([])
const selectedTemplate = ref<DocumentTemplate | null>(null)
const signingTemplate = ref<SigningTemplate | null>(null)
@@ -99,8 +103,8 @@ const filteredTemplates = computed(() => {
})
})
function cloneLibrary(): DocumentDepartment[] {
return documentLibrary.map((department) => ({
function cloneLibrary(source: DocumentDepartment[]): DocumentDepartment[] {
return source.map((department) => ({
...department,
categories: department.categories.map((category) => ({
...category,
@@ -110,9 +114,12 @@ function cloneLibrary(): DocumentDepartment[] {
}
function loadLibrary() {
library.value = cloneLibrary()
library.value = cloneLibrary(loadDocumentLibrary())
}
// 演示数据落本地:刷新后文书库还在,不用每次重新建文书
watch(library, (value) => saveDocumentLibrary(value), { deep: true })
function handlePreview(template: DocumentTemplate) {
selectedTemplate.value = template
}
@@ -262,6 +269,31 @@ function closeSigningDialog() {
signingTemplate.value = null
}
// 闭环:文档库发起签署后跳到工作台并选中该任务,否则任务创建成功但用户看不到后续
function handleTaskCreated(task: SigningTaskRecord) {
closeSigningDialog()
void router.push({ name: 'workbench-signing', query: { taskId: task.id } })
}
// 自测用:一键清掉本地保存的演示文书与签署任务,回到初始状态。
// 签署任务的演示数据在 api 层的内存里,清存储后直接刷新最彻底。
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
@@ -281,6 +313,7 @@ onMounted(loadLibrary)
:total="filteredTemplates.length"
@add="showAddMessage"
@export="showExportMessage"
@reset="resetMockData"
/>
<DocumentFilterTabs
@@ -332,6 +365,7 @@ onMounted(loadLibrary)
:visible="signingDialogVisible"
:initial-template="signingTemplate"
@close="closeSigningDialog"
@created="handleTaskCreated"
/>
<WordEditorDialog
:visible="wordEditorVisible"
@@ -1,3 +1,4 @@
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
import type { SigningDocumentData } from '@/utils/signing-document'
import type { DocumentDepartment, DocumentStatus } from './types'
@@ -19,6 +20,16 @@ export { documentStatusLabels }
// Word 自维护方案:文书库初始为空,所有文书由科室通过 Word 编辑器(粘贴 / 导入 .docx)创建维护。
export const documentLibrary: DocumentDepartment[] = []
/** 读取演示文书库:优先用本地保存的数据,没有则用初始空库(调用方负责再克隆一份使用) */
export function loadDocumentLibrary(): DocumentDepartment[] {
return readMockState<DocumentDepartment[]>(MOCK_STORAGE_KEYS.documentLibrary) ?? documentLibrary
}
/** 保存演示文书库,刷新后仍可继续编辑、预览与发起签署 */
export function saveDocumentLibrary(library: DocumentDepartment[]): void {
writeMockState(MOCK_STORAGE_KEYS.documentLibrary, library)
}
/**
* 签署预览使用的示例患者快照(脱敏演示数据)。
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;