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,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>