feat(文书管理): 增加版本工作流与版本历史

- 模板状态按后端原始状态展示与筛选,待审核与已驳回分开展示
- 仅已发布模板可发起签署,新增提交审核/审核通过/驳回等动作
- 新增版本历史弹窗,复用统一的模板时间格式化方法
- 演示数据补齐已签署任务的签名图,并提供获取签名 dataURL 的接口
- 修复编辑器插入占位符时未定位光标导致内容落到文首的问题
This commit is contained in:
yelan
2026-09-17 16:44:54 +08:00
parent 81922d774f
commit 97c5182ba4
13 changed files with 982 additions and 64 deletions
+9 -2
View File
@@ -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) {
return ''
}
@@ -94,7 +101,7 @@ function mapDocument(dto: TemplateResponseDto): DocumentRecord {
status: normalizeDocumentStatus(dto.status),
backendStatus: dto.status ?? undefined,
updatedBy: dto.updatedBy ?? dto.createdBy ?? '—',
updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt),
updatedAt: formatTemplateDateTime(dto.updatedAt ?? dto.createdAt),
versionId,
departmentId: dto.departmentId,
departmentName: dto.departmentId ? undefined : '全院通用',
@@ -195,3 +195,38 @@ export function downloadSigningArtifact(artifactId: string): Promise<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 ''
}
}
+18 -8
View File
@@ -5,6 +5,7 @@ import {
readCachedTemplateContent,
} from '@/api/management/documents'
import { unwrapApiResponse } from '@/utils/api-response'
import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature'
import { mockFlags } from '@/utils/mock-flags'
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
@@ -254,30 +255,39 @@ const defaultMockRecords: SigningTaskRecord[] = [
]
/**
* 给演示任务补上文书正文快照。
* 给演示任务补齐渲染所需的数据:文书正文快照 + 已签署任务的签名图。
*
* 预置任务(以及历史上从写死模板创建的任务)只记了 documentId,没有正文快照;
* ① 正文:预置任务(以及历史上从写死模板创建的任务)只记了 documentId,没有正文快照;
* 缺正文时详情会退化成前端写死的回退纸样——内容与模板无关、没有签名位、
* 也没有固定页眉。这里按 documentId 从文书库取正文补上;
* 实在取不到的保持原样,由详情页走回退分支,不伪造正文。
* ② 签名图:种子任务里标了「已签署」却不带签名,会出现「列表说已签署、打开没有签名、
* 产物列表也是空的」这种自相矛盾的状态,下载的 PDF 也缺签名。
* 这里给缺签名的已签署任务补一张示意签名;真实签署过的任务已有签名图,不会被覆盖。
*/
function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[] {
const templates = new Map(readLocalSigningTemplates().map((template) => [template.id, template]))
return records.map((record) => {
if (record.documentContentHtml) {
return record
let next = 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) {
return record
return next
}
return {
...record,
templateVersionId: record.templateVersionId || template.versionId,
...next,
templateVersionId: next.templateVersionId || template.versionId,
documentContentHtml: template.contentHtml,
}
})
+13
View File
@@ -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,
} from '@/utils/signing-document'
import { documentStatusLabels } from '../mock'
import { readStatusLabel, readStatusTone } from '../mock'
import type { DocumentTemplate } from '../types'
const props = defineProps<{
@@ -74,19 +74,17 @@ watch(
},
)
const statusLabel = computed(() => {
if (!props.template) {
return ''
}
const statusLabel = computed(() => (props.template ? readStatusLabel(props.template) : ''))
return documentStatusLabels[props.template.status]
})
/** 徽标配色与文书卡片同口径:草稿/待审核/已驳回/已发布/已归档各不相同 */
const statusTone = computed(() => (props.template ? readStatusTone(props.template) : 'draft'))
/** 与文书卡片保持同一口径:草稿与已归档不可发起签署 */
/**
* 与文书卡片保持同一口径:只有已发布(后端 APPROVED / PUBLISHED)才能发起签署。
* 不放行 review —— 它同时承载待审核与已驳回,两者都未定稿。
*/
const canInitiate = computed(
() =>
Boolean(props.template?.contentHtml) &&
(props.template?.status === 'published' || props.template?.status === 'review'),
() => Boolean(props.template?.contentHtml) && props.template?.status === 'published',
)
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
@@ -179,7 +177,7 @@ function syncPreviewFields() {
<div class="dialog-title">
<h2 id="document-preview-title">{{ template.name }}</h2>
<span class="word-badge">类 Word 版式</span>
<span class="status-badge">{{ statusLabel }}</span>
<span class="status-badge" :class="'tone-' + statusTone">{{ statusLabel }}</span>
</div>
<button type="button" class="close-button" aria-label="关闭预览" @click="emit('close')">
×
@@ -291,7 +289,7 @@ function syncPreviewFields() {
type="button"
class="footer-button primary"
:disabled="!canInitiate"
:title="canInitiate ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署'"
:title="canInitiate ? '用此模板发起签署' : '仅已发布(审核通过)的模板可发起签署'"
@click="emit('use', template)"
>
用此模板发起签署
@@ -377,7 +375,24 @@ function syncPreviewFields() {
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);
background: var(--ok-l);
}
@@ -1,37 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TemplateVersionAction } from '@/api/management/documents'
import type { DocumentTemplate } from '../types'
import { documentStatusLabels } from '../mock'
import { readStatusLabel, readStatusTone } from '../mock'
const props = defineProps<{
template: DocumentTemplate
/** 演示模式下「删除」是真删;真实模式下语义是下架(模板无 DELETE 接口,走归档) */
isMock: boolean
}>()
const emit = defineEmits<{
preview: [template: DocumentTemplate]
edit: [template: DocumentTemplate]
versions: [template: DocumentTemplate]
initiate: [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(
() => props.template.status === 'published' || props.template.status === 'review',
)
/** 配色按后端状态分组取(待审核与已驳回不能共用一个颜色) */
const statusTone = computed(() => readStatusTone(props.template))
/**
* 只有已发布(后端 APPROVED / PUBLISHED)的模板可以发起签署。
*
* 这里不再放行 review:review 同时承载后端的 PENDING_REVIEW(待审核)与
* REJECTED(已驳回),两者都还没通过审核。允许它们发起签署,
* 等于让未定稿的文书进入签署流程,签出来的文书也无法追溯是哪一版定稿。
*/
const canInitiate = computed(() => props.template.status === 'published')
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>
<template>
<article class="template-card">
<div class="template-head">
<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" />
{{ statusLabel }}
</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('edit', template)">编辑</button>
<button
type="button"
class="card-button"
title="查看该文书的版本历史"
@click="emit('versions', template)"
>
版本
</button>
<button
v-if="canInitiate"
type="button"
class="card-button primary"
:disabled="!canInitiate"
:title="initiateTitle"
@click="emit('initiate', template)"
>
发起
</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
type="button"
class="card-button danger"
title="删除此文书"
:title="removeTitle"
@click="emit('remove', template)"
>
删除
{{ isMock ? '删除' : '下架' }}
</button>
</div>
</div>
@@ -153,6 +243,13 @@ const initiateTitle = computed(() =>
background: var(--warn-l);
}
/* 已驳回要和「待审核」在颜色上分开:两者归一后都是 review,
只靠文字区分时一眼扫过去仍会混为一谈 */
.status-rejected {
color: var(--err);
background: var(--err-l);
}
.template-meta {
margin: 0;
overflow: hidden;
@@ -203,10 +300,14 @@ const initiateTitle = computed(() =>
border-radius: 50%;
}
/* 按钮数量随状态变化(待审核时多一个「驳回」),允许换行并右对齐,
否则窄屏下最后一个按钮会被挤出卡片 */
.template-actions {
display: flex;
flex-shrink: 0;
flex: 1 1 auto;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.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 editableRef = shallowRef<Element | null>(null)
const editorMode = 'default'
/**
* 用户是否真的把光标放进过正文。
*
* 点「插入占位符 / 勾选框」按钮时焦点在按钮上,编辑器处于失焦态;
* 直接 insertNode 会落到**文档最开头**——导入 .docx 后补签名位,
* 签名域会被插到标题之前,而且当场看不出来,要等到预览或签署才发现。
*
* 不能用 editor.selection 判断「有没有光标」:导入 .docx 写入内容时它同样会有一个
* 位于文首的选区,照样把签名位插到开头。因此只认真实交互——
* 在可编辑区域上发生过 pointerdown / keydown 才算用户放好了光标。
*/
const userPlacedCaret = shallowRef(false)
function markUserCaret() {
userPlacedCaret.value = true
}
// 医疗文书用不到的工具栏能力直接移除,避免科室误用。
// 注意菜单 key:分割线是 divider、表情是 emotion(不是 emoji)。
const toolbarConfig: Partial<IToolbarConfig> = {
@@ -64,6 +82,11 @@ const editorConfig: Partial<IEditorConfig> = {
function handleCreated(editor: IDomEditor) {
editorRef.value = editor
const editable = editor.getEditableContainer()
editableRef.value = editable
editable.addEventListener('pointerdown', markUserCaret)
editable.addEventListener('keydown', markUserCaret)
}
function getText(): string {
@@ -72,12 +95,23 @@ function getText(): string {
// slate 文本节点:{ text };由编辑器在当前光标处插入。
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 })
onBeforeUnmount(() => {
editableRef.value?.removeEventListener('pointerdown', markUserCaret)
editableRef.value?.removeEventListener('keydown', markUserCaret)
editorRef.value?.destroy()
})
</script>
@@ -8,8 +8,11 @@ import {
deleteTemplate,
getDocuments,
getTemplateContent,
getTemplateVersions,
updateTemplate,
updateTemplateContent,
updateTemplateVersionStatus,
type TemplateVersionAction,
} from '@/api/management/documents'
import { getCampuses, getDepartments } from '@/api/management/organization'
import type {
@@ -17,6 +20,7 @@ import type {
CreateTemplateRequest,
DocumentRecord,
DepartmentRecord,
TemplateVersionResponseDto,
} from '@/api/management/types'
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
@@ -29,10 +33,18 @@ 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 DocumentVersionDialog from './components/DocumentVersionDialog.vue'
import WordEditorDialog from './components/WordEditorDialog.vue'
import { documentStatusOptions, loadDocumentLibrary, saveDocumentLibrary } from './mock'
import {
backendStatusLabels,
backendStatusOrder,
loadDocumentLibrary,
readBackendStatus,
saveDocumentLibrary,
} from './mock'
import type {
DepartmentTab,
DocumentBackendStatus,
DocumentDepartment,
DocumentFilterForm,
DocumentTemplate,
@@ -70,6 +82,13 @@ const wordEditorLoading = ref(false)
const previewLoading = 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 departments = ref<DepartmentRecord[]>([])
@@ -102,20 +121,42 @@ const departmentTabs = computed<DepartmentTab[]>(() => [
})),
])
const statusTabs = computed<StatusTab[]>(() => [
/**
* 状态页签按后端原始状态归类,而不是归一后的 draft / review / published / archived。
*
* 归一会把 PENDING_REVIEW(待审核)和 REJECTED(已驳回)并成 review,
* 页签上只能显示一个含糊的「需复审」:一个是等着审、一个是被打回,
* 科室该做的事完全不同,混在一个页签里就分不出来了。
*
* 只列出库里实际存在的状态: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 },
...documentStatusOptions.map((option) => ({
...option,
count: allTemplates.value.filter((template) => template.status === option.key).length,
...backendStatusOrder
.filter((status) => counts.has(status))
.map((status) => ({
key: status,
label: backendStatusLabels[status],
count: counts.get(status) ?? 0,
})),
])
]
})
const filteredTemplates = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return allTemplates.value.filter((template) => {
const matchesDepartment = !filters.department || template.departmentKey === filters.department
const matchesStatus = filters.status === 'all' || template.status === filters.status
const matchesStatus =
filters.status === 'all' || readBackendStatus(template) === filters.status
const matchesKeyword =
!keyword ||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
@@ -461,28 +502,181 @@ async function saveTemplate(form: TemplateEditorForm) {
}
try {
let savedId = form.id
if (form.id) {
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上;
// 新版本默认是草稿,这里顺带发布,保证编辑完就能直接发起签署
// 已发布的版本不可原地改写(已签署文书引用它),编辑落到新版本上。
// 新版本的状态由后端决定,前端不在这里「顺带发布」——发布是需要留痕的正式动作,
// 要由人显式触发(见 handleWorkflow),否则「谁把这份文书放行到可签署」无从追溯。
await updateTemplateContent(form.id, toVersionPayload(form))
await updateTemplate(form.id, {
name: form.name,
description: form.description,
category: form.category,
})
ElMessage.success('文书已更新并发布新版本')
} else {
await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
ElMessage.success('文书已创建')
const created = await createTemplateWithContent(toCreatePayload(form), toVersionPayload(form))
savedId = created.id
}
closeWordEditor()
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 {
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() {
filters.keyword = ''
filters.department = null
// status 也要一起重置:只按状态页签筛出空结果时,不清它等于「清了个寂寞」
filters.status = 'all'
}
function useTemplate(template: DocumentTemplate) {
@@ -684,10 +880,13 @@ onMounted(() => {
v-for="template in filteredTemplates"
:key="template.id"
:template="template"
:is-mock="useMockLibrary"
@preview="handlePreview"
@edit="showEditMessage"
@versions="openVersions"
@initiate="openSigningDialog"
@remove="removeTemplate"
@workflow="handleWorkflow"
/>
</div>
@@ -699,7 +898,7 @@ onMounted(() => {
<template v-else>
<p>没有符合条件的文书</p>
<button
v-if="filters.keyword || filters.department"
v-if="filters.keyword || filters.department || filters.status !== 'all'"
type="button"
class="reset-button"
@click="clearFilters"
@@ -731,6 +930,15 @@ onMounted(() => {
@close="wordEditorVisible = false"
@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">
@@ -12,7 +12,7 @@ import type { DocumentRecord } from '@/api/management/types'
import type { LocalDocument, LocalDepartment } from '@/utils/mock-document-library'
import { readDocumentLibrary, writeDocumentLibrary } from '@/utils/mock-document-library'
import type { DocumentDepartment, DocumentStatus } from './types'
import type { DocumentDepartment, DocumentBackendStatus, DocumentStatus } from './types'
/**
* 演示文库与页面模型之间的归一。
@@ -108,6 +108,119 @@ export const documentStatusLabels: Record<DocumentStatus, string> = {
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
}
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 {
key: DocumentFilterStatus
@@ -113,7 +113,9 @@ defineExpose({ getDocumentElement })
</button>
</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>
@@ -4,7 +4,11 @@ import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
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 {
completeSigningTask,
@@ -279,6 +283,23 @@ async function loadTaskDetail(id: string | null) {
} else {
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 {
if (requestId === detailRequestId) {
@@ -459,8 +480,8 @@ async function handleTaskAction(action: SigningTaskAction) {
return
}
if (action === 'reopen' && task.status !== 'expired') {
ElMessage.info('只有已超时任务可以重新发起')
if (action === 'reopen' && task.status !== 'expired' && task.status !== 'failed') {
ElMessage.info('只有已超时或处理失败的任务可以重新发起')
return
}