feat(documents): 接入文档库真实接口

This commit is contained in:
xy
2026-09-17 18:46:09 +08:00
parent 81530b5dd6
commit 8b1cbee16f
8 changed files with 136 additions and 59 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ VITE_API_BASE_URL=/api
# Mock 开关:按业务模块(对齐侧边栏菜单)独立控制,取值只认 true / false。
# 留空或注释掉即使用该模块的缺省值,缺省值以 src/utils/mock-flags.ts 为准,
# 目前是「签署工作台 + 文档库走演示数据,其余模块走真实接口」。
# 目前只有签署工作台缺省走演示数据;文档库及其余已接入模块缺省走真实接口。
#
# 建议在本地 .env.*.local 里显式声明这两个开关,而不是依赖缺省值:
# 缺省值只保证「不改代码时的默认行为」,缺省值一旦调整,
@@ -11,7 +11,7 @@ VITE_API_BASE_URL=/api
# 「接口报错」或「数据不对」,排查成本很高。
# VITE_MOCK_WORKBENCH_HOME=false
# VITE_MOCK_SIGNING=true
# VITE_MOCK_DOCUMENTS=true
# VITE_MOCK_DOCUMENTS=false
# VITE_MOCK_DOCUMENT_PERMISSIONS=false
# VITE_MOCK_REPORTS=false
# VITE_MOCK_USERS=false
+2 -2
View File
@@ -90,11 +90,11 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
- 列表读 `GET /api/v1/templates`(自动翻完所有页),再把扁平的模板列表组织成页面需要的「科室 → 分类 → 文书」树;
- 正文不在列表接口里(`TemplateResponseDto` 只给 `templateContentSha256`,不给正文),预览、编辑、发起签署前都要再取一次版本详情 `GET /api/v1/templates/{id}/versions/{versionId}` 拿 `contentHtml`。取不到正文时页面如实报错,不展示空壳模板;
- 新建走「建模板 + 建首版」两步,编辑走「建新版本 + 更新模板」;模板没有 DELETE 接口,页面上的「删除」实际是归档动作(`POST .../versions/{versionId}/archive`),保留历史留痕链。
- 新建走「建模板 + 建首版」两步,编辑走「建新版本 + 更新模板」;版本号由后端按并发安全序号生成。列表和详情分别使用最新维护版本与当前发布版本指针,审核编辑不会误操作旧发布版,发起签署也不会误用待审核草稿。模板没有 DELETE 接口,页面上的「删除」实际是归档动作(`POST .../versions/{versionId}/archive`),保留历史留痕链。
签署工作台的真实模式同样依赖正文:`GET /api/v1/templates/available-versions` 只返回 `contentSha256`,签署端用这个哈希当缓存键去换回正文快照(同一哈希命中同一份正文,版本更新后哈希变化即自动失效),避免版本更新后仍按旧正文签署。取不到正文时任务详情如实提示并给出占位版式,不退化成「看起来像真文书」的假纸样。
自测与演示:开启 `VITE_MOCK_DOCUMENTS` / `VITE_MOCK_SIGNING`(两者缺省即为开启)时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态(该按钮只在演示模式下出现)。持久化只针对演示数据,不涉及登录态与真实患者信息。本地建议在 `.env.development.local` 里显式写出这两个开关,不要依赖缺省值。
自测与演示:显式开启 `VITE_MOCK_DOCUMENTS=true` 或 `VITE_MOCK_SIGNING=true` 时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态(该按钮只在演示模式下出现)。文档库缺省使用真实接口,签署工作台暂时缺省使用演示数据;持久化只针对演示数据,不涉及登录态与真实患者信息。本地建议在 `.env.development.local` 里显式写出开关,不要依赖缺省值。
## 当前路由结构
+18 -12
View File
@@ -17,6 +17,7 @@ import type {
DocumentRecord,
TemplateResponseDto,
TemplateVersionResponseDto,
UpdateTemplateRequest,
} from './types'
const useMockData = mockFlags.documents
@@ -89,8 +90,9 @@ function normalizeDocumentStatus(status: BackendTemplateStatus | null | undefine
}
function mapDocument(dto: TemplateResponseDto): DocumentRecord {
const versionId = dto.currentVersionId ?? undefined
const version = dto.versionSequence ? `v${dto.versionSequence}` : '—'
const versionId = dto.latestVersionId ?? dto.currentVersionId ?? undefined
const version = dto.latestVersionNo ?? (dto.versionSequence ? `v${dto.versionSequence}` : '—')
const backendStatus = dto.latestVersionStatus ?? dto.status
return {
id: dto.id,
@@ -98,11 +100,12 @@ function mapDocument(dto: TemplateResponseDto): DocumentRecord {
code: dto.templateCode,
category: dto.category ?? '未分类',
version: String(version),
status: normalizeDocumentStatus(dto.status),
backendStatus: dto.status ?? undefined,
status: normalizeDocumentStatus(backendStatus),
backendStatus: backendStatus ?? undefined,
updatedBy: dto.updatedBy ?? dto.createdBy ?? '—',
updatedAt: formatTemplateDateTime(dto.updatedAt ?? dto.createdAt),
versionId,
currentVersionId: dto.currentVersionId ?? undefined,
departmentId: dto.departmentId,
departmentName: dto.departmentId ? undefined : '全院通用',
campusId: dto.campusId,
@@ -143,21 +146,21 @@ export function buildSignatureFieldsPayload(contentHtml: string): unknown[] {
/**
* 读取模板当前版本的正文。
* 模板列表接口只给 currentVersionId 与哈希,不给正文,
* 模板列表接口只给版本指针与哈希,不给正文,
* 因此展示/编辑正文必须再取一次版本详情——这是文档库能否接入的关键一步。
*/
export async function getTemplateContent(templateId: string): Promise<string> {
export async function getTemplateContent(templateId: string, versionId?: string): Promise<string> {
if (useMockData) {
return ''
}
const template = await getDocumentDetail(templateId)
const resolvedVersionId = versionId ?? (await getDocumentDetail(templateId))?.versionId
if (!template?.versionId) {
if (!resolvedVersionId) {
return ''
}
const version = await getTemplateVersionDetail(templateId, template.versionId)
const version = await getTemplateVersionDetail(templateId, resolvedVersionId)
return readVersionContentHtml(version)
}
@@ -235,7 +238,10 @@ async function findContentByVersionId(templateVersionId: string): Promise<string
: []
const candidates = [firstPage, ...remainingPages].flatMap((page) => page.records)
const matched = candidates.find((template) => template.versionId === templateVersionId)
const matched = candidates.find(
(template) =>
template.versionId === templateVersionId || template.currentVersionId === templateVersionId,
)
if (!matched) {
return ''
@@ -376,7 +382,7 @@ export async function createTemplate(payload: CreateTemplateRequest): Promise<Do
export async function updateTemplate(
id: string,
payload: Partial<CreateTemplateRequest>,
payload: UpdateTemplateRequest,
): Promise<DocumentRecord> {
if (useMockData) {
throw new Error('演示模式下请在文档库页面直接编辑文书')
@@ -506,7 +512,7 @@ export async function createTemplateWithContent(
signatureFields: buildSignatureFieldsPayload(version.contentHtml),
})
// 版本创建后模板的 currentVersionId/versionSequence 才更新,重新取一次拿到准确版本号
// 版本创建后模板的 latestVersionId/versionSequence 才更新,重新取一次拿到准确版本号
const detail = await getDocumentDetail(template.id)
return detail ?? template
}
+11
View File
@@ -27,6 +27,8 @@ export interface DocumentRecord {
description?: string
contentHtml?: string
contentSha256?: string
/** 当前已发布版本;发起签署必须使用它,不能误用最新草稿版本。 */
currentVersionId?: string
}
export type DocumentListResponse = PageResult<DocumentRecord>
@@ -51,6 +53,9 @@ export interface TemplateResponseDto {
category?: string | null
status?: BackendTemplateStatus | null
currentVersionId?: string | null
latestVersionId?: string | null
latestVersionNo?: string | null
latestVersionStatus?: BackendTemplateStatus | null
versionSequence?: number | null
createdAt?: string | null
createdBy?: string | null
@@ -93,6 +98,12 @@ export interface CreateTemplateRequest {
category?: string
}
export interface UpdateTemplateRequest {
name: string
description?: string
category?: string
}
export interface CreateTemplateVersionRequest {
versionNo?: string
contentHtml: string
+1 -1
View File
@@ -32,7 +32,7 @@ export const mockFlags = {
/** 签署工作台:签署任务、签署产物,以及仅用于签署留痕的审计日志 */
signing: resolveMockFlag(import.meta.env.VITE_MOCK_SIGNING, true),
/** 文档库管理:文书库与文书模板接口 */
documents: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENTS, true),
documents: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENTS, false),
/** 文档权限:模板权限配置与权限矩阵 */
documentPermissions: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENT_PERMISSIONS, false),
/** 报表分析 */
@@ -10,6 +10,7 @@ const props = defineProps<{
template: DocumentTemplate
canEdit: boolean
canInitiate: boolean
canWorkflow: (action: TemplateVersionAction) => boolean
canRemove: boolean
/** 演示模式下「删除」是真删;真实模式下语义是下架(模板无 DELETE 接口,走归档) */
isMock: boolean
@@ -31,9 +32,7 @@ const statusTone = computed(() => readStatusTone(props.template))
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
const canInitiateTemplate = computed(
() =>
props.canInitiate &&
(props.template.status === 'published' || props.template.status === 'review'),
() => props.canInitiate && Boolean(props.template.currentVersionId || props.isMock),
)
const initiateTitle = computed(() =>
@@ -64,6 +63,8 @@ const primaryAction = computed<{ key: TemplateVersionAction; label: string } | n
return { key: 'submit-review', label: '重新提交' }
case 'DISABLED':
return { key: 'publish', label: '重新启用' }
case 'APPROVED':
return { key: 'publish', label: '发布' }
default:
return null
}
@@ -75,21 +76,21 @@ const primaryAction = computed<{ key: TemplateVersionAction; label: string } | n
* 「驳回」不能只做成主动作的反面:审核通过和驳回是审核人的一对选择,
* 必须同时可见,否则待审核的文书就只能通过、不能打回。
*/
const secondaryActions = computed<
{ key: TemplateVersionAction; label: string; title: string }[]
>(() => {
if (props.template.backendStatus === 'PENDING_REVIEW') {
return [
{
key: 'reject',
label: '驳回',
title: '驳回后需填写原因,科室按意见修改并重新提交',
},
]
}
const secondaryActions = computed<{ key: TemplateVersionAction; label: string; title: string }[]>(
() => {
if (props.template.backendStatus === 'PENDING_REVIEW' && props.canWorkflow('reject')) {
return [
{
key: 'reject',
label: '驳回',
title: '驳回后需填写原因,科室按意见修改并重新提交',
},
]
}
return []
})
return []
},
)
/**
* 本次交付暂不包含「版本历史」与「删除 / 下架」入口,先隐藏对应按钮。
@@ -136,10 +137,9 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
版本
</button>
<button
v-if="canInitiate"
v-if="canInitiateTemplate"
type="button"
class="card-button primary"
:disabled="!canInitiateTemplate"
:title="initiateTitle"
@click="emit('initiate', template)"
>
@@ -147,7 +147,7 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
</button>
<!-- 还不能签署时,把「当前该做的下一步」放在同一位置,避免按钮越堆越多 -->
<button
v-else-if="primaryAction"
v-if="primaryAction && canWorkflow(primaryAction.key)"
type="button"
class="card-button primary"
:title="`${primaryAction.label}后才能发起签署`"
@@ -19,6 +19,7 @@ const props = defineProps<{
template: DocumentTemplate | null
campuses: CampusRecord[]
departments: DepartmentRecord[]
isMock: boolean
}>()
const emit = defineEmits<{
@@ -52,7 +53,7 @@ const form = reactive<TemplateEditorForm>({
campusId: props.campuses[0]?.id ?? '',
departmentId: '',
category: '知情同意书',
versionNo: 'v1.0.0',
versionNo: 'v1',
contentHtml: '',
})
@@ -80,7 +81,7 @@ watch(
campusId: template?.campusId ?? props.campuses[0]?.id ?? '',
departmentId: template?.departmentId ?? '',
category: template?.category ?? '知情同意书',
versionNo: template?.version ?? 'v1.0.0',
versionNo: template?.version ?? 'v1',
contentHtml: template?.contentHtml ?? '',
})
valueHtml.value = template?.contentHtml || '<p><br></p>'
@@ -224,11 +225,21 @@ function save() {
</label>
<label class="meta-field">
<span>文书编号</span>
<input v-model="form.templateCode" type="text" placeholder="如 TPL-XXX-001" />
<input
v-model="form.templateCode"
type="text"
placeholder="如 TPL-XXX-001"
:disabled="Boolean(template)"
:title="template ? '文书编号创建后不可修改' : undefined"
/>
</label>
<label class="meta-field">
<span>所属院区</span>
<select v-model="form.campusId" @change="handleCampusChange">
<select
v-model="form.campusId"
:disabled="Boolean(template)"
@change="handleCampusChange"
>
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
@@ -236,7 +247,7 @@ function save() {
</label>
<label class="meta-field">
<span>所属科室</span>
<select v-model="form.departmentId">
<select v-model="form.departmentId" :disabled="Boolean(template)">
<option value="">全院通用</option>
<option
v-for="department in availableDepartments"
@@ -253,7 +264,8 @@ function save() {
</label>
<label class="meta-field">
<span>版本号</span>
<input v-model="form.versionNo" type="text" />
<input v-if="isMock" v-model="form.versionNo" type="text" />
<span v-else class="auto-version">由系统按 vN 自动生成</span>
</label>
</div>
@@ -8,7 +8,9 @@ import {
deleteTemplate,
getDocuments,
getTemplateContent,
getTemplateVersionDetail,
getTemplateVersions,
readVersionContentHtml,
updateTemplate,
updateTemplateContent,
updateTemplateVersionStatus,
@@ -18,6 +20,7 @@ import { getCampuses, getDepartments } from '@/api/management/organization'
import type {
CampusRecord,
CreateTemplateRequest,
CreateTemplateVersionRequest,
DocumentRecord,
DepartmentRecord,
TemplateVersionResponseDto,
@@ -56,8 +59,8 @@ import type {
/**
* 文档库数据来源。
*
* - 演示模式(VITE_MOCK_DOCUMENTS 开启,缺省开启):读本地演示文书库,可随意增删改;
* - 真实模式:读模板接口。列表只给模板元数据(含 currentVersionId),
* - 演示模式(显式开启 VITE_MOCK_DOCUMENTS):读本地演示文书库,可随意增删改;
* - 真实模式:读模板接口。列表只给模板元数据(含发布/最新版本指针),
* 正文要再取一次版本详情——这是接入时最容易被忽略的一步,
* 缺了它文档库会显示一堆没有正文、无法预览的"空壳模板"。
*
@@ -78,7 +81,8 @@ const canCreateTemplate = computed(
const canEditTemplate = computed(
() =>
!loginStore.isReadOnly &&
(useMockLibrary || loginStore.hasWritePermission('sign:template:version:create')),
(useMockLibrary ||
loginStore.hasWritePermission(['sign:template:update', 'sign:template:version:create'])),
)
const canArchiveTemplate = computed(
() =>
@@ -89,6 +93,22 @@ const canInitiateSigning = computed(
() =>
!loginStore.isReadOnly && (useMockLibrary || loginStore.hasWritePermission('sign:task:create')),
)
const workflowPermissions: Record<TemplateVersionAction, string> = {
'submit-review': 'sign:template:review:submit',
reject: 'sign:template:review:reject',
approve: 'sign:template:review:approve',
publish: 'sign:template:publish',
disable: 'sign:template:disable',
archive: 'sign:template:archive',
}
function canRunTemplateWorkflow(action: TemplateVersionAction) {
return (
!loginStore.isReadOnly &&
(useMockLibrary || loginStore.hasWritePermission(workflowPermissions[action]))
)
}
/** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
const libraryLoading = ref(false)
const libraryError = ref('')
@@ -177,8 +197,7 @@ const filteredTemplates = computed(() => {
return allTemplates.value.filter((template) => {
const matchesDepartment = !filters.department || template.departmentKey === filters.department
const matchesStatus =
filters.status === 'all' || readBackendStatus(template) === filters.status
const matchesStatus = filters.status === 'all' || readBackendStatus(template) === filters.status
const matchesKeyword =
!keyword ||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
@@ -364,7 +383,7 @@ async function handlePreview(template: DocumentTemplate) {
previewError.value = false
try {
const contentHtml = await getTemplateContent(template.id)
const contentHtml = await getTemplateContent(template.id, template.versionId)
// 用户在等待期间可能已经切到别的文书,回填前确认仍是同一份
if (selectedTemplate.value?.id === template.id) {
@@ -422,7 +441,7 @@ async function showEditMessage(template: DocumentTemplate) {
wordEditorLoading.value = true
try {
const contentHtml = await getTemplateContent(template.id)
const contentHtml = await getTemplateContent(template.id, template.versionId)
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,请确认模板版本内容接口可访问')
@@ -512,9 +531,11 @@ function toCreatePayload(form: TemplateEditorForm): CreateTemplateRequest {
}
}
function toVersionPayload(form: TemplateEditorForm) {
function toVersionPayload(
form: TemplateEditorForm,
): Omit<CreateTemplateVersionRequest, 'signatureFields'> {
return {
versionNo: form.versionNo,
...(useMockLibrary && form.versionNo ? { versionNo: form.versionNo } : {}),
contentHtml: form.contentHtml,
}
}
@@ -537,6 +558,8 @@ async function saveTemplate(form: TemplateEditorForm) {
return
}
let versionCreated = false
try {
let savedId = form.id
@@ -545,6 +568,7 @@ async function saveTemplate(form: TemplateEditorForm) {
// 新版本的状态由后端决定,前端不在这里「顺带发布」——发布是需要留痕的正式动作,
// 要由人显式触发(见 handleWorkflow),否则「谁把这份文书放行到可签署」无从追溯。
await updateTemplateContent(form.id, toVersionPayload(form))
versionCreated = true
await updateTemplate(form.id, {
name: form.name,
description: form.description,
@@ -573,7 +597,11 @@ async function saveTemplate(form: TemplateEditorForm) {
)
}
} catch {
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
if (form.id && versionCreated) {
ElMessage.error('新版本已保存,但文书名称、说明或分类更新失败;请刷新后重试基本信息')
} else {
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
}
}
}
@@ -627,6 +655,11 @@ async function handleWorkflow(template: DocumentTemplate, action: TemplateVersio
return
}
if (!canRunTemplateWorkflow(action)) {
ElMessage.warning(`当前账号没有${workflowActionLabels[action]}权限`)
return
}
const label = workflowActionLabels[action]
let reason: string | undefined
@@ -777,10 +810,23 @@ async function removeTemplate(template: DocumentTemplate) {
* 与模板无关的空文书。因此这里在缺正文时先取回,取不到就拒绝发起。
*/
async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTemplate | null> {
let contentHtml = template.contentHtml
if (!useMockLibrary && !template.currentVersionId) {
ElMessage.error('该文书还没有已发布版本,无法发起签署')
return null
}
if (!useMockLibrary && !contentHtml) {
contentHtml = await getTemplateContent(template.id)
let contentHtml = template.contentHtml
let versionNo = template.version
let contentSha256 = template.contentSha256
const signingVersionId = useMockLibrary
? (template.versionId ?? template.id)
: template.currentVersionId!
if (!useMockLibrary) {
const version = await getTemplateVersionDetail(template.id, signingVersionId)
contentHtml = readVersionContentHtml(version)
versionNo = version?.versionNo ?? versionNo
contentSha256 = version?.contentSha256 ?? contentSha256
if (!contentHtml) {
ElMessage.error('未能取到该文书的正文内容,无法发起签署')
@@ -790,10 +836,10 @@ async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTem
return {
id: template.id,
versionId: template.versionId ?? template.id,
versionId: signingVersionId,
name: template.name,
code: template.code ?? '',
version: template.version,
version: versionNo,
department: template.department,
departmentId: template.departmentId,
campusId: template.campusId ?? undefined,
@@ -801,7 +847,7 @@ async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTem
description: template.description ?? '',
supportedMethods: ['pad', 'sms'],
contentHtml,
contentSha256: template.contentSha256,
contentSha256,
}
}
@@ -930,6 +976,7 @@ onMounted(() => {
:template="template"
:can-edit="canEditTemplate"
:can-initiate="canInitiateSigning"
:can-workflow="canRunTemplateWorkflow"
:can-remove="canArchiveTemplate"
:is-mock="useMockLibrary"
@preview="handlePreview"
@@ -978,6 +1025,7 @@ onMounted(() => {
:template="wordEditingTemplate"
:campuses="campuses"
:departments="departments"
:is-mock="useMockLibrary"
@close="wordEditorVisible = false"
@save="saveTemplate"
/>