feat(演示): 持久化 Mock 数据并打通文档库发起签署闭环
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 签署预览使用的示例患者快照(脱敏演示数据)。
|
||||
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;
|
||||
|
||||
Reference in New Issue
Block a user