feat(management): 接入文档用户权限与报表
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import type { DocumentRecord, TemplatePermissionRecord } from '@/api/management/types'
|
||||
|
||||
import type { PermissionSubjectOption, TemplatePermissionForm } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
templates: DocumentRecord[]
|
||||
selectedTemplateId: string
|
||||
permissions: TemplatePermissionRecord[]
|
||||
subjects: PermissionSubjectOption[]
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedTemplateId': [value: string]
|
||||
add: [form: TemplatePermissionForm]
|
||||
delete: [permissionId: string]
|
||||
}>()
|
||||
|
||||
const form = reactive<TemplatePermissionForm>({
|
||||
subjectType: 'ROLE',
|
||||
subjectId: '',
|
||||
permissionLevel: 'VIEW',
|
||||
effect: 'ALLOW',
|
||||
})
|
||||
|
||||
const subjectTypeLabels: Record<PermissionSubjectOption['type'], string> = {
|
||||
ROLE: '角色',
|
||||
DEPARTMENT: '科室',
|
||||
USER: '用户',
|
||||
}
|
||||
|
||||
const permissionLabels: Record<TemplatePermissionForm['permissionLevel'], string> = {
|
||||
VIEW: '可见(查看)',
|
||||
USE: '可用(发起签署)',
|
||||
MAINTAIN: '可维护(编辑/停用)',
|
||||
}
|
||||
|
||||
const effectLabels: Record<TemplatePermissionForm['effect'], string> = {
|
||||
ALLOW: '允许',
|
||||
DENY: '拒绝',
|
||||
}
|
||||
|
||||
const availableSubjects = computed(() =>
|
||||
props.subjects.filter((subject) => subject.type === form.subjectType),
|
||||
)
|
||||
|
||||
const selectedTemplate = computed(() =>
|
||||
props.templates.find((template) => template.id === props.selectedTemplateId),
|
||||
)
|
||||
|
||||
function handleTemplateChange(event: Event) {
|
||||
emit('update:selectedTemplateId', (event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
function handleSubjectTypeChange() {
|
||||
form.subjectId = availableSubjects.value[0]?.id ?? ''
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!form.subjectId) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('add', { ...form })
|
||||
}
|
||||
|
||||
function formatSubject(subjectType: PermissionSubjectOption['type'], subjectId: string) {
|
||||
const subject = props.subjects.find((item) => item.type === subjectType && item.id === subjectId)
|
||||
return subject?.label ?? subjectId
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.subjects,
|
||||
() => {
|
||||
if (!availableSubjects.value.some((subject) => subject.id === form.subjectId)) {
|
||||
form.subjectId = availableSubjects.value[0]?.id ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="permission-panel">
|
||||
<div class="panel-toolbar">
|
||||
<label class="template-select">
|
||||
<span>选择模板</span>
|
||||
<select :value="selectedTemplateId" @change="handleTemplateChange">
|
||||
<option value="" disabled>请选择模板</option>
|
||||
<option v-for="template in templates" :key="template.id" :value="template.id">
|
||||
{{ template.name }} · {{ template.version }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<span v-if="selectedTemplate" class="template-meta">
|
||||
{{ selectedTemplate.code || selectedTemplate.id }} · {{ selectedTemplate.category }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedTemplateId" class="empty-state">暂无模板,请先选择一个模板。</div>
|
||||
<div v-else-if="loading" class="loading-state">正在加载模板权限…</div>
|
||||
<template v-else>
|
||||
<div class="permission-list">
|
||||
<div class="list-heading">
|
||||
<span>已配置权限</span>
|
||||
<span>{{ permissions.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="permissions.length" class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>授权对象</th>
|
||||
<th>权限</th>
|
||||
<th>效果</th>
|
||||
<th>来源</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="permission in permissions" :key="permission.id">
|
||||
<td>
|
||||
<span class="subject-type">{{ subjectTypeLabels[permission.subjectType] }}</span>
|
||||
{{ formatSubject(permission.subjectType, permission.subjectId) }}
|
||||
</td>
|
||||
<td>{{ permissionLabels[permission.permissionLevel] }}</td>
|
||||
<td>{{ effectLabels[permission.effect] }}</td>
|
||||
<td>{{ permission.inherited ? '继承' : '直接' }}</td>
|
||||
<td>{{ permission.createdAt }}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="delete-button"
|
||||
:disabled="permission.inherited || saving"
|
||||
:title="permission.inherited ? '继承权限不可直接删除' : '删除权限'"
|
||||
@click="emit('delete', permission.id)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="empty-state compact">当前模板暂无直接权限配置。</p>
|
||||
</div>
|
||||
|
||||
<form class="add-form" @submit.prevent="submit">
|
||||
<div class="list-heading">
|
||||
<span>新增权限</span>
|
||||
<span>服务端会再次校验授权范围</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>对象类型</span>
|
||||
<select v-model="form.subjectType" @change="handleSubjectTypeChange">
|
||||
<option v-for="(label, value) in subjectTypeLabels" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>授权对象</span>
|
||||
<select v-model="form.subjectId" :disabled="!availableSubjects.length">
|
||||
<option value="" disabled>请选择对象</option>
|
||||
<option v-for="subject in availableSubjects" :key="subject.id" :value="subject.id">
|
||||
{{ subject.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>权限级别</span>
|
||||
<select v-model="form.permissionLevel">
|
||||
<option v-for="(label, value) in permissionLabels" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>授权效果</span>
|
||||
<select v-model="form.effect">
|
||||
<option v-for="(label, value) in effectLabels" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="add-button" :disabled="saving || !form.subjectId">
|
||||
{{ saving ? '保存中…' : '添加权限' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permission-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.template-select,
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
min-width: min(420px, 100%);
|
||||
}
|
||||
|
||||
select {
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
background: #f5f8f9;
|
||||
}
|
||||
|
||||
.template-meta {
|
||||
padding-bottom: 8px;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.list-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
color: var(--ink);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-heading span:last-child {
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 8px 10px;
|
||||
color: var(--mut);
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
background: #f5f9fa;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
.subject-type {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
margin-right: 5px;
|
||||
color: var(--brand);
|
||||
font-size: 10.5px;
|
||||
background: var(--brand-l);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 3px 8px;
|
||||
color: var(--err);
|
||||
font-size: 11.5px;
|
||||
background: #fff;
|
||||
border: 1px solid rgb(194 73 73 / 32%);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.delete-button:disabled {
|
||||
color: #aebbc0;
|
||||
border-color: #dce5e8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
padding: 7px 13px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: var(--brand);
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.add-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
padding: 32px 16px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 18px 12px;
|
||||
margin: 0;
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.add-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,24 +2,118 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { getDepartments } from '@/api/management/organization'
|
||||
import { getDocuments } from '@/api/management/documents'
|
||||
import {
|
||||
createTemplatePermission,
|
||||
deleteTemplatePermission,
|
||||
getTemplatePermissions,
|
||||
isPermissionMockEnabled,
|
||||
} from '@/api/management/document-permissions'
|
||||
import { getRoles } from '@/api/management/roles'
|
||||
import { getUsers } from '@/api/management/users'
|
||||
import type { DocumentRecord, TemplatePermissionRecord } from '@/api/management/types'
|
||||
|
||||
import DepartmentPermissionTable from './components/DepartmentPermissionTable.vue'
|
||||
import RolePermissionMatrix from './components/RolePermissionMatrix.vue'
|
||||
import TemplatePermissionPanel from './components/TemplatePermissionPanel.vue'
|
||||
import { departmentPermissionRows, permissionColumns, rolePermissionRows } from './mock'
|
||||
import type { DepartmentPermissionRow, PermissionKey, RolePermissionRow } from './types'
|
||||
import type {
|
||||
DepartmentPermissionRow,
|
||||
PermissionKey,
|
||||
PermissionSubjectOption,
|
||||
RolePermissionRow,
|
||||
TemplatePermissionForm,
|
||||
} from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const roles = ref<RolePermissionRow[]>([])
|
||||
const departments = ref<DepartmentPermissionRow[]>([])
|
||||
const templates = ref<DocumentRecord[]>([])
|
||||
const selectedTemplateId = ref('')
|
||||
const templatePermissions = ref<TemplatePermissionRecord[]>([])
|
||||
const permissionSubjects = ref<PermissionSubjectOption[]>([])
|
||||
const permissionLoading = ref(false)
|
||||
const permissionSaving = ref(false)
|
||||
|
||||
async function loadPermissions() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
|
||||
try {
|
||||
if (isPermissionMockEnabled) {
|
||||
roles.value = rolePermissionRows.map((role) => ({
|
||||
...role,
|
||||
permissions: { ...role.permissions },
|
||||
}))
|
||||
departments.value = departmentPermissionRows.map((row) => ({ ...row }))
|
||||
return
|
||||
}
|
||||
|
||||
const [templatesResult, rolesResult, departmentsResult, usersResult] = await Promise.allSettled(
|
||||
[
|
||||
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
|
||||
getRoles(),
|
||||
getDepartments(),
|
||||
getUsers({ page: 1, pageSize: 200, status: 'all' }),
|
||||
],
|
||||
)
|
||||
|
||||
if (templatesResult.status === 'rejected') {
|
||||
throw templatesResult.reason
|
||||
}
|
||||
|
||||
templates.value = templatesResult.value.records
|
||||
selectedTemplateId.value = templates.value[0]?.id ?? ''
|
||||
permissionSubjects.value = [
|
||||
...(rolesResult.status === 'fulfilled'
|
||||
? rolesResult.value.map((role) => ({
|
||||
type: 'ROLE' as const,
|
||||
id: role.id,
|
||||
label: role.name,
|
||||
}))
|
||||
: []),
|
||||
...(departmentsResult.status === 'fulfilled'
|
||||
? departmentsResult.value.map((department) => ({
|
||||
type: 'DEPARTMENT' as const,
|
||||
id: department.id,
|
||||
label: department.name,
|
||||
}))
|
||||
: []),
|
||||
...(usersResult.status === 'fulfilled'
|
||||
? usersResult.value.records.map((user) => ({
|
||||
type: 'USER' as const,
|
||||
id: user.id,
|
||||
label: `${user.name}${user.employeeNo ? `(${user.employeeNo})` : ''}`,
|
||||
}))
|
||||
: []),
|
||||
]
|
||||
await loadTemplatePermissions()
|
||||
} catch {
|
||||
templates.value = []
|
||||
permissionSubjects.value = []
|
||||
templatePermissions.value = []
|
||||
ElMessage.error('权限配置加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplatePermissions() {
|
||||
if (!selectedTemplateId.value || isPermissionMockEnabled) {
|
||||
templatePermissions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
permissionLoading.value = true
|
||||
|
||||
try {
|
||||
templatePermissions.value = await getTemplatePermissions(selectedTemplateId.value)
|
||||
} catch {
|
||||
templatePermissions.value = []
|
||||
ElMessage.error('模板权限加载失败,请稍后重试')
|
||||
} finally {
|
||||
permissionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRolePermission(roleId: string, permission: PermissionKey) {
|
||||
@@ -37,6 +131,47 @@ function adjustDepartmentPermission(row: DepartmentPermissionRow) {
|
||||
ElMessage.info(`原型演示:调整${row.department}文档权限`)
|
||||
}
|
||||
|
||||
async function addTemplatePermission(form: TemplatePermissionForm) {
|
||||
if (!selectedTemplateId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
permissionSaving.value = true
|
||||
|
||||
try {
|
||||
await createTemplatePermission(selectedTemplateId.value, form)
|
||||
await loadTemplatePermissions()
|
||||
ElMessage.success('模板权限已添加')
|
||||
} catch {
|
||||
ElMessage.error('模板权限添加失败,请稍后重试')
|
||||
} finally {
|
||||
permissionSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplatePermissionRecord(permissionId: string) {
|
||||
if (!selectedTemplateId.value || !window.confirm('确定删除这条模板权限吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
permissionSaving.value = true
|
||||
|
||||
try {
|
||||
await deleteTemplatePermission(selectedTemplateId.value, permissionId)
|
||||
await loadTemplatePermissions()
|
||||
ElMessage.success('模板权限已删除')
|
||||
} catch {
|
||||
ElMessage.error('模板权限删除失败,请稍后重试')
|
||||
} finally {
|
||||
permissionSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectTemplate(templateId: string) {
|
||||
selectedTemplateId.value = templateId
|
||||
void loadTemplatePermissions()
|
||||
}
|
||||
|
||||
onMounted(loadPermissions)
|
||||
</script>
|
||||
|
||||
@@ -61,7 +196,7 @@ onMounted(loadPermissions)
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-else-if="isPermissionMockEnabled">
|
||||
<section class="page-card">
|
||||
<div class="card-heading">
|
||||
<h2>按角色授权</h2>
|
||||
@@ -81,6 +216,24 @@ onMounted(loadPermissions)
|
||||
<DepartmentPermissionTable :rows="departments" @adjust="adjustDepartmentPermission" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-else class="page-card">
|
||||
<div class="card-heading">
|
||||
<h2>模板权限</h2>
|
||||
<span>按模板配置角色、科室和用户的可见/可用/可维护权限</span>
|
||||
</div>
|
||||
<TemplatePermissionPanel
|
||||
:templates="templates"
|
||||
:selected-template-id="selectedTemplateId"
|
||||
:permissions="templatePermissions"
|
||||
:subjects="permissionSubjects"
|
||||
:loading="permissionLoading"
|
||||
:saving="permissionSaving"
|
||||
@update:selected-template-id="selectTemplate"
|
||||
@add="addTemplatePermission"
|
||||
@delete="deleteTemplatePermissionRecord"
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -24,3 +24,16 @@ export interface DepartmentPermissionRow {
|
||||
commonDocuments: DepartmentDocumentAccess
|
||||
otherDocuments: DepartmentDocumentAccess
|
||||
}
|
||||
|
||||
export interface PermissionSubjectOption {
|
||||
id: string
|
||||
type: 'ROLE' | 'DEPARTMENT' | 'USER'
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TemplatePermissionForm {
|
||||
subjectType: PermissionSubjectOption['type']
|
||||
subjectId: string
|
||||
permissionLevel: 'VIEW' | 'USE' | 'MAINTAIN'
|
||||
effect: 'ALLOW' | 'DENY'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { DocumentTemplate } from '../types'
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
template: DocumentTemplate | null
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -49,7 +51,18 @@ const statusLabel = computed(() => {
|
||||
</header>
|
||||
|
||||
<div class="dialog-body">
|
||||
<article class="preview-paper">
|
||||
<div v-if="loading" class="preview-state">正在加载模板内容…</div>
|
||||
<div v-else-if="error" class="preview-state preview-state--error">
|
||||
模板内容加载失败,请关闭后重试。
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="template.contentHtml"
|
||||
class="preview-frame"
|
||||
:srcdoc="template.contentHtml"
|
||||
sandbox=""
|
||||
title="文档模板内容预览"
|
||||
/>
|
||||
<article v-else class="preview-paper">
|
||||
<h3>{{ template.name }}</h3>
|
||||
<p class="paper-number">模板编号:{{ template.code }} · 版本 {{ template.version }}</p>
|
||||
<p>
|
||||
@@ -84,9 +97,21 @@ const statusLabel = computed(() => {
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<p>占位符由系统在签署时自动填充,当前仅用于原型演示。</p>
|
||||
<p>
|
||||
{{
|
||||
template.contentHtml
|
||||
? '模板内容来自服务端,预览区域已限制脚本执行。'
|
||||
: '占位符由系统在签署时自动填充,当前仅用于原型演示。'
|
||||
}}
|
||||
</p>
|
||||
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
|
||||
<button type="button" class="footer-button primary" @click="emit('use', template)">
|
||||
<button
|
||||
type="button"
|
||||
class="footer-button primary"
|
||||
:disabled="template.status !== 'enabled'"
|
||||
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
|
||||
@click="emit('use', template)"
|
||||
>
|
||||
用此模板发起签署
|
||||
</button>
|
||||
</footer>
|
||||
@@ -194,6 +219,26 @@ const statusLabel = computed(() => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-state {
|
||||
display: grid;
|
||||
min-height: 420px;
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.preview-state--error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 560px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
.preview-paper {
|
||||
min-height: 420px;
|
||||
padding: 26px 34px;
|
||||
@@ -299,6 +344,14 @@ const statusLabel = computed(() => {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.footer-button:disabled {
|
||||
color: #aebbc0;
|
||||
background: #f1f5f6;
|
||||
border-color: #dce5e8;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.preview-paper {
|
||||
padding: 22px 18px;
|
||||
|
||||
@@ -40,7 +40,13 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
<div class="template-actions">
|
||||
<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 primary" @click="emit('initiate', template)">
|
||||
<button
|
||||
type="button"
|
||||
class="card-button primary"
|
||||
:disabled="template.status !== 'enabled'"
|
||||
:title="template.status === 'enabled' ? '用此模板发起签署' : '仅已启用模板可发起签署'"
|
||||
@click="emit('initiate', template)"
|
||||
>
|
||||
发起
|
||||
</button>
|
||||
</div>
|
||||
@@ -207,6 +213,17 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
background: var(--brand-d);
|
||||
}
|
||||
|
||||
.card-button:disabled {
|
||||
color: #aebbc0;
|
||||
background: #f1f5f6;
|
||||
border-color: #dce5e8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-button.primary:disabled:hover {
|
||||
background: #f1f5f6;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.template-footer {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
<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>
|
||||
@@ -2,6 +2,18 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||
import {
|
||||
createTemplate,
|
||||
createTemplateVersion,
|
||||
getDocumentDetail,
|
||||
getDocuments,
|
||||
getTemplateVersionDetail,
|
||||
isDocumentMockEnabled,
|
||||
updateTemplateVersionStatus,
|
||||
} from '@/api/management/documents'
|
||||
import type { TemplateVersionAction } from '@/api/management/documents'
|
||||
import type { CampusRecord, DepartmentRecord, DocumentRecord } from '@/api/management/types'
|
||||
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||
import type { SigningTemplate } from '@/api/workbench/types'
|
||||
|
||||
@@ -9,20 +21,53 @@ import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
|
||||
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
|
||||
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
|
||||
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
|
||||
import TemplateEditorDialog from './components/TemplateEditorDialog.vue'
|
||||
import { documentLibrary, documentStatusOptions } from './mock'
|
||||
import type {
|
||||
DepartmentTab,
|
||||
DocumentStatus,
|
||||
DocumentDepartment,
|
||||
DocumentFilterForm,
|
||||
DocumentTemplate,
|
||||
StatusTab,
|
||||
TemplateEditorForm,
|
||||
} from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const library = ref<DocumentDepartment[]>([])
|
||||
const selectedTemplate = ref<DocumentTemplate | null>(null)
|
||||
const signingTemplate = ref<SigningTemplate | null>(null)
|
||||
const editorTemplate = ref<DocumentTemplate | null>(null)
|
||||
const campuses = ref<CampusRecord[]>([])
|
||||
const departments = ref<DepartmentRecord[]>([])
|
||||
const signingDialogVisible = ref(false)
|
||||
const editorVisible = ref(false)
|
||||
const editorSaving = ref(false)
|
||||
const workflowSaving = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref(false)
|
||||
let previewRequestId = 0
|
||||
|
||||
const mockCampuses: CampusRecord[] = [
|
||||
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||||
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
||||
{ id: 'mock-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
|
||||
]
|
||||
|
||||
const mockDepartments: DepartmentRecord[] = [
|
||||
'放射科',
|
||||
'心内科',
|
||||
'骨科',
|
||||
'健康管理中心',
|
||||
'全院通用',
|
||||
].map((name, index) => ({
|
||||
id: `mock-template-department-${index + 1}`,
|
||||
campusId: index === 3 ? 'mock-east' : 'mock-main',
|
||||
code: `MOCK-TPL-${index + 1}`,
|
||||
name,
|
||||
status: 'ENABLED',
|
||||
sortNo: index + 1,
|
||||
}))
|
||||
|
||||
const filters = reactive<DocumentFilterForm>({
|
||||
keyword: '',
|
||||
@@ -76,39 +121,362 @@ const filteredTemplates = computed(() => {
|
||||
|
||||
async function loadLibrary() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
library.value = documentLibrary
|
||||
try {
|
||||
if (isDocumentMockEnabled) {
|
||||
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
|
||||
departments.value = mockDepartments.map((department) => ({ ...department }))
|
||||
library.value = documentLibrary.map((department) => ({
|
||||
...department,
|
||||
categories: department.categories.map((category) => ({
|
||||
...category,
|
||||
documents: category.documents.map((document) => ({ ...document })),
|
||||
})),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const [documentsResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
|
||||
getCampuses(),
|
||||
getDepartments(),
|
||||
])
|
||||
|
||||
if (documentsResult.status === 'rejected') {
|
||||
throw documentsResult.reason
|
||||
}
|
||||
|
||||
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||||
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
|
||||
library.value = buildDocumentLibrary(
|
||||
documentsResult.value.records,
|
||||
new Map(departments.value.map((department) => [department.id, department.name])),
|
||||
new Map(campuses.value.map((campus) => [campus.id, campus.name])),
|
||||
)
|
||||
} catch {
|
||||
library.value = []
|
||||
ElMessage.error('文档模板加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview(template: DocumentTemplate) {
|
||||
function mapDocumentStatus(status: DocumentRecord['status']): DocumentStatus {
|
||||
if (status === 'published') {
|
||||
return 'enabled'
|
||||
}
|
||||
|
||||
if (status === 'archived') {
|
||||
return 'archived'
|
||||
}
|
||||
|
||||
if (status === 'review') {
|
||||
return 'review'
|
||||
}
|
||||
|
||||
return 'draft'
|
||||
}
|
||||
|
||||
function buildDocumentLibrary(
|
||||
records: DocumentRecord[],
|
||||
departmentNames = new Map<string, string>(),
|
||||
campusNames = new Map<string, string>(),
|
||||
): DocumentDepartment[] {
|
||||
const departments = new Map<string, DocumentDepartment>()
|
||||
|
||||
records.forEach((record) => {
|
||||
const departmentName =
|
||||
record.departmentName || departmentNames.get(record.departmentId ?? '') || '全院通用'
|
||||
const departmentKey = record.departmentId || departmentName
|
||||
const campusName = record.campusName || campusNames.get(record.campusId ?? '')
|
||||
const department = departments.get(departmentKey) ?? {
|
||||
key: departmentKey,
|
||||
name: departmentName,
|
||||
campus:
|
||||
campusName === '本部院区' || campusName === '东院区' || campusName === '西院区'
|
||||
? campusName
|
||||
: 'all',
|
||||
categories: [],
|
||||
}
|
||||
const category =
|
||||
department.categories.find((item) => item.name === record.category) ??
|
||||
(() => {
|
||||
const nextCategory = { name: record.category, documents: [] }
|
||||
department.categories.push(nextCategory)
|
||||
return nextCategory
|
||||
})()
|
||||
|
||||
category.documents.push({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
code: record.code ?? record.id,
|
||||
version: record.version,
|
||||
status: mapDocumentStatus(record.status),
|
||||
backendStatus: record.backendStatus,
|
||||
description: record.description ?? '',
|
||||
versionId: record.versionId,
|
||||
departmentId: record.departmentId,
|
||||
campusId: record.campusId,
|
||||
contentHtml: record.contentHtml,
|
||||
contentSha256: record.contentSha256,
|
||||
})
|
||||
departments.set(departmentKey, department)
|
||||
})
|
||||
|
||||
return [...departments.values()]
|
||||
}
|
||||
|
||||
async function handlePreview(template: DocumentTemplate) {
|
||||
selectedTemplate.value = template
|
||||
previewError.value = false
|
||||
|
||||
if (isDocumentMockEnabled) {
|
||||
previewLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++previewRequestId
|
||||
previewLoading.value = true
|
||||
|
||||
try {
|
||||
const detail = await getDocumentDetail(template.id)
|
||||
|
||||
if (!detail) {
|
||||
throw new Error('模板不存在')
|
||||
}
|
||||
|
||||
let contentHtml = detail.contentHtml
|
||||
const versionId = detail.versionId ?? template.versionId
|
||||
|
||||
if (!contentHtml && versionId) {
|
||||
const version = await getTemplateVersionDetail(template.id, versionId)
|
||||
contentHtml = version?.contentHtml ?? version?.content ?? undefined
|
||||
}
|
||||
|
||||
if (requestId !== previewRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedTemplate.value = {
|
||||
...template,
|
||||
code: detail.code ?? template.code,
|
||||
version: detail.version || template.version,
|
||||
versionId,
|
||||
description: detail.description ?? template.description,
|
||||
contentHtml,
|
||||
contentSha256: detail.contentSha256,
|
||||
}
|
||||
} catch {
|
||||
if (requestId === previewRequestId) {
|
||||
previewError.value = true
|
||||
ElMessage.error('模板内容加载失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === previewRequestId) {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewRequestId += 1
|
||||
selectedTemplate.value = null
|
||||
previewLoading.value = false
|
||||
previewError.value = false
|
||||
}
|
||||
|
||||
function showAddMessage() {
|
||||
ElMessage.info('原型演示:新增文档模板(类 Word 在线编辑器)')
|
||||
editorTemplate.value = null
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function showExportMessage() {
|
||||
ElMessage.info('原型演示:导出模板包(ZIP)')
|
||||
}
|
||||
|
||||
function showEditMessage(template: DocumentTemplate) {
|
||||
ElMessage.info('原型演示:编辑模板《' + template.name + '》')
|
||||
async function showEditMessage(template: DocumentTemplate) {
|
||||
editorTemplate.value = template
|
||||
editorVisible.value = true
|
||||
|
||||
if (isDocumentMockEnabled || template.contentHtml) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await getDocumentDetail(template.id)
|
||||
const versionId = detail?.versionId ?? template.versionId
|
||||
let contentHtml = detail?.contentHtml
|
||||
|
||||
if (!contentHtml && versionId) {
|
||||
const version = await getTemplateVersionDetail(template.id, versionId)
|
||||
contentHtml = version?.contentHtml ?? version?.content ?? undefined
|
||||
}
|
||||
|
||||
if (editorVisible.value && editorTemplate.value?.id === template.id) {
|
||||
editorTemplate.value = {
|
||||
...template,
|
||||
versionId,
|
||||
description: detail?.description ?? template.description,
|
||||
contentHtml,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
ElMessage.warning('模板内容加载失败,可先关闭后重试')
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorVisible.value = false
|
||||
editorTemplate.value = null
|
||||
}
|
||||
|
||||
function addMockTemplate(form: TemplateEditorForm) {
|
||||
const department = departments.value.find((item) => item.id === form.departmentId)
|
||||
const departmentName = department?.name ?? '全院通用'
|
||||
const departmentKey = department?.id ?? 'mock-general'
|
||||
let departmentGroup = library.value.find((item) => item.key === departmentKey)
|
||||
|
||||
if (!departmentGroup) {
|
||||
departmentGroup = {
|
||||
key: departmentKey,
|
||||
name: departmentName,
|
||||
campus: 'all',
|
||||
categories: [],
|
||||
}
|
||||
library.value.push(departmentGroup)
|
||||
}
|
||||
|
||||
let category = departmentGroup.categories.find((item) => item.name === form.category)
|
||||
|
||||
if (!category) {
|
||||
category = { name: form.category, documents: [] }
|
||||
departmentGroup.categories.push(category)
|
||||
}
|
||||
|
||||
const timestamp = Date.now()
|
||||
category.documents.push({
|
||||
id: `mock-template-${timestamp}`,
|
||||
name: form.name,
|
||||
code: form.templateCode,
|
||||
version: form.versionNo || 'v1',
|
||||
status: 'draft',
|
||||
description: form.description,
|
||||
versionId: `mock-version-${timestamp}`,
|
||||
departmentId: form.departmentId || null,
|
||||
campusId: form.campusId,
|
||||
contentHtml: form.contentHtml,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveTemplate(form: TemplateEditorForm) {
|
||||
editorSaving.value = true
|
||||
|
||||
try {
|
||||
if (isDocumentMockEnabled) {
|
||||
if (form.id) {
|
||||
library.value.forEach((department) =>
|
||||
department.categories.forEach((category) =>
|
||||
category.documents.forEach((document) => {
|
||||
if (document.id === form.id) {
|
||||
document.version = form.versionNo || document.version
|
||||
document.contentHtml = form.contentHtml
|
||||
document.description = form.description
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
addMockTemplate(form)
|
||||
}
|
||||
} else if (form.id) {
|
||||
await createTemplateVersion(form.id, {
|
||||
versionNo: form.versionNo || undefined,
|
||||
contentHtml: form.contentHtml,
|
||||
})
|
||||
await loadLibrary()
|
||||
} else {
|
||||
const template = await createTemplate({
|
||||
templateCode: form.templateCode,
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
campusId: form.campusId,
|
||||
departmentId: form.departmentId || null,
|
||||
category: form.category || undefined,
|
||||
})
|
||||
await createTemplateVersion(template.id, {
|
||||
versionNo: form.versionNo || undefined,
|
||||
contentHtml: form.contentHtml,
|
||||
})
|
||||
await loadLibrary()
|
||||
}
|
||||
|
||||
closeEditor()
|
||||
ElMessage.success(form.id ? '模板新版本已保存' : '模板已创建')
|
||||
} catch {
|
||||
ElMessage.error(form.id ? '模板版本保存失败,请稍后重试' : '模板创建失败,请稍后重试')
|
||||
} finally {
|
||||
editorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateMockTemplateStatus(templateId: string, action: TemplateVersionAction) {
|
||||
const nextStatus =
|
||||
action === 'submit-review' || action === 'reject'
|
||||
? 'review'
|
||||
: action === 'disable' || action === 'archive'
|
||||
? 'archived'
|
||||
: 'enabled'
|
||||
|
||||
library.value.forEach((department) =>
|
||||
department.categories.forEach((category) =>
|
||||
category.documents.forEach((document) => {
|
||||
if (document.id === templateId) {
|
||||
document.status = nextStatus
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleWorkflow(action: TemplateVersionAction, comment?: string) {
|
||||
const template = editorTemplate.value
|
||||
const versionId = template?.versionId
|
||||
|
||||
if (!template || (!versionId && !isDocumentMockEnabled)) {
|
||||
ElMessage.error('当前模板没有可操作的版本')
|
||||
return
|
||||
}
|
||||
|
||||
workflowSaving.value = true
|
||||
|
||||
try {
|
||||
if (isDocumentMockEnabled) {
|
||||
updateMockTemplateStatus(template.id, action)
|
||||
} else if (versionId) {
|
||||
await updateTemplateVersionStatus(template.id, versionId, action, comment)
|
||||
await loadLibrary()
|
||||
} else {
|
||||
throw new Error('当前模板没有可操作的版本')
|
||||
}
|
||||
|
||||
closeEditor()
|
||||
ElMessage.success('模板版本状态已更新')
|
||||
} catch {
|
||||
ElMessage.error('模板版本操作失败,请稍后重试')
|
||||
} finally {
|
||||
workflowSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
||||
return {
|
||||
id: template.id,
|
||||
versionId: template.id,
|
||||
versionId: template.versionId ?? template.id,
|
||||
name: template.name,
|
||||
code: template.code,
|
||||
version: template.version,
|
||||
department: template.department,
|
||||
departmentId: template.departmentId,
|
||||
campusId: template.campusId ?? undefined,
|
||||
category: template.category,
|
||||
description: template.description,
|
||||
supportedMethods: ['pad', 'sms'],
|
||||
@@ -191,6 +559,8 @@ onMounted(loadLibrary)
|
||||
<DocumentPreviewDialog
|
||||
:visible="Boolean(selectedTemplate)"
|
||||
:template="selectedTemplate"
|
||||
:loading="previewLoading"
|
||||
:error="previewError"
|
||||
@close="closePreview"
|
||||
@use="useTemplate"
|
||||
/>
|
||||
@@ -199,6 +569,17 @@ onMounted(loadLibrary)
|
||||
:initial-template="signingTemplate"
|
||||
@close="closeSigningDialog"
|
||||
/>
|
||||
<TemplateEditorDialog
|
||||
:visible="editorVisible"
|
||||
:template="editorTemplate"
|
||||
:campuses="campuses"
|
||||
:departments="departments"
|
||||
:saving="editorSaving"
|
||||
:workflow-saving="workflowSaving"
|
||||
@close="closeEditor"
|
||||
@save="saveTemplate"
|
||||
@workflow="handleWorkflow"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@ export interface LibraryDocument {
|
||||
code: string
|
||||
version: string
|
||||
status: DocumentStatus
|
||||
backendStatus?: string
|
||||
description: string
|
||||
versionId?: string
|
||||
departmentId?: string | null
|
||||
campusId?: string | null
|
||||
contentHtml?: string
|
||||
contentSha256?: string
|
||||
}
|
||||
|
||||
export interface DocumentCategory {
|
||||
@@ -31,6 +37,18 @@ export interface DocumentTemplate extends LibraryDocument {
|
||||
category: string
|
||||
}
|
||||
|
||||
export interface TemplateEditorForm {
|
||||
id?: string
|
||||
templateCode: string
|
||||
name: string
|
||||
description: string
|
||||
campusId: string
|
||||
departmentId: string
|
||||
category: string
|
||||
versionNo: string
|
||||
contentHtml: string
|
||||
}
|
||||
|
||||
export interface DocumentFilterForm {
|
||||
keyword: string
|
||||
department: string | null
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||
import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing'
|
||||
import type { SigningTaskRecord, SigningTemplate, WorkbenchCampus } from '@/api/workbench/types'
|
||||
|
||||
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
|
||||
import ReportDetailTable from './components/ReportDetailTable.vue'
|
||||
@@ -22,12 +27,16 @@ import type {
|
||||
ReportFilterForm,
|
||||
ReportMetricCard as ReportMetricCardData,
|
||||
ReportPeriodOption,
|
||||
ReportSelectOption,
|
||||
ReportTask,
|
||||
ReportTaskStatus,
|
||||
ReportTrendPoint,
|
||||
} from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const tasks = ref<ReportTask[]>([])
|
||||
const departmentOptions = ref<ReportSelectOption[]>(reportDepartmentOptions)
|
||||
const router = useRouter()
|
||||
|
||||
const filters = reactive<ReportFilterForm>({
|
||||
period: '14d',
|
||||
@@ -145,21 +154,159 @@ function updateCategory(value: ReportDocumentCategory) {
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
ElMessage.info('原型演示:导出 Excel')
|
||||
const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
|
||||
const header = ['任务号', '文档', '科室', '院区', '方式', '状态', '发起时间']
|
||||
const rows = filteredTasks.value.map((task) => [
|
||||
task.id,
|
||||
task.documentName,
|
||||
task.department,
|
||||
task.campus,
|
||||
task.method === 'pad' ? '手写板' : '短信',
|
||||
getReportStatusLabel(task.status),
|
||||
formatExportDate(task.createdAt),
|
||||
])
|
||||
const csv = `\uFEFF${[header, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n')}`
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `医签通签署报表-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
ElMessage.success('报表已导出')
|
||||
}
|
||||
|
||||
function handleView(row: ReportTask) {
|
||||
ElMessage.info(`原型演示:查看任务${row.id},签署工作台暂未实现`)
|
||||
void router.push({ name: 'workbench-signing', query: { taskId: row.id } })
|
||||
}
|
||||
|
||||
function getReportStatusLabel(status: ReportTaskStatus) {
|
||||
const labels: Record<ReportTaskStatus, string> = {
|
||||
signed: '已签署',
|
||||
pending: '待签署',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
}
|
||||
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
function formatExportDate(value: Date) {
|
||||
const pad = (part: number) => String(part).padStart(2, '0')
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(
|
||||
value.getHours(),
|
||||
)}:${pad(value.getMinutes())}`
|
||||
}
|
||||
|
||||
function mapCategory(
|
||||
category: string,
|
||||
documentName: string,
|
||||
): Exclude<ReportDocumentCategory, 'all'> {
|
||||
const value = `${category} ${documentName}`
|
||||
|
||||
if (value.includes('入院') || value.includes('住院')) {
|
||||
return 'admission'
|
||||
}
|
||||
|
||||
if (value.includes('风险') || value.includes('告知')) {
|
||||
return 'risk'
|
||||
}
|
||||
|
||||
return 'consent'
|
||||
}
|
||||
|
||||
function mapReportTask(task: SigningTaskRecord, templates: SigningTemplate[]): ReportTask {
|
||||
const template = templates.find(
|
||||
(item) => item.versionId === task.templateVersionId || item.id === task.documentId,
|
||||
)
|
||||
const createdAt = new Date((task.createdAt ?? task.updatedAt).replace(' ', 'T'))
|
||||
const normalizedCreatedAt = Number.isNaN(createdAt.getTime()) ? new Date() : createdAt
|
||||
const status: ReportTask['status'] =
|
||||
task.status === 'signed'
|
||||
? 'signed'
|
||||
: task.status === 'expired'
|
||||
? 'expired'
|
||||
: task.status === 'void' || task.status === 'failed' || task.status === 'rejected'
|
||||
? 'void'
|
||||
: 'pending'
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
patientName: task.patientName,
|
||||
patientId: task.patientId,
|
||||
documentName: task.documentName,
|
||||
department: task.department,
|
||||
campus: task.campus as Exclude<ReportCampus, 'all'>,
|
||||
category: mapCategory(template?.category ?? '', task.documentName),
|
||||
method: task.method,
|
||||
status,
|
||||
createdAt: normalizedCreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||
return value === '本部院区' || value === '东院区' || value === '西院区'
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
|
||||
try {
|
||||
if (isSigningMockEnabled) {
|
||||
tasks.value = reportTasks.map((task) => ({
|
||||
...task,
|
||||
createdAt: new Date(task.createdAt),
|
||||
}))
|
||||
departmentOptions.value = reportDepartmentOptions
|
||||
return
|
||||
}
|
||||
|
||||
const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||
getSigningTemplates(),
|
||||
getCampuses(),
|
||||
getDepartments(),
|
||||
])
|
||||
const templates = templatesResult.status === 'fulfilled' ? templatesResult.value : []
|
||||
const campusNames: Record<string, WorkbenchCampus> =
|
||||
campusesResult.status === 'fulfilled'
|
||||
? (Object.fromEntries(
|
||||
campusesResult.value
|
||||
.filter((campus) => isWorkbenchCampus(campus.name))
|
||||
.map((campus) => [campus.id, campus.name]),
|
||||
) as Record<string, WorkbenchCampus>)
|
||||
: {}
|
||||
const taskResponse = await getSigningTasks(
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
status: 'all',
|
||||
dateRange: 'all',
|
||||
campus: 'all',
|
||||
method: 'all',
|
||||
},
|
||||
{ templates, campusNames },
|
||||
)
|
||||
tasks.value = taskResponse.records.map((task) => mapReportTask(task, templates))
|
||||
|
||||
const departmentNames = new Set<string>(
|
||||
departmentsResult.status === 'fulfilled'
|
||||
? departmentsResult.value.map((department) => department.name)
|
||||
: [],
|
||||
)
|
||||
tasks.value.forEach((task) => departmentNames.add(task.department))
|
||||
departmentOptions.value = [
|
||||
{ value: 'all', label: '全部科室' },
|
||||
...[...departmentNames]
|
||||
.filter((name) => name && name !== '未指定科室')
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.map((name) => ({ value: name, label: name })),
|
||||
]
|
||||
} catch {
|
||||
tasks.value = []
|
||||
ElMessage.error('报表数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadReports)
|
||||
@@ -174,7 +321,7 @@ onMounted(loadReports)
|
||||
:category="filters.category"
|
||||
:period-options="reportPeriodOptions"
|
||||
:campus-options="reportCampusOptions"
|
||||
:department-options="reportDepartmentOptions"
|
||||
:department-options="departmentOptions"
|
||||
:category-options="reportCategoryOptions"
|
||||
@update:period="updatePeriod"
|
||||
@update:campus="updateCampus"
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||
|
||||
import type { UserEditorForm } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
initial: UserEditorForm | null
|
||||
campuses: CampusRecord[]
|
||||
departments: DepartmentRecord[]
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [form: UserEditorForm]
|
||||
}>()
|
||||
|
||||
const form = reactive<UserEditorForm>(createEmptyForm())
|
||||
const error = reactive({ message: '' })
|
||||
|
||||
const isEditing = computed(() => Boolean(form.id))
|
||||
const availableDepartments = computed(() =>
|
||||
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
|
||||
)
|
||||
|
||||
function createEmptyForm(): UserEditorForm {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
employeeNo: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
campusId: props.campuses[0]?.id ?? '',
|
||||
departmentId: '',
|
||||
status: 'enabled',
|
||||
dataScope: 'DEPARTMENT',
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, props.initial ? { ...props.initial } : createEmptyForm())
|
||||
error.message = ''
|
||||
}
|
||||
|
||||
function handleCampusChange() {
|
||||
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
|
||||
form.departmentId = ''
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
error.message = ''
|
||||
|
||||
if (!form.displayName.trim()) {
|
||||
error.message = '请输入姓名'
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEditing.value && !form.username.trim()) {
|
||||
error.message = '请输入登录账号'
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEditing.value && !form.password) {
|
||||
error.message = '请输入初始密码'
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.campusId) {
|
||||
error.message = '请选择院区'
|
||||
return
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
...form,
|
||||
username: form.username.trim(),
|
||||
displayName: form.displayName.trim(),
|
||||
employeeNo: form.employeeNo.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.initial,
|
||||
() => {
|
||||
if (props.visible) {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
|
||||
<section
|
||||
class="dialog-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-editor-title"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
|
||||
<p>{{ isEditing ? '更新用户基础资料与数据范围' : '创建可登录医签通的医护用户' }}</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 v-if="!isEditing">
|
||||
<span>登录账号 <em>*</em></span>
|
||||
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
|
||||
</label>
|
||||
<label v-else>
|
||||
<span>登录账号</span>
|
||||
<input :value="form.username" disabled />
|
||||
</label>
|
||||
<label>
|
||||
<span>姓名 <em>*</em></span>
|
||||
<input v-model="form.displayName" autocomplete="name" placeholder="请输入姓名" />
|
||||
</label>
|
||||
<label v-if="!isEditing">
|
||||
<span>初始密码 <em>*</em></span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="请输入初始密码"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>工号</span>
|
||||
<input v-model="form.employeeNo" placeholder="请输入工号" />
|
||||
</label>
|
||||
<label>
|
||||
<span>手机号</span>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
inputmode="tel"
|
||||
autocomplete="tel"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>邮箱</span>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>院区 <em>*</em></span>
|
||||
<select v-model="form.campusId" @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">
|
||||
<option value="">未指定科室</option>
|
||||
<option
|
||||
v-for="department in availableDepartments"
|
||||
:key="department.id"
|
||||
:value="department.id"
|
||||
>
|
||||
{{ department.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model="form.status">
|
||||
<option value="enabled">启用</option>
|
||||
<option value="disabled">停用</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>数据范围</span>
|
||||
<select v-model="form.dataScope">
|
||||
<option value="ALL">全院</option>
|
||||
<option value="CAMPUS">院区</option>
|
||||
<option value="DEPARTMENT">本科室</option>
|
||||
<option value="READ_ONLY_ALL">全院只读</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||
<p class="form-tip">当前接口未提供用户角色绑定字段,角色分配暂保留在角色管理流程。</p>
|
||||
</form>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<span class="footer-spacer" />
|
||||
<button type="button" class="button button--ghost" @click="emit('close')">取消</button>
|
||||
<button type="button" class="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</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 {
|
||||
width: 680px;
|
||||
max-width: 96vw;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--sh-modal);
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
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 {
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
color: var(--mut);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-grid label > span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-grid em {
|
||||
color: var(--err);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.form-grid input:focus,
|
||||
.form-grid select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.form-grid input:disabled {
|
||||
color: var(--mut);
|
||||
background: #f5f8f9;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0;
|
||||
color: var(--err);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
padding: 9px 10px;
|
||||
margin: 14px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
background: #f7fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
background: #f8fbfc;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
background: var(--brand);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
color: var(--brand);
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,21 +2,64 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||
import { getRoles } from '@/api/management/roles'
|
||||
import { getUserDetail, getUsers, isUserMockEnabled } from '@/api/management/users'
|
||||
import { createUser, updateUser } from '@/api/management/users'
|
||||
import type {
|
||||
CampusRecord,
|
||||
DepartmentRecord,
|
||||
RoleRecord,
|
||||
UserRecord as ApiUserRecord,
|
||||
} from '@/api/management/types'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
||||
import UserTable from './components/UserTable.vue'
|
||||
import UserEditorDialog from './components/UserEditorDialog.vue'
|
||||
import { roleColors, roleSummaries, users } from './mock'
|
||||
import type { RoleSummary, UserTableRow } from './types'
|
||||
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const loading = ref(true)
|
||||
const keyword = ref('')
|
||||
const roleRows = ref<RoleSummary[]>([])
|
||||
const userRows = ref<UserTableRow[]>([])
|
||||
const roleColorMap = ref<Record<string, string>>(roleColors)
|
||||
const campuses = ref<CampusRecord[]>([])
|
||||
const departments = ref<DepartmentRecord[]>([])
|
||||
const editorVisible = ref(false)
|
||||
const editorInitial = ref<UserEditorForm | null>(null)
|
||||
const editorSaving = ref(false)
|
||||
|
||||
const mockCampuses: CampusRecord[] = [
|
||||
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||||
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
||||
{ id: 'mock-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
|
||||
]
|
||||
|
||||
const mockDepartments: DepartmentRecord[] = [
|
||||
'医务处',
|
||||
'放射科',
|
||||
'心内科',
|
||||
'骨科',
|
||||
'健康管理中心',
|
||||
'信息科',
|
||||
'纪检监察室',
|
||||
].map((name, index) => ({
|
||||
id: `mock-department-${index + 1}`,
|
||||
campusId: index === 3 || index === 4 ? 'mock-east' : 'mock-main',
|
||||
code: `MOCK-${index + 1}`,
|
||||
name,
|
||||
status: 'ENABLED',
|
||||
sortNo: index + 1,
|
||||
}))
|
||||
|
||||
const visibleUsers = computed(() => {
|
||||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||
|
||||
return userRows.value.filter((user) => {
|
||||
const matchesCampus = user.campus === '本部院区'
|
||||
const matchesCampus = user.campus === appStore.selectedCampus
|
||||
const matchesKeyword =
|
||||
!normalizedKeyword ||
|
||||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
|
||||
@@ -27,26 +70,233 @@ const visibleUsers = computed(() => {
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
try {
|
||||
if (isUserMockEnabled) {
|
||||
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
|
||||
departments.value = mockDepartments.map((department) => ({ ...department }))
|
||||
roleRows.value = roleSummaries.map((role) => ({ ...role }))
|
||||
userRows.value = users.map((user) => ({ ...user }))
|
||||
roleColorMap.value = roleColors
|
||||
return
|
||||
}
|
||||
|
||||
const [usersResult, rolesResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||
getUsers({ page: 1, pageSize: 200, status: 'all' }),
|
||||
getRoles(),
|
||||
getCampuses(),
|
||||
getDepartments(),
|
||||
])
|
||||
|
||||
if (usersResult.status === 'rejected') {
|
||||
throw usersResult.reason
|
||||
}
|
||||
|
||||
const campusNames = new Map(
|
||||
campusesResult.status === 'fulfilled'
|
||||
? campusesResult.value.map((campus) => [campus.id, campus.name])
|
||||
: [],
|
||||
)
|
||||
const departmentNames = new Map(
|
||||
departmentsResult.status === 'fulfilled'
|
||||
? departmentsResult.value.map((department) => [department.id, department.name])
|
||||
: [],
|
||||
)
|
||||
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
|
||||
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
|
||||
const apiUsers = usersResult.value.records
|
||||
|
||||
userRows.value = apiUsers.map((user) => mapUserRow(user, campusNames, departmentNames))
|
||||
|
||||
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
|
||||
roleRows.value = roles.map((role) => mapRoleSummary(role, apiUsers))
|
||||
roleColorMap.value = Object.fromEntries(
|
||||
roles.map((role) => [role.name, role.color ?? '#8a9aa3']),
|
||||
)
|
||||
} catch {
|
||||
roleRows.value = []
|
||||
userRows.value = []
|
||||
ElMessage.error('用户与角色加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function mapUserRow(
|
||||
user: ApiUserRecord,
|
||||
campusNames: Map<string, string>,
|
||||
departmentNames: Map<string, string>,
|
||||
): UserTableRow {
|
||||
const dataScopeLabels: Record<string, string> = {
|
||||
ALL: '全院',
|
||||
CAMPUS: '院区',
|
||||
DEPARTMENT: '本科室',
|
||||
READ_ONLY_ALL: '全院只读',
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
account: user.account ?? '',
|
||||
employeeNo: user.employeeNo ?? '—',
|
||||
phone: user.phone ?? '',
|
||||
email: user.email ?? '',
|
||||
campus: campusNames.get(user.campusId ?? '') ?? '未指定院区',
|
||||
campusId: user.campusId,
|
||||
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
|
||||
departmentId: user.departmentId,
|
||||
role: user.role,
|
||||
dataScope: dataScopeLabels[user.dataScope ?? ''] ?? user.dataScope ?? '—',
|
||||
status: user.status,
|
||||
}
|
||||
}
|
||||
|
||||
function mapRoleSummary(role: RoleRecord, users: ApiUserRecord[]): RoleSummary {
|
||||
const userCount =
|
||||
role.userCount ??
|
||||
users.filter((user) => user.roleNames?.some((roleName) => roleName === role.name)).length
|
||||
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color ?? '#8a9aa3',
|
||||
description: role.description,
|
||||
userCount,
|
||||
}
|
||||
}
|
||||
|
||||
function showAddUserMessage() {
|
||||
ElMessage.info('原型演示:新增用户')
|
||||
editorInitial.value = null
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function showImportMessage() {
|
||||
ElMessage.info('原型演示:批量导入')
|
||||
}
|
||||
|
||||
function editUser(user: UserTableRow) {
|
||||
ElMessage.info(`原型演示:编辑用户《${user.name}》`)
|
||||
async function editUser(row: UserTableRow) {
|
||||
try {
|
||||
const user = isUserMockEnabled
|
||||
? userRows.value.find((item) => item.id === row.id)
|
||||
: await getUserDetail(row.id)
|
||||
|
||||
if (!user) {
|
||||
ElMessage.error('用户不存在或已被删除')
|
||||
return
|
||||
}
|
||||
|
||||
editorInitial.value = {
|
||||
id: user.id,
|
||||
username: user.account ?? '',
|
||||
password: '',
|
||||
displayName: user.name,
|
||||
employeeNo: user.employeeNo ?? '',
|
||||
phone: user.phone ?? '',
|
||||
email: user.email ?? '',
|
||||
campusId:
|
||||
user.campusId ?? campuses.value.find((campus) => campus.name === row.campus)?.id ?? '',
|
||||
departmentId:
|
||||
user.departmentId ??
|
||||
departments.value.find((department) => department.name === row.department)?.id ??
|
||||
'',
|
||||
status: user.status,
|
||||
dataScope: toBackendDataScope(user.dataScope),
|
||||
}
|
||||
editorVisible.value = true
|
||||
} catch {
|
||||
ElMessage.error('用户详情加载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
function resetPassword(user: UserTableRow) {
|
||||
ElMessage.success(`已重置${user.name}的密码`)
|
||||
ElMessage.info(
|
||||
isUserMockEnabled
|
||||
? `原型演示:重置${user.name}的密码`
|
||||
: '当前接口未提供重置密码能力,请由认证系统或管理员流程处理',
|
||||
)
|
||||
}
|
||||
|
||||
function toBackendDataScope(value: string | undefined) {
|
||||
const map: Record<string, string> = {
|
||||
全院: 'ALL',
|
||||
院区: 'CAMPUS',
|
||||
本科室: 'DEPARTMENT',
|
||||
全院只读: 'READ_ONLY_ALL',
|
||||
}
|
||||
|
||||
return map[value ?? ''] ?? value ?? 'DEPARTMENT'
|
||||
}
|
||||
|
||||
function getCampusName(id: string) {
|
||||
return campuses.value.find((campus) => campus.id === id)?.name ?? '未指定院区'
|
||||
}
|
||||
|
||||
function getDepartmentName(id: string) {
|
||||
return departments.value.find((department) => department.id === id)?.name ?? '未指定科室'
|
||||
}
|
||||
|
||||
async function saveUser(form: UserEditorForm) {
|
||||
editorSaving.value = true
|
||||
|
||||
try {
|
||||
if (isUserMockEnabled) {
|
||||
const row: UserTableRow = {
|
||||
id: form.id ?? `mock-user-${Date.now()}`,
|
||||
name: form.displayName,
|
||||
account: form.username,
|
||||
employeeNo: form.employeeNo || '—',
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
campus: getCampusName(form.campusId),
|
||||
campusId: form.campusId,
|
||||
department: getDepartmentName(form.departmentId),
|
||||
departmentId: form.departmentId || null,
|
||||
role: form.id
|
||||
? (userRows.value.find((item) => item.id === form.id)?.role ?? '未分配角色')
|
||||
: '未分配角色',
|
||||
dataScope: form.dataScope,
|
||||
status: form.status,
|
||||
}
|
||||
const index = userRows.value.findIndex((item) => item.id === row.id)
|
||||
if (index === -1) {
|
||||
userRows.value.unshift(row)
|
||||
} else {
|
||||
userRows.value[index] = row
|
||||
}
|
||||
} else if (form.id) {
|
||||
await updateUser(form.id, {
|
||||
displayName: form.displayName,
|
||||
employeeNo: form.employeeNo || undefined,
|
||||
phone: form.phone || undefined,
|
||||
email: form.email || undefined,
|
||||
campusId: form.campusId,
|
||||
departmentId: form.departmentId || null,
|
||||
status: form.status.toUpperCase(),
|
||||
dataScope: form.dataScope,
|
||||
})
|
||||
await loadUsers()
|
||||
} else {
|
||||
await createUser({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
displayName: form.displayName,
|
||||
employeeNo: form.employeeNo || undefined,
|
||||
phone: form.phone || undefined,
|
||||
email: form.email || undefined,
|
||||
campusId: form.campusId,
|
||||
departmentId: form.departmentId || null,
|
||||
status: form.status.toUpperCase(),
|
||||
dataScope: form.dataScope,
|
||||
})
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
editorVisible.value = false
|
||||
ElMessage.success(form.id ? '用户已更新' : '用户已创建')
|
||||
} catch {
|
||||
ElMessage.error(form.id ? '用户更新失败,请稍后重试' : '用户创建失败,请稍后重试')
|
||||
} finally {
|
||||
editorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
@@ -77,12 +327,22 @@ onMounted(loadUsers)
|
||||
</div>
|
||||
<UserTable
|
||||
:rows="visibleUsers"
|
||||
:role-colors="roleColors"
|
||||
:role-colors="roleColorMap"
|
||||
@edit="editUser"
|
||||
@reset-password="resetPassword"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<UserEditorDialog
|
||||
:visible="editorVisible"
|
||||
:initial="editorInitial"
|
||||
:campuses="campuses"
|
||||
:departments="departments"
|
||||
:saving="editorSaving"
|
||||
@close="editorVisible = false"
|
||||
@save="saveUser"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,10 +11,29 @@ export interface RoleSummary {
|
||||
export interface UserTableRow {
|
||||
id: string
|
||||
name: string
|
||||
account?: string
|
||||
employeeNo: string
|
||||
phone?: string
|
||||
email?: string
|
||||
campus: string
|
||||
campusId?: string | null
|
||||
department: string
|
||||
departmentId?: string | null
|
||||
role: string
|
||||
dataScope: string
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
export interface UserEditorForm {
|
||||
id?: string
|
||||
username: string
|
||||
password: string
|
||||
displayName: string
|
||||
employeeNo: string
|
||||
phone: string
|
||||
email: string
|
||||
campusId: string
|
||||
departmentId: string
|
||||
status: UserStatus
|
||||
dataScope: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user