feat(文书管理): 增加版本工作流与版本历史
- 模板状态按后端原始状态展示与筛选,待审核与已驳回分开展示 - 仅已发布模板可发起签署,新增提交审核/审核通过/驳回等动作 - 新增版本历史弹窗,复用统一的模板时间格式化方法 - 演示数据补齐已签署任务的签名图,并提供获取签名 dataURL 的接口 - 修复编辑器插入占位符时未定位光标导致内容落到文首的问题
This commit is contained in:
@@ -44,7 +44,14 @@ const mockRecords: DocumentRecord[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined) {
|
/**
|
||||||
|
* 后端时间戳 → 页面展示用的 `YYYY-MM-DD HH:mm`。
|
||||||
|
*
|
||||||
|
* 导出给版本历史弹窗复用:版本列表与模板列表是同一批时间字段,
|
||||||
|
* 各写一份格式化早晚会分叉出两种显示口径。解析不出来时原样返回去壳的字符串,
|
||||||
|
* 不返回 Invalid Date。
|
||||||
|
*/
|
||||||
|
export function formatTemplateDateTime(value: string | null | undefined) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -94,7 +101,7 @@ function mapDocument(dto: TemplateResponseDto): DocumentRecord {
|
|||||||
status: normalizeDocumentStatus(dto.status),
|
status: normalizeDocumentStatus(dto.status),
|
||||||
backendStatus: dto.status ?? undefined,
|
backendStatus: dto.status ?? undefined,
|
||||||
updatedBy: dto.updatedBy ?? dto.createdBy ?? '—',
|
updatedBy: dto.updatedBy ?? dto.createdBy ?? '—',
|
||||||
updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt),
|
updatedAt: formatTemplateDateTime(dto.updatedAt ?? dto.createdAt),
|
||||||
versionId,
|
versionId,
|
||||||
departmentId: dto.departmentId,
|
departmentId: dto.departmentId,
|
||||||
departmentName: dto.departmentId ? undefined : '全院通用',
|
departmentName: dto.departmentId ? undefined : '全院通用',
|
||||||
|
|||||||
@@ -195,3 +195,38 @@ export function downloadSigningArtifact(artifactId: string): Promise<Blob> {
|
|||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Blob → dataURL */
|
||||||
|
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(typeof reader.result === 'string' ? reader.result : '')
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error('签名图读取失败'))
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取回签名图并转成 dataURL,供文书渲染使用。
|
||||||
|
*
|
||||||
|
* 签名在后端是独立的 SIGNATURE_IMAGE 产物,任务接口(SignTaskResponseDto)
|
||||||
|
* **不返回任何签名字段**,所以前端必须自己把它取回来——否则文书里的签名位
|
||||||
|
* 永远是「待患者签名」,导出的 PDF 也就没有患者签名。
|
||||||
|
*
|
||||||
|
* 这里刻意转成 dataURL 而不是直接把下载地址塞给 <img>:文书导出 PDF 走
|
||||||
|
* html2canvas 截图,跨域的 <img src="http://..."> 会被画布安全策略拦掉
|
||||||
|
* (`useCORS` 只在服务端返回 CORS 响应头时才生效),表现为
|
||||||
|
* 「页面上看得到签名、导出的 PDF 里却是空的」。
|
||||||
|
*/
|
||||||
|
export async function fetchSignatureDataUrl(artifact: SigningArtifact): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await blobToDataUrl(await downloadSigningArtifact(artifact.id))
|
||||||
|
} catch {
|
||||||
|
// 取签名图失败不该让整个详情页失败:正文、审计轨迹、产物列表都还能看
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
readCachedTemplateContent,
|
readCachedTemplateContent,
|
||||||
} from '@/api/management/documents'
|
} from '@/api/management/documents'
|
||||||
import { unwrapApiResponse } from '@/utils/api-response'
|
import { unwrapApiResponse } from '@/utils/api-response'
|
||||||
|
import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature'
|
||||||
import { mockFlags } from '@/utils/mock-flags'
|
import { mockFlags } from '@/utils/mock-flags'
|
||||||
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
|
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
|
||||||
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
|
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
|
||||||
@@ -254,30 +255,39 @@ const defaultMockRecords: SigningTaskRecord[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 给演示任务补上文书正文快照。
|
* 给演示任务补齐渲染所需的数据:文书正文快照 + 已签署任务的签名图。
|
||||||
*
|
*
|
||||||
* 预置任务(以及历史上从写死模板创建的任务)只记了 documentId,没有正文快照;
|
* ① 正文:预置任务(以及历史上从写死模板创建的任务)只记了 documentId,没有正文快照;
|
||||||
* 缺正文时详情会退化成前端写死的回退纸样——内容与模板无关、没有签名位、
|
* 缺正文时详情会退化成前端写死的回退纸样——内容与模板无关、没有签名位、
|
||||||
* 也没有固定页眉。这里按 documentId 从文书库取正文补上;
|
* 也没有固定页眉。这里按 documentId 从文书库取正文补上;
|
||||||
* 实在取不到的保持原样,由详情页走回退分支,不伪造正文。
|
* 实在取不到的保持原样,由详情页走回退分支,不伪造正文。
|
||||||
|
* ② 签名图:种子任务里标了「已签署」却不带签名,会出现「列表说已签署、打开没有签名、
|
||||||
|
* 产物列表也是空的」这种自相矛盾的状态,下载的 PDF 也缺签名。
|
||||||
|
* 这里给缺签名的已签署任务补一张示意签名;真实签署过的任务已有签名图,不会被覆盖。
|
||||||
*/
|
*/
|
||||||
function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[] {
|
function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[] {
|
||||||
const templates = new Map(readLocalSigningTemplates().map((template) => [template.id, template]))
|
const templates = new Map(readLocalSigningTemplates().map((template) => [template.id, template]))
|
||||||
|
|
||||||
return records.map((record) => {
|
return records.map((record) => {
|
||||||
if (record.documentContentHtml) {
|
let next = record
|
||||||
return record
|
|
||||||
|
if (next.status === 'signed' && !next.signatureDataUrl) {
|
||||||
|
next = { ...next, signatureDataUrl: DEMO_SIGNATURE_DATA_URL }
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = templates.get(record.documentId)
|
if (next.documentContentHtml) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = templates.get(next.documentId)
|
||||||
|
|
||||||
if (!template?.contentHtml) {
|
if (!template?.contentHtml) {
|
||||||
return record
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...record,
|
...next,
|
||||||
templateVersionId: record.templateVersionId || template.versionId,
|
templateVersionId: next.templateVersionId || template.versionId,
|
||||||
documentContentHtml: template.contentHtml,
|
documentContentHtml: template.contentHtml,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 演示用的手写签名图(内联 PNG)。
|
||||||
|
*
|
||||||
|
* 演示种子任务里有「已签署」状态的任务,但种子数据不带签名图,导致
|
||||||
|
* 列表显示「已签署」、打开却没有签名、产物列表也是空的——状态与数据自相矛盾。
|
||||||
|
* 这里给它们补一张示意签名,让演示数据自洽。
|
||||||
|
*
|
||||||
|
* 笔画是抽象曲线,**不写具体姓名**:演示数据里出现一个像真人的签名,
|
||||||
|
* 等于在留痕材料上凭空造了个签署人。真实签署的签名图由签署端采集,
|
||||||
|
* 不存在这里(见 api/workbench/artifacts 的 fetchSignatureDataUrl)。
|
||||||
|
*/
|
||||||
|
export const DEMO_SIGNATURE_DATA_URL =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALAAAAA0CAYAAAAqlrKQAAABGklEQVR42u3a0ZGDQAxEQYJwNI6F/NOwI+ADaqWVUHcVAXh4YPDdcQAAAAAAAABAb5/v+bs6rJOzvSUWRivmPefAIkHhGjnvPFhm4aOCkPfcQKwUcLUbOuY8eJxIDs/QcefBrkl3TRHH7GXXxK97Y8fsNH7XzGdVEd/bya4FP7iI43d11xVx2/MqXhEjXBETGG6lWERMy3DHvoC4kJ+FW32UKSfPP0I9GMUdqE6U4r0xmK/RPsGO/QnsbUNU/iwiHf6Y0DViYXrRaRmxYGkZsWhpGbFwaRuxcGkZsbsuW1+qhMuoiIVLyYivYvQbLm1DFi6hAa0Kq0O0UVv58/SAY/JWQnS4gN8cr4txb0ij78AejewFAAAAAKT4A3Urz8xIiE95AAAAAElFTkSuQmCC'
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type SigningFieldProgress,
|
type SigningFieldProgress,
|
||||||
} from '@/utils/signing-document'
|
} from '@/utils/signing-document'
|
||||||
|
|
||||||
import { documentStatusLabels } from '../mock'
|
import { readStatusLabel, readStatusTone } from '../mock'
|
||||||
import type { DocumentTemplate } from '../types'
|
import type { DocumentTemplate } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -74,19 +74,17 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
const statusLabel = computed(() => (props.template ? readStatusLabel(props.template) : ''))
|
||||||
if (!props.template) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return documentStatusLabels[props.template.status]
|
/** 徽标配色与文书卡片同口径:草稿/待审核/已驳回/已发布/已归档各不相同 */
|
||||||
})
|
const statusTone = computed(() => (props.template ? readStatusTone(props.template) : 'draft'))
|
||||||
|
|
||||||
/** 与文书卡片保持同一口径:草稿与已归档不可发起签署 */
|
/**
|
||||||
|
* 与文书卡片保持同一口径:只有已发布(后端 APPROVED / PUBLISHED)才能发起签署。
|
||||||
|
* 不放行 review —— 它同时承载待审核与已驳回,两者都未定稿。
|
||||||
|
*/
|
||||||
const canInitiate = computed(
|
const canInitiate = computed(
|
||||||
() =>
|
() => Boolean(props.template?.contentHtml) && props.template?.status === 'published',
|
||||||
Boolean(props.template?.contentHtml) &&
|
|
||||||
(props.template?.status === 'published' || props.template?.status === 'review'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
|
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
|
||||||
@@ -179,7 +177,7 @@ function syncPreviewFields() {
|
|||||||
<div class="dialog-title">
|
<div class="dialog-title">
|
||||||
<h2 id="document-preview-title">{{ template.name }}</h2>
|
<h2 id="document-preview-title">{{ template.name }}</h2>
|
||||||
<span class="word-badge">类 Word 版式</span>
|
<span class="word-badge">类 Word 版式</span>
|
||||||
<span class="status-badge">{{ statusLabel }}</span>
|
<span class="status-badge" :class="'tone-' + statusTone">{{ statusLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="close-button" aria-label="关闭预览" @click="emit('close')">
|
<button type="button" class="close-button" aria-label="关闭预览" @click="emit('close')">
|
||||||
×
|
×
|
||||||
@@ -291,7 +289,7 @@ function syncPreviewFields() {
|
|||||||
type="button"
|
type="button"
|
||||||
class="footer-button primary"
|
class="footer-button primary"
|
||||||
:disabled="!canInitiate"
|
:disabled="!canInitiate"
|
||||||
:title="canInitiate ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署'"
|
:title="canInitiate ? '用此模板发起签署' : '仅已发布(审核通过)的模板可发起签署'"
|
||||||
@click="emit('use', template)"
|
@click="emit('use', template)"
|
||||||
>
|
>
|
||||||
用此模板发起签署
|
用此模板发起签署
|
||||||
@@ -377,7 +375,24 @@ function syncPreviewFields() {
|
|||||||
background: var(--brand-l);
|
background: var(--brand-l);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
/* 状态徽标配色:与文书卡片同一套色调,草稿/待审核/已驳回不再一律显示成绿色 */
|
||||||
|
.status-badge.tone-draft,
|
||||||
|
.status-badge.tone-archived {
|
||||||
|
color: #8a9aa3;
|
||||||
|
background: #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.tone-review {
|
||||||
|
color: var(--warn);
|
||||||
|
background: var(--warn-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.tone-rejected {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.tone-published {
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
background: var(--ok-l);
|
background: var(--ok-l);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,99 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { TemplateVersionAction } from '@/api/management/documents'
|
||||||
|
|
||||||
import type { DocumentTemplate } from '../types'
|
import type { DocumentTemplate } from '../types'
|
||||||
import { documentStatusLabels } from '../mock'
|
import { readStatusLabel, readStatusTone } from '../mock'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
template: DocumentTemplate
|
template: DocumentTemplate
|
||||||
|
/** 演示模式下「删除」是真删;真实模式下语义是下架(模板无 DELETE 接口,走归档) */
|
||||||
|
isMock: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
preview: [template: DocumentTemplate]
|
preview: [template: DocumentTemplate]
|
||||||
edit: [template: DocumentTemplate]
|
edit: [template: DocumentTemplate]
|
||||||
|
versions: [template: DocumentTemplate]
|
||||||
initiate: [template: DocumentTemplate]
|
initiate: [template: DocumentTemplate]
|
||||||
remove: [template: DocumentTemplate]
|
remove: [template: DocumentTemplate]
|
||||||
|
workflow: [template: DocumentTemplate, action: TemplateVersionAction]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
const statusLabel = computed(() => readStatusLabel(props.template))
|
||||||
|
|
||||||
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
|
/** 配色按后端状态分组取(待审核与已驳回不能共用一个颜色) */
|
||||||
const canInitiate = computed(
|
const statusTone = computed(() => readStatusTone(props.template))
|
||||||
() => props.template.status === 'published' || props.template.status === 'review',
|
|
||||||
)
|
/**
|
||||||
|
* 只有已发布(后端 APPROVED / PUBLISHED)的模板可以发起签署。
|
||||||
|
*
|
||||||
|
* 这里不再放行 review:review 同时承载后端的 PENDING_REVIEW(待审核)与
|
||||||
|
* REJECTED(已驳回),两者都还没通过审核。允许它们发起签署,
|
||||||
|
* 等于让未定稿的文书进入签署流程,签出来的文书也无法追溯是哪一版定稿。
|
||||||
|
*/
|
||||||
|
const canInitiate = computed(() => props.template.status === 'published')
|
||||||
|
|
||||||
const initiateTitle = computed(() =>
|
const initiateTitle = computed(() =>
|
||||||
canInitiate.value ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署',
|
canInitiate.value ? '用此模板发起签署' : '仅已发布(审核通过)的模板可发起签署',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本工作流主动作。
|
||||||
|
*
|
||||||
|
* 模板状态只能由后端工作流推进,前端不自行改写 status,因此这里按后端原始状态
|
||||||
|
* (backendStatus,不是归一后的 status)给出「当前该做的下一步」,把文书从草稿推到可签署。
|
||||||
|
*
|
||||||
|
* 不放在这里的两种状态:
|
||||||
|
* - APPROVED / PUBLISHED 已经可签署,走「发起」按钮;
|
||||||
|
* - ARCHIVED 是终态,没有任何可执行动作。
|
||||||
|
*/
|
||||||
|
const primaryAction = computed<{ key: TemplateVersionAction; label: string } | null>(() => {
|
||||||
|
switch (props.template.backendStatus) {
|
||||||
|
case 'DRAFT':
|
||||||
|
return { key: 'submit-review', label: '提交审核' }
|
||||||
|
case 'PENDING_REVIEW':
|
||||||
|
return { key: 'approve', label: '审核通过' }
|
||||||
|
case 'REJECTED':
|
||||||
|
return { key: 'submit-review', label: '重新提交' }
|
||||||
|
case 'DISABLED':
|
||||||
|
return { key: 'publish', label: '重新启用' }
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本工作流次要动作。
|
||||||
|
*
|
||||||
|
* 「驳回」不能只做成主动作的反面:审核通过和驳回是审核人的一对选择,
|
||||||
|
* 必须同时可见,否则待审核的文书就只能通过、不能打回。
|
||||||
|
*/
|
||||||
|
const secondaryActions = computed<
|
||||||
|
{ key: TemplateVersionAction; label: string; title: string }[]
|
||||||
|
>(() => {
|
||||||
|
if (props.template.backendStatus === 'PENDING_REVIEW') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'reject',
|
||||||
|
label: '驳回',
|
||||||
|
title: '驳回后需填写原因,科室按意见修改并重新提交',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架此文书'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="template-card">
|
<article class="template-card">
|
||||||
<div class="template-head">
|
<div class="template-head">
|
||||||
<h3 :title="template.name">{{ template.name }}</h3>
|
<h3 :title="template.name">{{ template.name }}</h3>
|
||||||
<span class="template-status" :class="'status-' + template.status">
|
<span class="template-status" :class="'status-' + statusTone">
|
||||||
<i aria-hidden="true" />
|
<i aria-hidden="true" />
|
||||||
{{ statusLabel }}
|
{{ statusLabel }}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,21 +113,49 @@ const initiateTitle = computed(() =>
|
|||||||
<button type="button" class="card-button" @click="emit('preview', template)">预览</button>
|
<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" @click="emit('edit', template)">编辑</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card-button"
|
||||||
|
title="查看该文书的版本历史"
|
||||||
|
@click="emit('versions', template)"
|
||||||
|
>
|
||||||
|
版本
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canInitiate"
|
||||||
type="button"
|
type="button"
|
||||||
class="card-button primary"
|
class="card-button primary"
|
||||||
:disabled="!canInitiate"
|
|
||||||
:title="initiateTitle"
|
:title="initiateTitle"
|
||||||
@click="emit('initiate', template)"
|
@click="emit('initiate', template)"
|
||||||
>
|
>
|
||||||
发起
|
发起
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 还不能签署时,把「当前该做的下一步」放在同一位置,避免按钮越堆越多 -->
|
||||||
|
<button
|
||||||
|
v-else-if="primaryAction"
|
||||||
|
type="button"
|
||||||
|
class="card-button primary"
|
||||||
|
:title="`${primaryAction.label}后才能发起签署`"
|
||||||
|
@click="emit('workflow', template, primaryAction.key)"
|
||||||
|
>
|
||||||
|
{{ primaryAction.label }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="action in secondaryActions"
|
||||||
|
:key="action.key"
|
||||||
|
type="button"
|
||||||
|
class="card-button"
|
||||||
|
:title="action.title"
|
||||||
|
@click="emit('workflow', template, action.key)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="card-button danger"
|
class="card-button danger"
|
||||||
title="删除此文书"
|
:title="removeTitle"
|
||||||
@click="emit('remove', template)"
|
@click="emit('remove', template)"
|
||||||
>
|
>
|
||||||
删除
|
{{ isMock ? '删除' : '下架' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +243,13 @@ const initiateTitle = computed(() =>
|
|||||||
background: var(--warn-l);
|
background: var(--warn-l);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 已驳回要和「待审核」在颜色上分开:两者归一后都是 review,
|
||||||
|
只靠文字区分时一眼扫过去仍会混为一谈 */
|
||||||
|
.status-rejected {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
.template-meta {
|
.template-meta {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -203,10 +300,14 @@ const initiateTitle = computed(() =>
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 按钮数量随状态变化(待审核时多一个「驳回」),允许换行并右对齐,
|
||||||
|
否则窄屏下最后一个按钮会被挤出卡片 */
|
||||||
.template-actions {
|
.template-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-button {
|
.card-button {
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { formatTemplateDateTime } from '@/api/management/documents'
|
||||||
|
import type { TemplateVersionResponseDto } from '@/api/management/types'
|
||||||
|
|
||||||
|
import { readBackendStatusLabel } from '../mock'
|
||||||
|
import type { DocumentTemplate } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
template: DocumentTemplate | null
|
||||||
|
versions: TemplateVersionResponseDto[]
|
||||||
|
loading: boolean
|
||||||
|
error: boolean
|
||||||
|
/** 演示文库只保存当前版本,弹窗要如实说明,别让科室以为这就是全部历史 */
|
||||||
|
isMock: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本 DTO → 展示行。
|
||||||
|
*
|
||||||
|
* 版本号优先用后端给的 versionNo;只给了自增号时补一个 `v{号}`,
|
||||||
|
* 两者都没有才显示 —,不自己编一个看起来像版本号的值。
|
||||||
|
*/
|
||||||
|
const versionRows = computed(() =>
|
||||||
|
props.versions.map((version) => ({
|
||||||
|
id: version.id,
|
||||||
|
versionNo: version.versionNo || (version.versionNumber ? `v${version.versionNumber}` : '—'),
|
||||||
|
statusLabel: readBackendStatusLabel(version.status),
|
||||||
|
isCurrent: version.id === props.template?.versionId,
|
||||||
|
createdBy: version.createdBy || '—',
|
||||||
|
createdAt: formatTemplateDateTime(version.createdAt),
|
||||||
|
reviewedAt: formatTemplateDateTime(version.reviewedAt),
|
||||||
|
publishedAt: formatTemplateDateTime(version.publishedAt),
|
||||||
|
reviewComment: version.reviewComment ?? '',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible && template" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card version-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="version-dialog-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div class="dialog-heading">
|
||||||
|
<strong id="version-dialog-title">版本历史</strong>
|
||||||
|
<span>{{ template.name }} · {{ template.code }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dialog-body">
|
||||||
|
<p v-if="loading" class="version-state">正在加载版本历史…</p>
|
||||||
|
|
||||||
|
<p v-else-if="error" class="version-state version-state--error">
|
||||||
|
版本历史加载失败,请确认后端模板版本接口可访问后重试。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else-if="versionRows.length">
|
||||||
|
<ol class="version-list">
|
||||||
|
<li v-for="row in versionRows" :key="row.id" class="version-item">
|
||||||
|
<div class="version-item__head">
|
||||||
|
<strong>{{ row.versionNo }}</strong>
|
||||||
|
<span class="version-status">{{ row.statusLabel }}</span>
|
||||||
|
<span v-if="row.isCurrent" class="version-current">当前版本</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="version-meta">
|
||||||
|
<div>
|
||||||
|
<dt>创建人</dt>
|
||||||
|
<dd>{{ row.createdBy }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>创建时间</dt>
|
||||||
|
<dd>{{ row.createdAt || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.reviewedAt">
|
||||||
|
<dt>审核时间</dt>
|
||||||
|
<dd>{{ row.reviewedAt }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.publishedAt">
|
||||||
|
<dt>发布时间</dt>
|
||||||
|
<dd>{{ row.publishedAt }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p v-if="row.reviewComment" class="version-comment">
|
||||||
|
审核意见:{{ row.reviewComment }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p v-if="isMock" class="version-hint">
|
||||||
|
演示文库只保留当前版本,因此这里只有一条。真实模式下会列出该文书的全部历史版本。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else class="version-state">该文书还没有版本记录。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span>已签署的文书引用的是具体版本,历史版本不会被覆盖,可随时追溯。</span>
|
||||||
|
<button type="button" class="action-button" @click="emit('close')">关闭</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
display: flex;
|
||||||
|
width: 560px;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
flex-direction: column;
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-heading {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-heading strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-heading span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-state {
|
||||||
|
padding: 28px 0;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-state--error {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item__head strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-status {
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-current {
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--ok-l);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 18px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-meta div {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-meta dt {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-comment {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 9px 0 0;
|
||||||
|
color: #a15c19;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
background: var(--warn-l);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-hint {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer span {
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,8 +16,26 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const editorRef = shallowRef<IDomEditor>()
|
const editorRef = shallowRef<IDomEditor>()
|
||||||
|
const editableRef = shallowRef<Element | null>(null)
|
||||||
const editorMode = 'default'
|
const editorMode = 'default'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户是否真的把光标放进过正文。
|
||||||
|
*
|
||||||
|
* 点「插入占位符 / 勾选框」按钮时焦点在按钮上,编辑器处于失焦态;
|
||||||
|
* 直接 insertNode 会落到**文档最开头**——导入 .docx 后补签名位,
|
||||||
|
* 签名域会被插到标题之前,而且当场看不出来,要等到预览或签署才发现。
|
||||||
|
*
|
||||||
|
* 不能用 editor.selection 判断「有没有光标」:导入 .docx 写入内容时它同样会有一个
|
||||||
|
* 位于文首的选区,照样把签名位插到开头。因此只认真实交互——
|
||||||
|
* 在可编辑区域上发生过 pointerdown / keydown 才算用户放好了光标。
|
||||||
|
*/
|
||||||
|
const userPlacedCaret = shallowRef(false)
|
||||||
|
|
||||||
|
function markUserCaret() {
|
||||||
|
userPlacedCaret.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 医疗文书用不到的工具栏能力直接移除,避免科室误用。
|
// 医疗文书用不到的工具栏能力直接移除,避免科室误用。
|
||||||
// 注意菜单 key:分割线是 divider、表情是 emotion(不是 emoji)。
|
// 注意菜单 key:分割线是 divider、表情是 emotion(不是 emoji)。
|
||||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||||
@@ -64,6 +82,11 @@ const editorConfig: Partial<IEditorConfig> = {
|
|||||||
|
|
||||||
function handleCreated(editor: IDomEditor) {
|
function handleCreated(editor: IDomEditor) {
|
||||||
editorRef.value = editor
|
editorRef.value = editor
|
||||||
|
|
||||||
|
const editable = editor.getEditableContainer()
|
||||||
|
editableRef.value = editable
|
||||||
|
editable.addEventListener('pointerdown', markUserCaret)
|
||||||
|
editable.addEventListener('keydown', markUserCaret)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getText(): string {
|
function getText(): string {
|
||||||
@@ -72,12 +95,23 @@ function getText(): string {
|
|||||||
|
|
||||||
// slate 文本节点:{ text };由编辑器在当前光标处插入。
|
// slate 文本节点:{ text };由编辑器在当前光标处插入。
|
||||||
function insertText(text: string) {
|
function insertText(text: string) {
|
||||||
editorRef.value?.insertNode({ text })
|
const editor = editorRef.value
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先聚焦并定位光标再插入:用户没点过正文时落到文末,
|
||||||
|
// 点过则保持他自己的光标位置(光标停在表格单元格里时内容就落进该单元格)。
|
||||||
|
editor.focus(!userPlacedCaret.value)
|
||||||
|
editor.insertNode({ text })
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ getText, insertText })
|
defineExpose({ getText, insertText })
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
editableRef.value?.removeEventListener('pointerdown', markUserCaret)
|
||||||
|
editableRef.value?.removeEventListener('keydown', markUserCaret)
|
||||||
editorRef.value?.destroy()
|
editorRef.value?.destroy()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
getDocuments,
|
getDocuments,
|
||||||
getTemplateContent,
|
getTemplateContent,
|
||||||
|
getTemplateVersions,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
updateTemplateContent,
|
updateTemplateContent,
|
||||||
|
updateTemplateVersionStatus,
|
||||||
|
type TemplateVersionAction,
|
||||||
} from '@/api/management/documents'
|
} from '@/api/management/documents'
|
||||||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||||
import type {
|
import type {
|
||||||
@@ -17,6 +20,7 @@ import type {
|
|||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
DocumentRecord,
|
DocumentRecord,
|
||||||
DepartmentRecord,
|
DepartmentRecord,
|
||||||
|
TemplateVersionResponseDto,
|
||||||
} from '@/api/management/types'
|
} from '@/api/management/types'
|
||||||
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
||||||
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||||
@@ -29,10 +33,18 @@ import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
|
|||||||
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
|
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
|
||||||
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
|
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
|
||||||
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
|
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
|
||||||
|
import DocumentVersionDialog from './components/DocumentVersionDialog.vue'
|
||||||
import WordEditorDialog from './components/WordEditorDialog.vue'
|
import WordEditorDialog from './components/WordEditorDialog.vue'
|
||||||
import { documentStatusOptions, loadDocumentLibrary, saveDocumentLibrary } from './mock'
|
import {
|
||||||
|
backendStatusLabels,
|
||||||
|
backendStatusOrder,
|
||||||
|
loadDocumentLibrary,
|
||||||
|
readBackendStatus,
|
||||||
|
saveDocumentLibrary,
|
||||||
|
} from './mock'
|
||||||
import type {
|
import type {
|
||||||
DepartmentTab,
|
DepartmentTab,
|
||||||
|
DocumentBackendStatus,
|
||||||
DocumentDepartment,
|
DocumentDepartment,
|
||||||
DocumentFilterForm,
|
DocumentFilterForm,
|
||||||
DocumentTemplate,
|
DocumentTemplate,
|
||||||
@@ -70,6 +82,13 @@ const wordEditorLoading = ref(false)
|
|||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
const previewError = ref(false)
|
const previewError = ref(false)
|
||||||
|
|
||||||
|
/** 版本历史弹窗 */
|
||||||
|
const versionDialogVisible = ref(false)
|
||||||
|
const versionDialogTemplate = ref<DocumentTemplate | null>(null)
|
||||||
|
const versionRecords = ref<TemplateVersionResponseDto[]>([])
|
||||||
|
const versionLoading = ref(false)
|
||||||
|
const versionError = ref(false)
|
||||||
|
|
||||||
const campuses = ref<CampusRecord[]>([])
|
const campuses = ref<CampusRecord[]>([])
|
||||||
const departments = ref<DepartmentRecord[]>([])
|
const departments = ref<DepartmentRecord[]>([])
|
||||||
|
|
||||||
@@ -102,20 +121,42 @@ const departmentTabs = computed<DepartmentTab[]>(() => [
|
|||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
|
|
||||||
const statusTabs = computed<StatusTab[]>(() => [
|
/**
|
||||||
{ key: 'all', label: '全部', count: allTemplates.value.length },
|
* 状态页签按后端原始状态归类,而不是归一后的 draft / review / published / archived。
|
||||||
...documentStatusOptions.map((option) => ({
|
*
|
||||||
...option,
|
* 归一会把 PENDING_REVIEW(待审核)和 REJECTED(已驳回)并成 review,
|
||||||
count: allTemplates.value.filter((template) => template.status === option.key).length,
|
* 页签上只能显示一个含糊的「需复审」:一个是等着审、一个是被打回,
|
||||||
})),
|
* 科室该做的事完全不同,混在一个页签里就分不出来了。
|
||||||
])
|
*
|
||||||
|
* 只列出库里实际存在的状态:7 个状态全摆出来,多数时候是 0 条的噪声。
|
||||||
|
*/
|
||||||
|
const statusTabs = computed<StatusTab[]>(() => {
|
||||||
|
const counts = new Map<DocumentBackendStatus, number>()
|
||||||
|
|
||||||
|
allTemplates.value.forEach((template) => {
|
||||||
|
const status = readBackendStatus(template)
|
||||||
|
counts.set(status, (counts.get(status) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'all', label: '全部', count: allTemplates.value.length },
|
||||||
|
...backendStatusOrder
|
||||||
|
.filter((status) => counts.has(status))
|
||||||
|
.map((status) => ({
|
||||||
|
key: status,
|
||||||
|
label: backendStatusLabels[status],
|
||||||
|
count: counts.get(status) ?? 0,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
const filteredTemplates = computed(() => {
|
||||||
const keyword = filters.keyword.trim().toLowerCase()
|
const keyword = filters.keyword.trim().toLowerCase()
|
||||||
|
|
||||||
return allTemplates.value.filter((template) => {
|
return allTemplates.value.filter((template) => {
|
||||||
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
||||||
const matchesStatus = filters.status === 'all' || template.status === filters.status
|
const matchesStatus =
|
||||||
|
filters.status === 'all' || readBackendStatus(template) === filters.status
|
||||||
const matchesKeyword =
|
const matchesKeyword =
|
||||||
!keyword ||
|
!keyword ||
|
||||||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
||||||
@@ -461,28 +502,181 @@ async function saveTemplate(form: TemplateEditorForm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let savedId = form.id
|
||||||
|
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上;
|
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上。
|
||||||
// 新版本默认是草稿,这里顺带发布,保证编辑完就能直接发起签署
|
// 新版本的状态由后端决定,前端不在这里「顺带发布」——发布是需要留痕的正式动作,
|
||||||
|
// 要由人显式触发(见 handleWorkflow),否则「谁把这份文书放行到可签署」无从追溯。
|
||||||
await updateTemplateContent(form.id, toVersionPayload(form))
|
await updateTemplateContent(form.id, toVersionPayload(form))
|
||||||
await updateTemplate(form.id, {
|
await updateTemplate(form.id, {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
category: form.category,
|
category: form.category,
|
||||||
})
|
})
|
||||||
ElMessage.success('文书已更新并发布新版本')
|
|
||||||
} else {
|
} else {
|
||||||
await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
|
const created = await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
|
||||||
ElMessage.success('文书已创建')
|
savedId = created.id
|
||||||
}
|
}
|
||||||
|
|
||||||
closeWordEditor()
|
closeWordEditor()
|
||||||
await loadLibrary()
|
await loadLibrary()
|
||||||
|
|
||||||
|
// 提示按后端返回的真实状态给:新版本/新模板若是草稿,还需要走工作流才能签署。
|
||||||
|
// 之前这里无条件说「已发布新版本」,但代码里并没有发布动作,属于假提示。
|
||||||
|
const saved = savedId ? allTemplates.value.find((item) => item.id === savedId) : undefined
|
||||||
|
const canSignNow = !saved || saved.status === 'published'
|
||||||
|
|
||||||
|
if (canSignNow) {
|
||||||
|
ElMessage.success(form.id ? '文书已更新并发布新版本' : '文书已创建')
|
||||||
|
} else {
|
||||||
|
ElMessage.success(
|
||||||
|
form.id
|
||||||
|
? '文书已保存为新版本,提交审核并通过后才能发起签署'
|
||||||
|
: '文书已创建,提交审核并通过后才能发起签署',
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
|
ElMessage.error(form.id ? '文书更新失败,请稍后重试' : '文书创建失败,请稍后重试')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 版本工作流动作的中文名,用于确认框与结果提示 */
|
||||||
|
const workflowActionLabels: Record<TemplateVersionAction, string> = {
|
||||||
|
'submit-review': '提交审核',
|
||||||
|
reject: '驳回',
|
||||||
|
approve: '审核通过',
|
||||||
|
publish: '发布',
|
||||||
|
disable: '停用',
|
||||||
|
archive: '归档',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集驳回原因。
|
||||||
|
*
|
||||||
|
* 驳回是要留痕的正式动作:只把状态改成「已驳回」而不写为什么,科室拿到驳回
|
||||||
|
* 也不知道该改哪里,只能重开一版碰运气。因此原因必填,并随动作提交给后端存档。
|
||||||
|
*/
|
||||||
|
async function promptRejectReason(template: DocumentTemplate): Promise<string | null> {
|
||||||
|
return ElMessageBox.prompt(
|
||||||
|
`请说明驳回「${template.name}」的原因,科室会按这条意见修改后重新提交。`,
|
||||||
|
'驳回文书',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认驳回',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputPlaceholder: '如:缺少麻醉方式选择项,风险告知段落需补充',
|
||||||
|
inputValidator: (value: string) => (value?.trim() ? true : '驳回原因不能为空'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((result) => result.value.trim())
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推进版本工作流。
|
||||||
|
*
|
||||||
|
* 模板状态只能由后端工作流改写,前端不自行设置 status——这是留痕要求:
|
||||||
|
* 「谁在什么时候把这份文书放行到可签署」必须由后端记录,不能由前端拼一个状态提交。
|
||||||
|
* 因此「提交审核 / 审核通过 / 驳回 / 重新启用」这类动作统一走这里。
|
||||||
|
*/
|
||||||
|
async function handleWorkflow(template: DocumentTemplate, action: TemplateVersionAction) {
|
||||||
|
if (useMockLibrary) {
|
||||||
|
ElMessage.info('演示模式没有审核流程,文书可直接发起签署')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template.versionId) {
|
||||||
|
ElMessage.error('该文书没有可操作的版本,请刷新后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = workflowActionLabels[action]
|
||||||
|
let reason: string | undefined
|
||||||
|
|
||||||
|
if (action === 'reject') {
|
||||||
|
const rejectReason = await promptRejectReason(template)
|
||||||
|
|
||||||
|
if (!rejectReason) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reason = rejectReason
|
||||||
|
} else {
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
`确定对文书「${template.name}」执行「${label}」?`,
|
||||||
|
`确认${label}`,
|
||||||
|
{ type: 'warning', confirmButtonText: label, cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateTemplateVersionStatus(template.id, template.versionId, action, reason)
|
||||||
|
ElMessage.success(`已${label}`)
|
||||||
|
await loadLibrary()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(`${label}失败,请稍后重试`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查看版本历史。
|
||||||
|
*
|
||||||
|
* 模板列表只显示当前版本号(V2.1 这类),「这份文书改过几版、每版谁在什么时候
|
||||||
|
* 提交/通过/驳回」此前没有任何入口——被驳回后科室只能看到状态变了,看不到为什么。
|
||||||
|
* 这里补上版本列表,驳回意见也一并展示。
|
||||||
|
*
|
||||||
|
* 演示文库只保存当前版本,因此演示模式合成一条并标注,不假装有一份完整历史。
|
||||||
|
*/
|
||||||
|
async function openVersions(template: DocumentTemplate) {
|
||||||
|
versionDialogTemplate.value = template
|
||||||
|
versionDialogVisible.value = true
|
||||||
|
versionRecords.value = []
|
||||||
|
versionError.value = false
|
||||||
|
|
||||||
|
if (useMockLibrary) {
|
||||||
|
versionLoading.value = false
|
||||||
|
versionRecords.value = template.versionId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: template.versionId,
|
||||||
|
templateId: template.id,
|
||||||
|
versionNo: template.version,
|
||||||
|
contentHtml: template.contentHtml,
|
||||||
|
contentSha256: template.contentSha256,
|
||||||
|
status: template.backendStatus,
|
||||||
|
createdAt: template.updatedAt,
|
||||||
|
createdBy: template.updatedBy,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
versionRecords.value = await getTemplateVersions(template.id)
|
||||||
|
} catch {
|
||||||
|
versionError.value = true
|
||||||
|
} finally {
|
||||||
|
versionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVersions() {
|
||||||
|
versionDialogVisible.value = false
|
||||||
|
versionDialogTemplate.value = null
|
||||||
|
versionRecords.value = []
|
||||||
|
versionError.value = false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除文书。
|
* 删除文书。
|
||||||
* 模板没有物理删除接口:它可能已被历史签署任务引用,删掉会让留痕链断裂。
|
* 模板没有物理删除接口:它可能已被历史签署任务引用,删掉会让留痕链断裂。
|
||||||
@@ -620,6 +814,8 @@ async function resetMockData() {
|
|||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
filters.keyword = ''
|
filters.keyword = ''
|
||||||
filters.department = null
|
filters.department = null
|
||||||
|
// status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」
|
||||||
|
filters.status = 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTemplate(template: DocumentTemplate) {
|
function useTemplate(template: DocumentTemplate) {
|
||||||
@@ -684,10 +880,13 @@ onMounted(() => {
|
|||||||
v-for="template in filteredTemplates"
|
v-for="template in filteredTemplates"
|
||||||
:key="template.id"
|
:key="template.id"
|
||||||
:template="template"
|
:template="template"
|
||||||
|
:is-mock="useMockLibrary"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
@edit="showEditMessage"
|
@edit="showEditMessage"
|
||||||
|
@versions="openVersions"
|
||||||
@initiate="openSigningDialog"
|
@initiate="openSigningDialog"
|
||||||
@remove="removeTemplate"
|
@remove="removeTemplate"
|
||||||
|
@workflow="handleWorkflow"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -699,7 +898,7 @@ onMounted(() => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<p>没有符合条件的文书</p>
|
<p>没有符合条件的文书</p>
|
||||||
<button
|
<button
|
||||||
v-if="filters.keyword || filters.department"
|
v-if="filters.keyword || filters.department || filters.status !== 'all'"
|
||||||
type="button"
|
type="button"
|
||||||
class="reset-button"
|
class="reset-button"
|
||||||
@click="clearFilters"
|
@click="clearFilters"
|
||||||
@@ -731,6 +930,15 @@ onMounted(() => {
|
|||||||
@close="wordEditorVisible = false"
|
@close="wordEditorVisible = false"
|
||||||
@save="saveTemplate"
|
@save="saveTemplate"
|
||||||
/>
|
/>
|
||||||
|
<DocumentVersionDialog
|
||||||
|
:visible="versionDialogVisible"
|
||||||
|
:template="versionDialogTemplate"
|
||||||
|
:versions="versionRecords"
|
||||||
|
:loading="versionLoading"
|
||||||
|
:error="versionError"
|
||||||
|
:is-mock="useMockLibrary"
|
||||||
|
@close="closeVersions"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 -->
|
<!-- 取正文期间给一层轻量遮罩,避免科室在编辑器打开前误点其它操作 -->
|
||||||
<div v-if="wordEditorLoading" class="editor-loading" role="status">
|
<div v-if="wordEditorLoading" class="editor-loading" role="status">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { DocumentRecord } from '@/api/management/types'
|
|||||||
import type { LocalDocument, LocalDepartment } from '@/utils/mock-document-library'
|
import type { LocalDocument, LocalDepartment } from '@/utils/mock-document-library'
|
||||||
import { readDocumentLibrary, writeDocumentLibrary } from '@/utils/mock-document-library'
|
import { readDocumentLibrary, writeDocumentLibrary } from '@/utils/mock-document-library'
|
||||||
|
|
||||||
import type { DocumentDepartment, DocumentStatus } from './types'
|
import type { DocumentDepartment, DocumentBackendStatus, DocumentStatus } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 演示文库与页面模型之间的归一。
|
* 演示文库与页面模型之间的归一。
|
||||||
@@ -108,6 +108,119 @@ export const documentStatusLabels: Record<DocumentStatus, string> = {
|
|||||||
archived: '已归档',
|
archived: '已归档',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const documentStatusOptions = (
|
/**
|
||||||
Object.entries(documentStatusLabels) as [DocumentStatus, string][]
|
* 后端原始状态 → 中文标签。
|
||||||
).map(([key, label]) => ({ key, label }))
|
*
|
||||||
|
* 卡片与预览弹窗用它,而不是归一后的 documentStatusLabels:归一过程把
|
||||||
|
* PENDING_REVIEW(待审核)和 REJECTED(已驳回)都并成了 review,
|
||||||
|
* 只看「需复审」分不清是「还等着审」还是「已经被打回」。
|
||||||
|
*/
|
||||||
|
export const backendStatusLabels: Record<DocumentBackendStatus, string> = {
|
||||||
|
DRAFT: '草稿',
|
||||||
|
PENDING_REVIEW: '待审核',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
APPROVED: '审核通过',
|
||||||
|
PUBLISHED: '已发布',
|
||||||
|
DISABLED: '已停用',
|
||||||
|
ARCHIVED: '已归档',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态页签的固定展示顺序:按文书生命周期排列(草稿 → 送审 → 驳回/通过 → 发布 → 停用 → 归档)。
|
||||||
|
* 固定顺序而不是按接口返回顺序,否则同一个库里状态一多,页签会来回跳。
|
||||||
|
*/
|
||||||
|
export const backendStatusOrder: DocumentBackendStatus[] = [
|
||||||
|
'DRAFT',
|
||||||
|
'PENDING_REVIEW',
|
||||||
|
'REJECTED',
|
||||||
|
'APPROVED',
|
||||||
|
'PUBLISHED',
|
||||||
|
'DISABLED',
|
||||||
|
'ARCHIVED',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 取状态标签:优先用后端原始状态(更精确),拿不到再退回归一后的状态 */
|
||||||
|
export function readStatusLabel(template: {
|
||||||
|
status: DocumentStatus
|
||||||
|
backendStatus?: string
|
||||||
|
}): string {
|
||||||
|
const backendLabel = template.backendStatus
|
||||||
|
? backendStatusLabels[template.backendStatus as DocumentBackendStatus]
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return backendLabel ?? documentStatusLabels[template.status]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按后端状态取标签。
|
||||||
|
* 版本历史只有后端状态,没有归一后的 status,因此单独开一个入口。
|
||||||
|
* 遇到未知状态原样显示,不显示成空白——后端新增状态时至少能看见它是什么。
|
||||||
|
*/
|
||||||
|
export function readBackendStatusLabel(status: string | null | undefined): string {
|
||||||
|
if (!status) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return backendStatusLabels[status as DocumentBackendStatus] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态色调:决定状态徽标用哪一套配色。
|
||||||
|
*
|
||||||
|
* 不能直接用归一后的 DocumentStatus 当配色键——PENDING_REVIEW 与 REJECTED
|
||||||
|
* 归一后都是 review,两者会共用同一个颜色,页签与文字已经区分开了、颜色却还糊在一起。
|
||||||
|
*/
|
||||||
|
export type DocumentStatusTone = 'draft' | 'review' | 'rejected' | 'published' | 'archived'
|
||||||
|
|
||||||
|
export function readStatusTone(template: {
|
||||||
|
status: DocumentStatus
|
||||||
|
backendStatus?: string
|
||||||
|
}): DocumentStatusTone {
|
||||||
|
switch (readBackendStatus(template)) {
|
||||||
|
case 'PENDING_REVIEW':
|
||||||
|
return 'review'
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'rejected'
|
||||||
|
case 'APPROVED':
|
||||||
|
case 'PUBLISHED':
|
||||||
|
return 'published'
|
||||||
|
case 'DISABLED':
|
||||||
|
case 'ARCHIVED':
|
||||||
|
return 'archived'
|
||||||
|
case 'DRAFT':
|
||||||
|
default:
|
||||||
|
return 'draft'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取文书的后端原始状态,供状态页签归类。
|
||||||
|
*
|
||||||
|
* 真实模式的列表接口会给 backendStatus;演示库与部分历史数据可能没有,
|
||||||
|
* 这时按归一后的 status 反推一个等价状态,避免这类文书在页签里被漏掉。
|
||||||
|
*
|
||||||
|
* 反推对 'review' 是有损的:归一过程把 PENDING_REVIEW 与 REJECTED 合并了,
|
||||||
|
* 只能归到 PENDING_REVIEW。这只是缺字段时的兜底,不改变页签按真实状态过滤的口径。
|
||||||
|
*/
|
||||||
|
export function readBackendStatus(template: {
|
||||||
|
status: DocumentStatus
|
||||||
|
backendStatus?: string
|
||||||
|
}): DocumentBackendStatus {
|
||||||
|
const known = backendStatusOrder.find((status) => status === template.backendStatus)
|
||||||
|
|
||||||
|
if (known) {
|
||||||
|
return known
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (template.status) {
|
||||||
|
case 'published':
|
||||||
|
return 'PUBLISHED'
|
||||||
|
case 'review':
|
||||||
|
return 'PENDING_REVIEW'
|
||||||
|
case 'archived':
|
||||||
|
return 'ARCHIVED'
|
||||||
|
case 'draft':
|
||||||
|
default:
|
||||||
|
return 'DRAFT'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,7 +47,31 @@ export interface TemplateEditorForm {
|
|||||||
contentHtml: string
|
contentHtml: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentFilterStatus = 'all' | DocumentStatus
|
/**
|
||||||
|
* 模板版本的后端原始状态。
|
||||||
|
*
|
||||||
|
* 逐字列出而不是直接复用 api/management/types.ts 的 BackendTemplateStatus:
|
||||||
|
* 后者带 `| string` 兜底(后端将来新增状态时前端不至于编译不过),
|
||||||
|
* 直接拿来当过滤键会把类型收窄成 string,页签取值就失去约束了。
|
||||||
|
* 这里列的是已知枚举,未知状态由 readBackendStatus 兜底归类。
|
||||||
|
*/
|
||||||
|
export type DocumentBackendStatus =
|
||||||
|
| 'DRAFT'
|
||||||
|
| 'PENDING_REVIEW'
|
||||||
|
| 'REJECTED'
|
||||||
|
| 'APPROVED'
|
||||||
|
| 'PUBLISHED'
|
||||||
|
| 'DISABLED'
|
||||||
|
| 'ARCHIVED'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态筛选的取值:按后端原始状态过滤,而不是按归一后的 DocumentStatus。
|
||||||
|
*
|
||||||
|
* 归一过程把 PENDING_REVIEW(待审核)和 REJECTED(已驳回)都并成 review,
|
||||||
|
* 用归一键做页签会把两种完全不同的处境混在一个「需复审」里:
|
||||||
|
* 一个是还等着审,一个是已经被打回,科室要做的下一步完全不同。
|
||||||
|
*/
|
||||||
|
export type DocumentFilterStatus = 'all' | DocumentBackendStatus
|
||||||
|
|
||||||
export interface StatusTab {
|
export interface StatusTab {
|
||||||
key: DocumentFilterStatus
|
key: DocumentFilterStatus
|
||||||
|
|||||||
@@ -113,7 +113,9 @@ defineExpose({ getDocumentElement })
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="task.status === 'expired'">
|
<!-- 处理失败与已超时都需要「重新发起」这条出路:只给「作废」的话,
|
||||||
|
一次偶发的后端处理失败就得把整个任务废掉重来 -->
|
||||||
|
<template v-else-if="task.status === 'expired' || task.status === 'failed'">
|
||||||
<button type="button" class="action-button" @click="emit('action', 'reopen')">
|
<button type="button" class="action-button" @click="emit('action', 'reopen')">
|
||||||
↻ 重新发起
|
↻ 重新发起
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||||
import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts'
|
import {
|
||||||
|
downloadSigningArtifact,
|
||||||
|
fetchSignatureDataUrl,
|
||||||
|
getSigningArtifacts,
|
||||||
|
} from '@/api/workbench/artifacts'
|
||||||
import { createPadSigningSession } from '@/api/workbench/deliveries'
|
import { createPadSigningSession } from '@/api/workbench/deliveries'
|
||||||
import {
|
import {
|
||||||
completeSigningTask,
|
completeSigningTask,
|
||||||
@@ -279,6 +283,23 @@ async function loadTaskDetail(id: string | null) {
|
|||||||
} else {
|
} else {
|
||||||
artifactsError.value = true
|
artifactsError.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务接口不返回签名,签名图是独立的 SIGNATURE_IMAGE 产物;
|
||||||
|
// 这里把它取回来转成 dataURL 补到任务上,文书里的签名位才有内容。
|
||||||
|
// 取不到就保持原样(渲染成「待患者签名」),不伪造一个签名糊上去。
|
||||||
|
if (detail && !detail.signatureDataUrl) {
|
||||||
|
const signatureArtifact = artifacts.value.find(
|
||||||
|
(item) => item.artifactType === 'SIGNATURE_IMAGE',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (signatureArtifact) {
|
||||||
|
const signatureDataUrl = await fetchSignatureDataUrl(signatureArtifact)
|
||||||
|
|
||||||
|
if (requestId === detailRequestId && signatureDataUrl) {
|
||||||
|
selectedTask.value = { ...detail, signatureDataUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (requestId === detailRequestId) {
|
if (requestId === detailRequestId) {
|
||||||
@@ -459,8 +480,8 @@ async function handleTaskAction(action: SigningTaskAction) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'reopen' && task.status !== 'expired') {
|
if (action === 'reopen' && task.status !== 'expired' && task.status !== 'failed') {
|
||||||
ElMessage.info('只有已超时任务可以重新发起')
|
ElMessage.info('只有已超时或处理失败的任务可以重新发起')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user