499 lines
12 KiB
Vue
499 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, reactive, watch } from 'vue'
|
||
|
||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||
import type { TemplateVersionAction } from '@/api/management/documents'
|
||
|
||
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||
|
||
const props = defineProps<{
|
||
visible: boolean
|
||
template: DocumentTemplate | null
|
||
campuses: CampusRecord[]
|
||
departments: DepartmentRecord[]
|
||
saving: boolean
|
||
workflowSaving: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
save: [form: TemplateEditorForm]
|
||
workflow: [action: TemplateVersionAction, comment?: string]
|
||
}>()
|
||
|
||
const form = reactive<TemplateEditorForm>(createEmptyForm())
|
||
const error = reactive({ message: '' })
|
||
const rejectComment = reactive({ value: '' })
|
||
|
||
const isNew = computed(() => !form.id)
|
||
const availableDepartments = computed(() =>
|
||
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
|
||
)
|
||
|
||
const workflowActions = computed(() => {
|
||
if (!props.template || !form.id) {
|
||
return []
|
||
}
|
||
|
||
const actions: Array<{ action: TemplateVersionAction; label: string; tone: string }> = []
|
||
|
||
if (
|
||
props.template.backendStatus === 'DRAFT' ||
|
||
props.template.backendStatus === 'REJECTED' ||
|
||
(!props.template.backendStatus && props.template.status === 'draft')
|
||
) {
|
||
actions.push({ action: 'submit-review', label: '提交审核', tone: 'primary' })
|
||
}
|
||
|
||
if (
|
||
props.template.backendStatus === 'PENDING_REVIEW' ||
|
||
(!props.template.backendStatus && props.template.status === 'review')
|
||
) {
|
||
actions.push({ action: 'approve', label: '审核通过', tone: 'primary' })
|
||
actions.push({ action: 'reject', label: '驳回', tone: 'danger' })
|
||
}
|
||
|
||
if (props.template.backendStatus === 'APPROVED') {
|
||
actions.push({ action: 'publish', label: '发布版本', tone: 'primary' })
|
||
}
|
||
|
||
if (
|
||
props.template.backendStatus === 'PUBLISHED' ||
|
||
(!props.template.backendStatus && props.template.status === 'enabled')
|
||
) {
|
||
actions.push({ action: 'disable', label: '停用版本', tone: 'secondary' })
|
||
actions.push({ action: 'archive', label: '归档版本', tone: 'secondary' })
|
||
}
|
||
|
||
return actions
|
||
})
|
||
|
||
function createEmptyForm(): TemplateEditorForm {
|
||
return {
|
||
templateCode: '',
|
||
name: '',
|
||
description: '',
|
||
campusId: props.campuses[0]?.id ?? '',
|
||
departmentId: '',
|
||
category: '知情同意书',
|
||
versionNo: '',
|
||
contentHtml: '',
|
||
}
|
||
}
|
||
|
||
function resetForm() {
|
||
if (!props.template) {
|
||
Object.assign(form, createEmptyForm())
|
||
} else {
|
||
Object.assign(form, {
|
||
id: props.template.id,
|
||
templateCode: props.template.code,
|
||
name: props.template.name,
|
||
description: props.template.description,
|
||
campusId: props.template.campusId ?? props.campuses[0]?.id ?? '',
|
||
departmentId: props.template.departmentId ?? '',
|
||
category: props.template.category,
|
||
versionNo: '',
|
||
contentHtml: props.template.contentHtml ?? '',
|
||
})
|
||
}
|
||
|
||
error.message = ''
|
||
rejectComment.value = ''
|
||
}
|
||
|
||
function handleCampusChange() {
|
||
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
|
||
form.departmentId = ''
|
||
}
|
||
}
|
||
|
||
function submit() {
|
||
error.message = ''
|
||
|
||
if (!form.templateCode.trim()) {
|
||
error.message = '请输入模板编号'
|
||
return
|
||
}
|
||
|
||
if (!form.name.trim()) {
|
||
error.message = '请输入模板名称'
|
||
return
|
||
}
|
||
|
||
if (!form.campusId) {
|
||
error.message = '请选择院区'
|
||
return
|
||
}
|
||
|
||
if (!form.contentHtml.trim()) {
|
||
error.message = '请输入模板 HTML 内容'
|
||
return
|
||
}
|
||
|
||
emit('save', {
|
||
...form,
|
||
templateCode: form.templateCode.trim(),
|
||
name: form.name.trim(),
|
||
description: form.description.trim(),
|
||
category: form.category.trim() || '知情同意书',
|
||
versionNo: form.versionNo.trim(),
|
||
contentHtml: form.contentHtml.trim(),
|
||
})
|
||
}
|
||
|
||
function runWorkflow(action: TemplateVersionAction) {
|
||
if (action === 'reject' && !rejectComment.value.trim()) {
|
||
error.message = '请输入驳回原因'
|
||
return
|
||
}
|
||
|
||
error.message = ''
|
||
emit('workflow', action, rejectComment.value.trim() || undefined)
|
||
}
|
||
|
||
watch(
|
||
() => props.visible,
|
||
(visible) => {
|
||
if (visible) {
|
||
resetForm()
|
||
}
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => props.template,
|
||
() => {
|
||
if (props.visible) {
|
||
resetForm()
|
||
}
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body">
|
||
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
|
||
<section class="editor-dialog" role="dialog" aria-modal="true" aria-labelledby="editor-title">
|
||
<header class="dialog-header">
|
||
<div>
|
||
<strong id="editor-title">{{ isNew ? '新增文档模板' : '编辑模板版本' }}</strong>
|
||
<p>
|
||
{{
|
||
isNew
|
||
? '创建模板并同时保存首个版本。'
|
||
: '编辑操作会创建新的文书版本,已发布版本不会被直接覆盖。'
|
||
}}
|
||
</p>
|
||
</div>
|
||
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||
×
|
||
</button>
|
||
</header>
|
||
|
||
<form class="dialog-body" @submit.prevent="submit">
|
||
<div class="form-grid">
|
||
<label>
|
||
<span>模板编号 <em>*</em></span>
|
||
<input
|
||
v-model="form.templateCode"
|
||
:disabled="!isNew"
|
||
placeholder="如:TPL-RAD-MRI-001"
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>模板名称 <em>*</em></span>
|
||
<input v-model="form.name" :disabled="!isNew" placeholder="请输入模板名称" />
|
||
</label>
|
||
<label>
|
||
<span>院区 <em>*</em></span>
|
||
<select v-model="form.campusId" :disabled="!isNew" @change="handleCampusChange">
|
||
<option value="" disabled>请选择院区</option>
|
||
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
|
||
{{ campus.name }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>科室</span>
|
||
<select v-model="form.departmentId" :disabled="!isNew">
|
||
<option value="">全院通用</option>
|
||
<option
|
||
v-for="department in availableDepartments"
|
||
:key="department.id"
|
||
:value="department.id"
|
||
>
|
||
{{ department.name }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>文档类别</span>
|
||
<input v-model="form.category" :disabled="!isNew" placeholder="如:知情同意书" />
|
||
</label>
|
||
<label>
|
||
<span>版本号</span>
|
||
<input v-model="form.versionNo" placeholder="如:V1.0,不填由服务端生成" />
|
||
</label>
|
||
</div>
|
||
|
||
<label class="full-field">
|
||
<span>模板说明</span>
|
||
<textarea
|
||
v-model="form.description"
|
||
:disabled="!isNew"
|
||
rows="2"
|
||
placeholder="请输入模板说明"
|
||
/>
|
||
</label>
|
||
|
||
<label class="full-field">
|
||
<span>模板 HTML <em>*</em></span>
|
||
<textarea
|
||
v-model="form.contentHtml"
|
||
rows="11"
|
||
placeholder="请输入服务端可渲染的 HTML 内容,不要包含脚本。"
|
||
/>
|
||
</label>
|
||
|
||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||
<p class="form-tip">
|
||
版本工作流由服务端校验权限和状态;此处不会将本地签名或患者数据写入模板内容。
|
||
</p>
|
||
</form>
|
||
|
||
<footer class="dialog-footer">
|
||
<div v-if="workflowActions.length" class="workflow-actions">
|
||
<span>当前版本:{{ template?.version }}</span>
|
||
<textarea
|
||
v-if="workflowActions.some((item) => item.action === 'reject')"
|
||
v-model="rejectComment.value"
|
||
rows="1"
|
||
placeholder="驳回原因(驳回时必填)"
|
||
/>
|
||
<button
|
||
v-for="item in workflowActions"
|
||
:key="item.action"
|
||
type="button"
|
||
class="footer-button"
|
||
:class="item.tone"
|
||
:disabled="saving || workflowSaving"
|
||
@click="runWorkflow(item.action)"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
<span class="footer-spacer" />
|
||
<button type="button" class="footer-button secondary" @click="emit('close')">取消</button>
|
||
<button type="button" class="footer-button primary" :disabled="saving" @click="submit">
|
||
{{ saving ? '保存中…' : isNew ? '创建模板' : '创建新版本' }}
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dialog-mask {
|
||
position: fixed;
|
||
z-index: 30;
|
||
inset: 0;
|
||
display: flex;
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
background: rgb(9 40 52 / 45%);
|
||
}
|
||
|
||
.editor-dialog {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 760px;
|
||
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: 12px;
|
||
align-items: center;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.dialog-header strong {
|
||
color: var(--ink);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.dialog-header p {
|
||
margin: 4px 0 0;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.close-button {
|
||
flex-shrink: 0;
|
||
margin-left: auto;
|
||
color: var(--mut);
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
background: transparent;
|
||
}
|
||
|
||
.close-button:hover {
|
||
color: var(--ink);
|
||
}
|
||
|
||
.dialog-body {
|
||
min-height: 0;
|
||
padding: 16px 18px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px 14px;
|
||
}
|
||
|
||
.form-grid label,
|
||
.full-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.full-field {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
label span em {
|
||
color: var(--err);
|
||
font-style: normal;
|
||
}
|
||
|
||
input,
|
||
select,
|
||
textarea {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
color: var(--ink);
|
||
font: inherit;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
resize: vertical;
|
||
}
|
||
|
||
input:focus,
|
||
select:focus,
|
||
textarea:focus {
|
||
outline: none;
|
||
border-color: var(--brand);
|
||
box-shadow: 0 0 0 3px rgb(14 110 140 / 10%);
|
||
}
|
||
|
||
input:disabled,
|
||
select:disabled,
|
||
textarea:disabled {
|
||
color: var(--mut);
|
||
background: #f5f8f9;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.form-error {
|
||
margin: 12px 0 0;
|
||
color: var(--err);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.form-tip {
|
||
margin: 12px 0 0;
|
||
color: var(--mut);
|
||
font-size: 11.5px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
padding: 10px 18px;
|
||
background: #f8fbfc;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.workflow-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
color: var(--mut);
|
||
font-size: 11.5px;
|
||
}
|
||
|
||
.workflow-actions textarea {
|
||
width: 170px;
|
||
min-height: 30px;
|
||
padding: 5px 8px;
|
||
}
|
||
|
||
.footer-spacer {
|
||
flex: 1;
|
||
}
|
||
|
||
.footer-button {
|
||
padding: 7px 13px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
border: 1px solid transparent;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.footer-button:disabled {
|
||
cursor: wait;
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.footer-button.primary {
|
||
color: #fff;
|
||
background: var(--brand);
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
.footer-button.secondary {
|
||
color: var(--brand);
|
||
background: #fff;
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
.footer-button.danger {
|
||
color: var(--err);
|
||
background: var(--err-l);
|
||
border-color: rgb(194 73 73 / 25%);
|
||
}
|
||
|
||
.footer-button:hover:not(:disabled) {
|
||
filter: brightness(0.96);
|
||
}
|
||
|
||
@media (max-width: 680px) {
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.footer-spacer {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|