feat(签署文书): 统一签署态文书样式并精简演示种子为单份麻精同意书
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文书库的两级筛选条(原型 V10.2 的 #libTabs / #libStatus)。
|
||||
*
|
||||
* 类名刻意沿用原型的 lib-tabs / lib-tab / lib-status / lib-st:这套样式在原型里
|
||||
* 是文档库页面独有的筛选语言(胶囊 + 虚线分隔),与通用 Tabs 组件的观感不同,
|
||||
* 换成通用组件会把「全部(8)」这类计数排版搞丢。
|
||||
*
|
||||
* 用 <button> 而不是原型的 <span onclick>:保留键盘可达与 aria-selected。
|
||||
*/
|
||||
import type { DepartmentTab, DocumentFilterStatus, StatusTab } from '../types'
|
||||
|
||||
defineProps<{
|
||||
@@ -15,14 +24,14 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-card">
|
||||
<div class="department-tabs" role="tablist" aria-label="按科室筛选">
|
||||
<div class="lib-filter">
|
||||
<div class="lib-tabs" role="tablist" aria-label="按科室筛选">
|
||||
<button
|
||||
v-for="tab in departments"
|
||||
:key="tab.key ?? 'all'"
|
||||
type="button"
|
||||
class="department-tab"
|
||||
:class="{ active: department === tab.key }"
|
||||
class="lib-tab"
|
||||
:class="{ on: department === tab.key }"
|
||||
:aria-selected="department === tab.key"
|
||||
role="tab"
|
||||
@click="emit('update:department', tab.key)"
|
||||
@@ -31,25 +40,26 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-tabs" role="tablist" aria-label="按状态筛选">
|
||||
<div class="lib-status" role="tablist" aria-label="按状态筛选">
|
||||
<button
|
||||
v-for="tab in statuses"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="status-tab"
|
||||
:class="{ active: status === tab.key }"
|
||||
class="lib-st"
|
||||
:class="{ on: status === tab.key }"
|
||||
:aria-selected="status === tab.key"
|
||||
role="tab"
|
||||
@click="emit('update:status', tab.key)"
|
||||
>
|
||||
{{ tab.label }}<span v-if="tab.count"> · {{ tab.count }}</span>
|
||||
{{ tab.label }}<template v-if="tab.count"> · {{ tab.count }}</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
/* 原型把两条页签包在 .card 里(padding:12px 16px; margin-bottom:12px),照抄 */
|
||||
.lib-filter {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--card);
|
||||
@@ -57,37 +67,40 @@ const emit = defineEmits<{
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.department-tabs {
|
||||
/* 原型 .lib-tabs:flex-wrap + gap 8,块间距靠 margin-bottom 交给外层 */
|
||||
.lib-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.department-tab {
|
||||
.lib-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
background: #f4f8fa;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.department-tab:hover {
|
||||
.lib-tab:hover {
|
||||
color: var(--brand);
|
||||
background: #eaf2f5;
|
||||
}
|
||||
|
||||
.department-tab.active {
|
||||
.lib-tab.on {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
/* 原型 .lib-status:与科室行之间用一条虚线分开,表达「同一维度下的第二级筛选」 */
|
||||
.lib-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
@@ -95,27 +108,27 @@ const emit = defineEmits<{
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
.lib-st {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.status-tab:hover {
|
||||
.lib-st:hover {
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.status-tab.active {
|
||||
.lib-st.on {
|
||||
color: var(--brand-d);
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
background: var(--brand-l);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { readLibraryStatus } from '../library'
|
||||
import type { DocumentHistoryRow, DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
template: DocumentTemplate | null
|
||||
rows: DocumentHistoryRow[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
/** 演示文库只保存当前版本,弹窗要如实说明,别让科室以为这就是全部历史 */
|
||||
isMock: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
/** 查看某个历史版本的正文(只读) */
|
||||
'view-version': [versionId: string]
|
||||
}>()
|
||||
|
||||
const sub = computed(() => {
|
||||
if (!props.template) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const status = readLibraryStatus(props.template)
|
||||
|
||||
return `${props.template.name} · ${props.template.code || '未编号'} · 当前 ${props.template.version}(${status})`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible && template" class="dialog-mask" @click.self="emit('close')">
|
||||
<section
|
||||
class="dialog-card history-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="history-dialog-title"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div class="dialog-heading">
|
||||
<strong id="history-dialog-title">📄 文档历史记录</strong>
|
||||
<span>{{ sub }}</span>
|
||||
</div>
|
||||
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="dialog-body">
|
||||
<p v-if="loading" class="history-state">正在加载历史记录…</p>
|
||||
|
||||
<p v-else-if="error" class="history-state history-state--error">
|
||||
历史记录加载失败,请确认后端模板版本接口可访问后重试。
|
||||
</p>
|
||||
|
||||
<template v-else-if="rows.length">
|
||||
<div class="table-scroll">
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-version">版本</th>
|
||||
<th class="col-operator">操作人</th>
|
||||
<th class="col-time">时间</th>
|
||||
<th>操作</th>
|
||||
<th>说明</th>
|
||||
<th class="col-action">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td><b>{{ row.version }}</b></td>
|
||||
<td>{{ row.operator }}</td>
|
||||
<td>{{ row.operatedAt || '—' }}</td>
|
||||
<td>{{ row.action }}</td>
|
||||
<td class="cell-note">{{ row.note }}</td>
|
||||
<td>
|
||||
<span v-if="row.isCurrent" class="current-badge">当前</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="row-button"
|
||||
title="只读查看该版本的正文"
|
||||
@click="emit('view-version', row.id)"
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="isMock" class="history-hint">
|
||||
演示文库只保留当前版本,因此这里只有一条。真实模式下会列出该文书的全部历史版本。
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-else class="history-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: 20;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: rgb(9 40 52 / 45%);
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
display: flex;
|
||||
width: 760px;
|
||||
max-width: 96vw;
|
||||
max-height: calc(100vh - 32px);
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--sh-modal);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.history-table th,
|
||||
.history-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
color: var(--ink);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.cell-note {
|
||||
color: var(--mut);
|
||||
}
|
||||
|
||||
.col-version {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.col-operator {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.col-time {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.col-action {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
padding: 2px 8px;
|
||||
color: var(--ok);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: var(--ok-l);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.row-button {
|
||||
padding: 3px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 11.5px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.row-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.history-state {
|
||||
padding: 28px 0;
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-state--error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.history-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: 11.5px;
|
||||
background: #f8fbfc;
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 0 0 14px 14px;
|
||||
}
|
||||
|
||||
.dialog-footer span {
|
||||
flex: 1;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
@@ -7,10 +7,8 @@ withDefaults(
|
||||
total: number
|
||||
/** 只在演示模式下展示「重置演示数据」:真实环境不该出现这个自测入口 */
|
||||
showReset?: boolean
|
||||
/** 模板包导出接口尚未接入,先只在演示模式展示入口;功能上线后放开这里即可 */
|
||||
showExport?: boolean
|
||||
}>(),
|
||||
{ showReset: false, showExport: false },
|
||||
{ showReset: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -26,42 +24,22 @@ function handleInput(event: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="library-header">
|
||||
<div class="library-title">
|
||||
<h2>文档库管理</h2>
|
||||
<p>
|
||||
按科室分类的知情同意书模板库,支持预览、编辑与版本管理。
|
||||
<span>共 {{ total }} 份模板</span>
|
||||
</p>
|
||||
</div>
|
||||
<!--
|
||||
原型 V10.2:文档库顶部不再重复页面标题(左侧导航已经写着「文档库管理」),
|
||||
只剩一行「搜索框 + 共 N 份模板 + 导出模板包 + +新增文档」。
|
||||
-->
|
||||
<div class="lib-header">
|
||||
<input
|
||||
:value="modelValue"
|
||||
class="lib-search"
|
||||
type="search"
|
||||
placeholder="搜索模板名 / 编号 / 科室 / 分类"
|
||||
aria-label="搜索模板名称、编号、科室或分类"
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<div class="library-tools">
|
||||
<input
|
||||
:value="modelValue"
|
||||
class="library-search"
|
||||
type="search"
|
||||
placeholder="搜索模板名称 / 编号 / 科室"
|
||||
aria-label="搜索模板名称、编号或科室"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<button
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.create"
|
||||
type="button"
|
||||
class="tool-button primary"
|
||||
@click="emit('add')"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
新增文档
|
||||
</button>
|
||||
<button
|
||||
v-if="showExport"
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.export"
|
||||
type="button"
|
||||
class="tool-button secondary"
|
||||
@click="emit('export')"
|
||||
>
|
||||
导出模板包
|
||||
</button>
|
||||
<div class="lib-tools">
|
||||
<span class="lib-count">共 {{ total }} 份模板</span>
|
||||
<button
|
||||
v-if="showReset"
|
||||
type="button"
|
||||
@@ -71,12 +49,29 @@ function handleInput(event: Event) {
|
||||
>
|
||||
重置演示数据
|
||||
</button>
|
||||
<button
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.export"
|
||||
type="button"
|
||||
class="tool-button secondary"
|
||||
@click="emit('export')"
|
||||
>
|
||||
导出模板包
|
||||
</button>
|
||||
<button
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.create"
|
||||
type="button"
|
||||
class="tool-button primary"
|
||||
@click="emit('add')"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
新增文档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.library-header {
|
||||
.lib-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
@@ -85,40 +80,9 @@ function handleInput(event: Event) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.library-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.library-title h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 17px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.library-title p {
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.library-title p span {
|
||||
margin-left: 8px;
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.library-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.library-search {
|
||||
width: 240px;
|
||||
.lib-search {
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
padding: 6px 12px;
|
||||
color: var(--ink);
|
||||
font-size: 12.5px;
|
||||
@@ -127,16 +91,34 @@ function handleInput(event: Event) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.library-search:focus {
|
||||
.lib-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgb(14 110 140 / 10%);
|
||||
}
|
||||
|
||||
.lib-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.lib-count {
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
padding: 7px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-radius: 7px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
@@ -177,11 +159,12 @@ function handleInput(event: Event) {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.library-tools {
|
||||
.lib-tools {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.library-search {
|
||||
.lib-search {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
buildSigningDocumentHtml,
|
||||
collectSelectedItemsFromRoot,
|
||||
describeFieldProgress,
|
||||
OPTION_TICK_SELECTOR,
|
||||
readAnswersFromRoot,
|
||||
type SigningDocumentData,
|
||||
toggleOptionAt,
|
||||
type SigningFieldProgress,
|
||||
} from '@/utils/signing-document'
|
||||
|
||||
import { readStatusLabel, readStatusTone } from '../mock'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
import { canInitiateSigning, readLibraryStatus, readStatusTone } from '../library'
|
||||
import { PREVIEW_PATIENT as previewPatient } from '../sample-patient'
|
||||
import type { DocumentLibraryStatus, DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
@@ -25,22 +28,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 签署预览用的示例患者快照(脱敏演示数据)。
|
||||
*
|
||||
* 创建文书时还没有真实签署任务,用固定示例值让科室看到占位符被填充后的版式;
|
||||
* 真实签署仍以任务创建时的患者/就诊快照为准。
|
||||
* 这份数据只服务于「签署预览」这个演示页签,因此留在组件内,
|
||||
* 不再从页面 mock.ts 导出——那会让"演示数据"渗透到文书库的其它部分。
|
||||
* 签署预览用的示例患者快照见 ../sample-patient(编辑器弹窗的「签署预览」共用同一份)。
|
||||
*/
|
||||
const previewPatient: SigningDocumentData = {
|
||||
patientName: '李某某',
|
||||
sex: '男',
|
||||
age: 46,
|
||||
visitNo: 'V20260914001',
|
||||
visitDate: '2026-09-14',
|
||||
phone: '138****5678',
|
||||
signedAt: '',
|
||||
}
|
||||
|
||||
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
|
||||
const activeTab = ref<'content' | 'signing'>('content')
|
||||
@@ -81,17 +70,22 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const statusLabel = computed(() => (props.template ? readStatusLabel(props.template) : ''))
|
||||
/** 状态口径统一走 library.ts:卡片、页签、预览三处必须是同一档 */
|
||||
const libraryStatus = computed<DocumentLibraryStatus>(() =>
|
||||
props.template ? readLibraryStatus(props.template) : '草稿',
|
||||
)
|
||||
|
||||
/** 徽标配色与文书卡片同口径:草稿/待审核/已驳回/已发布/已归档各不相同 */
|
||||
const statusTone = computed(() => (props.template ? readStatusTone(props.template) : 'draft'))
|
||||
const statusLabel = computed(() => libraryStatus.value)
|
||||
|
||||
/** 徽标配色与文书卡片同口径(见 library.ts 的 STATUS_TONES) */
|
||||
const statusTone = computed(() => readStatusTone(libraryStatus.value))
|
||||
|
||||
/**
|
||||
* 与文书卡片保持同一口径:只有已发布(后端 APPROVED / PUBLISHED)才能发起签署。
|
||||
* 不放行 review —— 它同时承载待审核与已驳回,两者都未定稿。
|
||||
* 与文书卡片保持同一口径:只有「启用中」的文书能发起签署。
|
||||
* 「需复审」不放行——它同时承载待审核与已驳回,两者都未定稿。
|
||||
*/
|
||||
const canInitiate = computed(
|
||||
() => Boolean(props.template?.contentHtml) && props.template?.status === 'published',
|
||||
() => Boolean(props.template?.contentHtml) && canInitiateSigning(libraryStatus.value),
|
||||
)
|
||||
|
||||
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
|
||||
@@ -160,9 +154,27 @@ function syncPreviewFields() {
|
||||
|
||||
const answers = readAnswersFromRoot(body)
|
||||
signingProgress.value = describeFieldProgress(body, answers)
|
||||
signingSelection.value = Array.from(
|
||||
body.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'),
|
||||
).map((input) => input.getAttribute('data-sign-item') ?? '')
|
||||
signingSelection.value = collectSelectedItemsFromRoot(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览里的 ☑ / ☐ 就地切换。
|
||||
* 选项渲染成文本(原型口径),不会触发 change 事件,必须自己接 click;
|
||||
* 切换判据在 utils/signing-document 的 toggleOptionAt 里,与签署端共用一份。
|
||||
*/
|
||||
function handlePreviewClick(event: MouseEvent) {
|
||||
const body = signingBodyRef.value
|
||||
const target = event.target
|
||||
|
||||
if (!body || !(target instanceof Element)) {
|
||||
return
|
||||
}
|
||||
|
||||
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
|
||||
|
||||
if (option && toggleOptionAt(body, option)) {
|
||||
syncPreviewFields()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -224,7 +236,7 @@ function syncPreviewFields() {
|
||||
<template v-else-if="template.contentHtml && activeTab === 'signing'">
|
||||
<p class="preview-note">
|
||||
按签署时效果渲染:占位符已用示例患者「{{ previewPatient.patientName }}」填充,
|
||||
表头含「勾选」的表格已转为勾选框,可直接勾选确认交互效果(预览不保存勾选结果)。
|
||||
空白处是待补录的下划线、☑ / ☐ 可直接点击确认交互效果(预览不保存结果)。
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||
<div
|
||||
@@ -232,6 +244,7 @@ function syncPreviewFields() {
|
||||
class="signing-preview"
|
||||
@change="syncPreviewFields"
|
||||
@input="syncPreviewFields"
|
||||
@click="handlePreviewClick"
|
||||
v-html="signingPreviewHtml"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
@@ -382,9 +395,10 @@ function syncPreviewFields() {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
/* 状态徽标配色:与文书卡片同一套色调,草稿/待审核/已驳回不再一律显示成绿色 */
|
||||
/* 状态徽标配色:与文书卡片同一套色调(见 library.ts 的 STATUS_TONES) */
|
||||
.status-badge.tone-draft,
|
||||
.status-badge.tone-archived {
|
||||
.status-badge.tone-archived,
|
||||
.status-badge.tone-stopped {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
@@ -394,12 +408,7 @@ function syncPreviewFields() {
|
||||
background: var(--warn-l);
|
||||
}
|
||||
|
||||
.status-badge.tone-rejected {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
}
|
||||
|
||||
.status-badge.tone-published {
|
||||
.status-badge.tone-enabled {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
@@ -503,37 +512,6 @@ function syncPreviewFields() {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 固定页眉:患者基本信息由 HIS 自动带出,与正文之间用一条横线分隔。
|
||||
四项要在一行内放下,取值下划线的宽度由字段定义给出(内联 min-width),样式里不再写死 */
|
||||
.signing-preview :deep(.sign-doc-header) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1.5px solid #52646e;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-doc-header__item) {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-doc-header__label) {
|
||||
flex-shrink: 0;
|
||||
color: #52646e;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-doc-header__value) {
|
||||
padding: 0 5px 1px;
|
||||
color: #1c2b33;
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #52646e;
|
||||
}
|
||||
|
||||
.signing-preview :deep(table) {
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
@@ -557,123 +535,9 @@ function syncPreviewFields() {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-doc-checkbox) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 正文行内选项(□否 □是):用 label 包住方框与选项文字,点文字也能选中 */
|
||||
.signing-preview :deep(.sign-doc-option) {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-doc-signature) {
|
||||
max-width: 150px;
|
||||
max-height: 52px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 填写项:签署预览里按签署时的控件外观渲染,样式与签署端保持一致 */
|
||||
.signing-preview :deep(.sign-field) {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
background: #f7fbfd;
|
||||
border: 1px solid #d4e3e8;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__label) {
|
||||
flex-shrink: 0;
|
||||
color: #587786;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__required) {
|
||||
margin-left: 2px;
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__control) {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__input) {
|
||||
width: 9em;
|
||||
min-width: 0;
|
||||
padding: 1px 5px;
|
||||
color: #1c2b33;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid #cfe0e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__input:focus) {
|
||||
border-color: var(--brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__choices) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__radio),
|
||||
.signing-preview :deep(.sign-field__checkbox) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__signature-empty) {
|
||||
padding: 12px 18px;
|
||||
color: #a96900;
|
||||
font-size: 11px;
|
||||
background: #fff9ec;
|
||||
border: 1px dashed #f0a21a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--signature) {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--signature .sign-field__control) {
|
||||
min-width: 150px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--abnormal) {
|
||||
background: #fdf0f0;
|
||||
border-color: #efb9bb;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--abnormal .sign-field__label) {
|
||||
color: #a42e34;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__badge) {
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
background: var(--err);
|
||||
border-radius: 8px;
|
||||
}
|
||||
/* 填写项胶囊、选项 ☑ / ☐、系统页眉的样式在 styles/signing-document.css(全局):
|
||||
同一份签署态文书会在文书库预览、签署工作台与编辑器三处出现,各写一份必然漂移。
|
||||
这里只保留预览容器自己的版式。 */
|
||||
|
||||
.signing-selection {
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import type { DepartmentRecord } from '@/api/management/types'
|
||||
|
||||
import { DEFAULT_DOCUMENT_TYPE, DOCUMENT_TYPES, GENERAL_OWNERSHIP } from '../library'
|
||||
import { DOC_TEMPLATE_PERMISSIONS } from '../permissions'
|
||||
import type { DocumentPropsForm, DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/** null 表示新增文档 */
|
||||
template: DocumentTemplate | null
|
||||
/** 可归属的科室(真实模式取组织接口,演示模式取演示字典) */
|
||||
departments: DepartmentRecord[]
|
||||
/**
|
||||
* 是否可以把文档设为「通用(全院)」。
|
||||
*
|
||||
* 原型 canSetGeneral() 只放给系统管理员 / 医务管理;这里由页面判定后传进来,
|
||||
* 弹窗不自己去读角色——弹窗只负责表单。
|
||||
*/
|
||||
canSetGeneral: boolean
|
||||
/**
|
||||
* 演示模式。
|
||||
*
|
||||
* 真实模式下「版本号」是只读展示:版本由后端在每次保存正文时递增生成
|
||||
* (CreateTemplateVersionRequest 的 versionNo 是可选字段,前端不填就由后端编),
|
||||
* 让科室手填一个和后端对不上的号,只会让卡片上的版本显示变成假信息。
|
||||
*/
|
||||
isMock: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [form: DocumentPropsForm]
|
||||
}>()
|
||||
|
||||
const isCreate = computed(() => !props.template)
|
||||
const title = computed(() => (isCreate.value ? '新增文档' : '编辑文档属性'))
|
||||
const sub = computed(() =>
|
||||
props.template
|
||||
? `${props.template.name} · ${props.template.code || '未编号'}`
|
||||
: '先创建文书属性,保存后进入「编辑正文」添加 Word 内容',
|
||||
)
|
||||
const submitLabel = computed(() => (isCreate.value ? '创建并编辑正文' : '保存属性'))
|
||||
|
||||
const form = reactive<DocumentPropsForm>({
|
||||
name: '',
|
||||
category: DEFAULT_DOCUMENT_TYPE,
|
||||
versionNo: 'v1.0.0',
|
||||
ownership: [],
|
||||
description: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 归属勾选列表。
|
||||
* 顺序与原型 docDeptOpts 一致:科室名在前、「通用(全院)」在最后,
|
||||
* 勾了通用(全院)就等于全院可见,其余科室勾选不再参与落库。
|
||||
*/
|
||||
const ownershipOptions = computed(() => {
|
||||
const names = props.departments.map((department) => department.name)
|
||||
|
||||
return props.canSetGeneral ? [...names, GENERAL_OWNERSHIP] : names
|
||||
})
|
||||
|
||||
const generalSelected = computed(() => form.ownership.includes(GENERAL_OWNERSHIP))
|
||||
|
||||
function toggleOwnership(option: string) {
|
||||
if (option === GENERAL_OWNERSHIP) {
|
||||
// 勾上「通用(全院)」就清掉科室勾选,取消则回到空选择——
|
||||
// 两种选择混在一起会让「到底归属哪儿」没有唯一答案
|
||||
form.ownership = generalSelected.value ? [] : [GENERAL_OWNERSHIP]
|
||||
return
|
||||
}
|
||||
|
||||
if (generalSelected.value) {
|
||||
form.ownership = []
|
||||
}
|
||||
|
||||
const index = form.ownership.indexOf(option)
|
||||
|
||||
if (index >= 0) {
|
||||
form.ownership.splice(index, 1)
|
||||
} else {
|
||||
form.ownership.push(option)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
const template = props.template
|
||||
|
||||
Object.assign(form, {
|
||||
id: template?.id,
|
||||
name: template?.name ?? '',
|
||||
category: template?.category || DEFAULT_DOCUMENT_TYPE,
|
||||
versionNo: template?.version ?? 'v1.0.0',
|
||||
ownership: template
|
||||
? template.departmentId
|
||||
? [template.departmentName ?? ''].filter(Boolean)
|
||||
: [GENERAL_OWNERSHIP]
|
||||
: // 新增时预选当前用户所属科室(对得上就直接用,对不上就空着让科室自己勾)
|
||||
[],
|
||||
description: template?.description ?? '',
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function submit() {
|
||||
const name = form.name.trim()
|
||||
|
||||
if (!name) {
|
||||
ElMessage.error('请填写文书名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.ownership.length) {
|
||||
ElMessage.error('请至少选择一个归属科室')
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
...(form.id ? { id: form.id } : {}),
|
||||
name,
|
||||
category: form.category,
|
||||
versionNo: form.versionNo.trim() || 'v1.0.0',
|
||||
ownership: [...form.ownership],
|
||||
description: form.description.trim(),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="dialog-mask" role="presentation" @click.self="emit('close')">
|
||||
<section class="props-dialog" role="dialog" aria-modal="true" aria-labelledby="props-title">
|
||||
<header class="dialog-header">
|
||||
<div class="dialog-heading">
|
||||
<strong id="props-title">{{ title }}</strong>
|
||||
<span>{{ sub }}</span>
|
||||
</div>
|
||||
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="dialog-body">
|
||||
<p class="section-label">① 基本信息</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="form-field span-2">
|
||||
<span>文书名称 <i>*</i></span>
|
||||
<input v-model="form.name" type="text" placeholder="例:麻醉药品、精神药品知情同意书" />
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>文档类型</span>
|
||||
<select v-model="form.category">
|
||||
<option v-for="type in DOCUMENT_TYPES" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-field">
|
||||
<span>版本号</span>
|
||||
<input v-if="isMock" v-model="form.versionNo" type="text" placeholder="v1.0.0" />
|
||||
<span v-else class="auto-version">由系统按 vN 自动生成</span>
|
||||
</div>
|
||||
|
||||
<div class="form-field span-2">
|
||||
<span>文档归属 <i>*</i></span>
|
||||
<div class="ownership">
|
||||
<button
|
||||
v-for="option in ownershipOptions"
|
||||
:key="option"
|
||||
type="button"
|
||||
class="ownership-chip"
|
||||
:class="{ 'is-on': form.ownership.includes(option) }"
|
||||
@click="toggleOwnership(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-tip">
|
||||
勾选科室设置可见范围,支持多科室与通用(全院)。勾选「通用(全院)」后各科室均可见。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="form-field span-2">
|
||||
<span>文书说明</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="2"
|
||||
placeholder="显示在文书卡片上的简介"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<p>
|
||||
{{
|
||||
isCreate
|
||||
? '创建后自动进入类 Word 编辑器:可编写正文、导入 .docx 文件或插入签署占位符,保存后生效。'
|
||||
: '属性与正文分开维护:此处只修改文档基本信息与归属;正文请在文档卡片点「编辑正文」。'
|
||||
}}
|
||||
</p>
|
||||
<button type="button" class="footer-button secondary" @click="emit('close')">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
v-permission.disable="isCreate ? DOC_TEMPLATE_PERMISSIONS.create : DOC_TEMPLATE_PERMISSIONS.props"
|
||||
type="button"
|
||||
class="footer-button primary"
|
||||
:disabled="saving"
|
||||
@click="submit"
|
||||
>
|
||||
{{ saving ? '保存中…' : submitLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: rgb(9 40 52 / 45%);
|
||||
}
|
||||
|
||||
.props-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 660px;
|
||||
max-width: 96vw;
|
||||
max-height: calc(100vh - 32px);
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--sh-modal);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin: 0 0 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-field.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.form-field > span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-field > span i {
|
||||
color: var(--err);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field select,
|
||||
.form-field textarea {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-field input:focus,
|
||||
.form-field select:focus,
|
||||
.form-field textarea:focus {
|
||||
border-color: var(--brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* 真实模式的版本号只读展示。
|
||||
* 必须显式覆盖 `.form-field > span` 的标签样式——它是同一层的第二个 span,
|
||||
* 不覆盖就会变成和「版本号」标签一样的加粗小字。
|
||||
*/
|
||||
.form-field > span.auto-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 33px;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 0;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
background: #f4f8fa;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ownership {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ownership-chip {
|
||||
padding: 4px 11px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
background: #f4f8fa;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.ownership-chip:hover {
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.ownership-chip.is-on {
|
||||
color: var(--brand-d);
|
||||
font-weight: 700;
|
||||
background: var(--brand-l);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin: 6px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 18px;
|
||||
background: #f8fbfc;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dialog-footer p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer-button {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.footer-button.secondary {
|
||||
color: var(--brand);
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
.footer-button.primary {
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
.footer-button:hover:not(:disabled) {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.footer-button:disabled {
|
||||
color: #aebbc0;
|
||||
cursor: not-allowed;
|
||||
background: #f1f5f6;
|
||||
border-color: #dce5e8;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-field.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialog-footer p {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,185 +3,169 @@ import { computed } from 'vue'
|
||||
|
||||
import type { TemplateVersionAction } from '@/api/management/documents'
|
||||
|
||||
import type { DocumentTemplate } from '../types'
|
||||
import { readStatusLabel, readStatusTone } from '../mock'
|
||||
import {
|
||||
canInitiateSigning,
|
||||
canToggleStatus,
|
||||
DOCUMENT_STATUS_DOT,
|
||||
isGeneralOwnership,
|
||||
readLibraryStatus,
|
||||
readStatusTone,
|
||||
} from '../library'
|
||||
import { DOC_TEMPLATE_PERMISSIONS, workflowPermission } from '../permissions'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
template: DocumentTemplate
|
||||
/** 演示模式下「删除」是真删;真实模式下语义是下架(模板无 DELETE 接口,走归档) */
|
||||
/**
|
||||
* 演示模式。
|
||||
*
|
||||
* 真实模式下卡片只有「停用 / 启用」这一个状态动作:后端菜单资源树里的
|
||||
* 「发布 / 提交审核」按钮虽在,但原型 V10.2 已经抛弃那条工作流,
|
||||
* 科室侧不该再看到「提交审核」这一步(草稿的送审改由属性/正文弹窗完成,
|
||||
* 或直接由医务管理放行)。
|
||||
*/
|
||||
isMock: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: [template: DocumentTemplate]
|
||||
edit: [template: DocumentTemplate]
|
||||
versions: [template: DocumentTemplate]
|
||||
'edit-props': [template: DocumentTemplate]
|
||||
'edit-content': [template: DocumentTemplate]
|
||||
history: [template: DocumentTemplate]
|
||||
'toggle-status': [template: DocumentTemplate]
|
||||
'submit-review': [template: DocumentTemplate]
|
||||
initiate: [template: DocumentTemplate]
|
||||
remove: [template: DocumentTemplate]
|
||||
workflow: [template: DocumentTemplate, action: TemplateVersionAction]
|
||||
}>()
|
||||
|
||||
const statusLabel = computed(() => readStatusLabel(props.template))
|
||||
const status = computed(() => readLibraryStatus(props.template))
|
||||
const tone = computed(() => readStatusTone(status.value))
|
||||
const dotColor = computed(() => DOCUMENT_STATUS_DOT[tone.value])
|
||||
|
||||
/** 配色按后端状态分组取(待审核与已驳回不能共用一个颜色) */
|
||||
const statusTone = computed(() => readStatusTone(props.template))
|
||||
const isGeneral = computed(() => isGeneralOwnership(props.template))
|
||||
const ownershipLabel = computed(() =>
|
||||
isGeneral.value ? '🌐 全院通用' : `🏥 ${props.template.departmentName ?? '未知科室'}`,
|
||||
)
|
||||
|
||||
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
|
||||
const hasInitiableVersion = computed(() => Boolean(props.template.currentVersionId || props.isMock))
|
||||
/**
|
||||
* 归属徽标上的「·+N 科室」。
|
||||
*
|
||||
* sharedDepartments 只在演示文库里有值(后端 CreateTemplateRequest 只接受单个
|
||||
* departmentId),所以真实模式下这里自然为 0、标签不显示后缀——
|
||||
* 不给真实数据编一个后端没存的共享清单。
|
||||
*/
|
||||
const sharedCount = computed(() => props.template.sharedDepartments?.length ?? 0)
|
||||
|
||||
/** 只有「启用中」能发起签署;其余状态要么没定稿,要么已下线 */
|
||||
const canInitiate = computed(() => canInitiateSigning(status.value))
|
||||
const initiateTitle = computed(() =>
|
||||
hasInitiableVersion.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: '重新启用' }
|
||||
case 'APPROVED':
|
||||
return { key: 'publish', label: '发布' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
const canToggle = computed(() => canToggleStatus(status.value))
|
||||
const toggleLabel = computed(() => (status.value === '停用' ? '启用' : '停用'))
|
||||
|
||||
/**
|
||||
* 版本工作流次要动作。
|
||||
*
|
||||
* 「驳回」不能只做成主动作的反面:审核通过和驳回是审核人的一对选择,
|
||||
* 必须同时可见,否则待审核的文书就只能通过、不能打回。
|
||||
*/
|
||||
const secondaryActions = computed<{ key: TemplateVersionAction; label: string; title: string }[]>(
|
||||
() => {
|
||||
if (props.template.backendStatus === 'PENDING_REVIEW') {
|
||||
return [
|
||||
{
|
||||
key: 'reject',
|
||||
label: '驳回',
|
||||
title: '驳回后需填写原因,科室按意见修改并重新提交',
|
||||
},
|
||||
]
|
||||
}
|
||||
/** 草稿的「提交审核」只在演示模式露出,真实模式不给这个入口 */
|
||||
const showSubmitReview = computed(() => props.isMock && status.value === '草稿')
|
||||
|
||||
return []
|
||||
},
|
||||
/** 停用/启用走的是后端版本动作:停用 → disable,启用 → publish */
|
||||
const toggleAction = computed<TemplateVersionAction>(() =>
|
||||
status.value === '停用' ? 'publish' : 'disable',
|
||||
)
|
||||
|
||||
/**
|
||||
* 本次交付暂不包含「版本历史」与「删除 / 下架」入口,先隐藏对应按钮。
|
||||
* 权限码已按 doc:template:hist / doc:template:stop 接好,需要恢复时把开关改回 true 即可。
|
||||
*/
|
||||
const showVersionAction = false
|
||||
const showRemoveAction = false
|
||||
|
||||
const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架此文书'))
|
||||
// 按钮文案与权限码的对应关系集中在模板里,避免多处各写一份
|
||||
const permissions = DOC_TEMPLATE_PERMISSIONS
|
||||
const togglePermission = computed(() => workflowPermission(toggleAction.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="template-card">
|
||||
<div class="template-head">
|
||||
<h3 :title="template.name">{{ template.name }}</h3>
|
||||
<span class="template-status" :class="'status-' + statusTone">
|
||||
<i aria-hidden="true" />
|
||||
{{ statusLabel }}
|
||||
<article class="tpl-card">
|
||||
<div class="tpl-hd">
|
||||
<h3 class="tpl-title" :title="template.name">{{ template.name }}</h3>
|
||||
<span class="tpl-status" :class="'tone-' + tone">
|
||||
<i :style="{ background: dotColor }" aria-hidden="true" />
|
||||
{{ status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="template-meta">
|
||||
{{ template.code }} · {{ template.version }} · {{ template.department }}
|
||||
</p>
|
||||
<p class="template-description">{{ template.description }}</p>
|
||||
<p class="tpl-meta">{{ template.code || '未编号' }} · {{ template.version }}</p>
|
||||
|
||||
<div class="template-footer">
|
||||
<span class="department-tag">
|
||||
<i aria-hidden="true" />
|
||||
{{ template.department }}
|
||||
<div class="tpl-tags">
|
||||
<span class="exam-tag" :class="isGeneral ? 'is-general' : 'is-department'">
|
||||
{{ ownershipLabel }}<template v-if="!isGeneral && sharedCount"> ·+{{ sharedCount }} 科室</template>
|
||||
</span>
|
||||
<div class="template-actions">
|
||||
<button type="button" class="card-button" @click="emit('preview', template)">预览</button>
|
||||
<button
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.edit"
|
||||
type="button"
|
||||
class="card-button"
|
||||
@click="emit('edit', template)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="showVersionAction"
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.history"
|
||||
type="button"
|
||||
class="card-button"
|
||||
title="查看该文书的版本历史"
|
||||
@click="emit('versions', template)"
|
||||
>
|
||||
版本
|
||||
</button>
|
||||
<button
|
||||
v-if="hasInitiableVersion"
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.initiate"
|
||||
type="button"
|
||||
class="card-button primary"
|
||||
:title="initiateTitle"
|
||||
@click="emit('initiate', template)"
|
||||
>
|
||||
发起
|
||||
</button>
|
||||
<!-- 还不能签署时,把「当前该做的下一步」放在同一位置,避免按钮越堆越多 -->
|
||||
<button
|
||||
v-if="primaryAction"
|
||||
v-permission="workflowPermission(primaryAction.key)"
|
||||
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"
|
||||
v-permission="workflowPermission(action.key)"
|
||||
type="button"
|
||||
class="card-button"
|
||||
:title="action.title"
|
||||
@click="emit('workflow', template, action.key)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showRemoveAction"
|
||||
v-permission="DOC_TEMPLATE_PERMISSIONS.stop"
|
||||
type="button"
|
||||
class="card-button danger"
|
||||
:title="removeTitle"
|
||||
@click="emit('remove', template)"
|
||||
>
|
||||
{{ isMock ? '删除' : '下架' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tpl-desc">{{ template.description || '—' }}</p>
|
||||
|
||||
<div class="tpl-ft">
|
||||
<button type="button" class="card-button" @click="emit('preview', template)">预览</button>
|
||||
<button
|
||||
v-permission="permissions.props"
|
||||
type="button"
|
||||
class="card-button"
|
||||
@click="emit('edit-props', template)"
|
||||
>
|
||||
编辑属性
|
||||
</button>
|
||||
<button
|
||||
v-permission="permissions.edit"
|
||||
type="button"
|
||||
class="card-button"
|
||||
@click="emit('edit-content', template)"
|
||||
>
|
||||
编辑正文
|
||||
</button>
|
||||
<button
|
||||
v-permission="permissions.history"
|
||||
type="button"
|
||||
class="card-button"
|
||||
@click="emit('history', template)"
|
||||
>
|
||||
历史记录
|
||||
</button>
|
||||
|
||||
<span class="ft-spacer" />
|
||||
|
||||
<button
|
||||
v-if="canToggle"
|
||||
v-permission="togglePermission"
|
||||
type="button"
|
||||
class="card-button"
|
||||
:title="
|
||||
toggleLabel === '停用'
|
||||
? '停用后各科室不可见、不可发起签署'
|
||||
: '启用后各科室可见并可发起签署'
|
||||
"
|
||||
@click="emit('toggle-status', template)"
|
||||
>
|
||||
{{ toggleLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showSubmitReview"
|
||||
v-permission="permissions.publish"
|
||||
type="button"
|
||||
class="card-button"
|
||||
title="提交审核,审核通过后自动发布"
|
||||
@click="emit('submit-review', template)"
|
||||
>
|
||||
提交审核
|
||||
</button>
|
||||
<button
|
||||
v-if="canInitiate"
|
||||
v-permission="permissions.initiate"
|
||||
type="button"
|
||||
class="card-button primary"
|
||||
:title="initiateTitle"
|
||||
@click="emit('initiate', template)"
|
||||
>
|
||||
发起
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-card {
|
||||
.tpl-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -193,24 +177,22 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
|
||||
box-shadow: var(--sh);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
.tpl-card:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--sh-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgb(14 110 140 / 10%);
|
||||
}
|
||||
|
||||
.template-head {
|
||||
.tpl-hd {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.template-head h3 {
|
||||
.tpl-title {
|
||||
display: -webkit-box;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
@@ -222,7 +204,7 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.template-status {
|
||||
.tpl-status {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 5px;
|
||||
@@ -234,41 +216,31 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.template-status i {
|
||||
.tpl-status i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-published {
|
||||
/* 徽标底色沿用原型 tg-* 那套色调;圆点颜色由内联样式给出(见 DOCUMENT_STATUS_DOT) */
|
||||
.tone-enabled {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.status-archived {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.status-review {
|
||||
.tone-review {
|
||||
color: var(--warn);
|
||||
background: var(--warn-l);
|
||||
}
|
||||
|
||||
/* 已驳回要和「待审核」在颜色上分开:两者归一后都是 review,
|
||||
只靠文字区分时一眼扫过去仍会混为一谈 */
|
||||
.status-rejected {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
.tone-draft,
|
||||
.tone-archived,
|
||||
.tone-stopped {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.template-meta {
|
||||
.tpl-meta {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
@@ -277,9 +249,36 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
flex: 1;
|
||||
.tpl-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 归属徽标:全院通用用蓝、科室用绿,与原型 tpl-card 的取色一致 */
|
||||
.exam-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.exam-tag.is-general {
|
||||
color: #3a7bd5;
|
||||
background: #e7effb;
|
||||
}
|
||||
|
||||
.exam-tag.is-department {
|
||||
color: #0b7a4b;
|
||||
background: #e2f5ec;
|
||||
}
|
||||
|
||||
.tpl-desc {
|
||||
display: -webkit-box;
|
||||
flex: 1;
|
||||
min-height: 58px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
@@ -290,90 +289,50 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.template-footer {
|
||||
.tpl-ft {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.department-tag {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 3px 9px;
|
||||
color: var(--brand);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--brand-l);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.department-tag i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 按钮数量随状态变化(待审核时多一个「驳回」),允许换行并右对齐,
|
||||
否则窄屏下最后一个按钮会被挤出卡片 */
|
||||
.template-actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* 把状态动作与发起推到右侧,左侧四个入口(预览/属性/正文/历史)成一组 */
|
||||
.ft-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
padding: 4px 9px;
|
||||
color: var(--brand);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.card-button:hover {
|
||||
background: var(--brand-l);
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.card-button.primary {
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.card-button.primary:hover {
|
||||
color: #fff;
|
||||
background: var(--brand-d);
|
||||
}
|
||||
|
||||
.card-button:disabled {
|
||||
color: #aebbc0;
|
||||
background: #f1f5f6;
|
||||
border-color: #dce5e8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-button.primary:disabled:hover {
|
||||
background: #f1f5f6;
|
||||
}
|
||||
|
||||
.card-button.danger {
|
||||
color: #b3442f;
|
||||
border-color: #cf7c68;
|
||||
}
|
||||
|
||||
.card-button.danger:hover {
|
||||
background: #fbeee9;
|
||||
border-color: var(--brand-d);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.template-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
.ft-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -244,6 +244,9 @@ onBeforeUnmount(() => {
|
||||
:default-config="toolbarConfig"
|
||||
:mode="editorMode"
|
||||
/>
|
||||
<!-- 工具栏的第二行(原型 mdDocEd 里「插入签署占位符」那一行)。
|
||||
放在工具栏与正文之间,让"插入什么"与"排版成什么样"在同一条视觉带里。 -->
|
||||
<slot name="toolbar-extra" />
|
||||
<Editor
|
||||
class="pane-content"
|
||||
:model-value="props.modelValue"
|
||||
@@ -316,8 +319,16 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
/* ---- 占位符 / 勾选框胶囊 ----
|
||||
现在有两种占位符胶囊,样式刻意保持一致(都代表"待签署内容",不该靠外观区分重要性):
|
||||
配色与形状照原型 V10.2 的 .docfld(见 styles/signing-document.css 里签署态的同一套色):
|
||||
|
||||
- 文本 / 日期:蓝(--brand 系)
|
||||
- 签名:橙(#c07f00 系)——它决定签名落位,是文书里最不能漏的一项
|
||||
- 勾选 / 选择:绿(#0f9d6c 系),且前面带一个 ☑ 提示这是可勾的
|
||||
|
||||
原型不给胶囊加类型角标,靠颜色区分类型;这里也照做,
|
||||
完整信息(必填 / 选项 / 多选 / 高危值)留在 title 里。
|
||||
|
||||
两种载体共用同一套外观:
|
||||
① 结构化占位符(.ms-sign-node):Slate 元素节点,正文里存成 <span data-field>。
|
||||
标签文字走下面的 ::before,DOM 里没有可落光标的文字,一次退格整块删除。
|
||||
② 旧式 {患者姓名}(.ms-sign-brace + .ms-sign-chip):decorate 出来的叶子,
|
||||
@@ -340,25 +351,49 @@ onBeforeUnmount(() => {
|
||||
margin-left: -0.18em;
|
||||
}
|
||||
|
||||
/* 占位符主体:带类型角标的胶囊 */
|
||||
/* 占位符主体:原型 .docfld 的编辑器版(虚线胶囊 + 品牌浅底 + 加粗) */
|
||||
.pane-content :deep(.ms-sign-chip) {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
padding: 1px 8px;
|
||||
margin: 0 2px;
|
||||
color: var(--brand-d);
|
||||
font-size: 0.92em;
|
||||
font-weight: 700;
|
||||
line-height: 1.65;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
line-height: 1.7;
|
||||
white-space: nowrap;
|
||||
background: var(--brand-l);
|
||||
border: 1px solid #bcd9e3;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* 结构化占位符的标签文字:走 CSS 内容而不是 DOM 文本节点。
|
||||
元素是 void,DOM 里只有它自己与一个绝对定位的零宽占位叶子;
|
||||
若在这里放真实文字,浏览器原生光标会落进文字里,
|
||||
一次退格就只删掉一个字符 —— 正好破坏"一次退格整块删除"这个设计目标 */
|
||||
一次退格就只删掉一个字符 —— 正好破坏"一次退格整块删除"这个设计目标。
|
||||
|
||||
外层方括号与原型 fieldHtml 的 `【label】` 一致:正文里一眼能认出这是占位符而不是文书正文。 */
|
||||
.pane-content :deep(.ms-sign-node)::before {
|
||||
content: attr(data-sign-label);
|
||||
content: '【' attr(data-sign-label) '】';
|
||||
}
|
||||
|
||||
/* 签名位:橙(原型 .docfld.k-signature) */
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='signature']) {
|
||||
color: #9a5f00;
|
||||
background: #fff7e8;
|
||||
border-color: #c07f00;
|
||||
}
|
||||
|
||||
/* 勾选 / 选择:绿,并在标签前补一个 ☑(原型 .docfld.k-checkbox::before) */
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='checkbox']),
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='choice']) {
|
||||
color: #0b7a4b;
|
||||
background: #eafaf1;
|
||||
border-color: #0f9d6c;
|
||||
}
|
||||
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='checkbox'])::before,
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='choice'])::before {
|
||||
content: '\2611 【' attr(data-sign-label) '】';
|
||||
}
|
||||
|
||||
/* 选中态:点一下即整块选中,给个明确的反色,让人知道接下来的退格会删掉整块 */
|
||||
@@ -368,21 +403,15 @@ onBeforeUnmount(() => {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.pane-content :deep(.ms-sign-node.is-selected)::after {
|
||||
border-left-color: rgb(255 255 255 / 35%);
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='signature'].is-selected) {
|
||||
background: #c07f00;
|
||||
border-color: #c07f00;
|
||||
}
|
||||
|
||||
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么。
|
||||
所有占位符共用一个样式,靠这个角标区分类型,不再按必填/签名位分色 */
|
||||
.pane-content :deep(.ms-sign-chip)::after {
|
||||
padding-left: 5px;
|
||||
margin-left: 5px;
|
||||
color: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: 400;
|
||||
content: attr(data-sign-tag);
|
||||
border-left: 1px solid rgb(0 0 0 / 10%);
|
||||
opacity: 0.7;
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='checkbox'].is-selected),
|
||||
.pane-content :deep(.ms-sign-node[data-sign-kind='choice'].is-selected) {
|
||||
background: #0f9d6c;
|
||||
border-color: #0f9d6c;
|
||||
}
|
||||
|
||||
/* 勾选框:签署时会转成可勾选控件,这里给它一个"框"的观感。
|
||||
|
||||
Reference in New Issue
Block a user