Compare commits
6 Commits
24d8a9ece1
...
4412a85adc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4412a85adc | ||
|
|
74df2ed741 | ||
|
|
ac8eff5286 | ||
|
|
332fb53cfa | ||
|
|
94a0d88f8b | ||
|
|
e7aa4843b7 |
@@ -22,7 +22,9 @@ import Menu from '@/components/layout/Menu.vue'
|
||||
.clinical-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -31,6 +33,8 @@ import Menu from '@/components/layout/Menu.vue'
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { DepartmentDocumentAccess, DepartmentPermissionRow } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: DepartmentPermissionRow[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
adjust: [row: DepartmentPermissionRow]
|
||||
}>()
|
||||
|
||||
const accessLabels: Record<DepartmentDocumentAccess, string> = {
|
||||
available: '可用',
|
||||
invisible: '不可见',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-scroll">
|
||||
<table class="permission-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>科室</th>
|
||||
<th>本科室文档</th>
|
||||
<th>全院通用文档</th>
|
||||
<th>其他科室文档</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>
|
||||
<strong>{{ row.department }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="access-tag" :class="'is-' + row.ownDocuments">
|
||||
{{ accessLabels[row.ownDocuments] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="access-tag" :class="'is-' + row.commonDocuments">
|
||||
{{ accessLabels[row.commonDocuments] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="access-tag" :class="'is-' + row.otherDocuments">
|
||||
{{ accessLabels[row.otherDocuments] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="adjust-button" @click="emit('adjust', row)">调整</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.permission-table {
|
||||
width: 100%;
|
||||
min-width: 640px;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.permission-table th {
|
||||
padding: 9px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
background: #f5f9fa;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.permission-table td {
|
||||
padding: 9px 12px;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
.permission-table tbody tr:hover td {
|
||||
background: #f7fbfc;
|
||||
}
|
||||
|
||||
.access-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.access-tag.is-available {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.access-tag.is-invisible {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.adjust-button {
|
||||
padding: 4px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.adjust-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import type { PermissionColumn, PermissionKey, RolePermissionRow } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: RolePermissionRow[]
|
||||
columns: PermissionColumn[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [roleId: string, permission: PermissionKey]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="permission-grid" role="table" aria-label="按角色授权矩阵">
|
||||
<div class="grid-cell grid-header role-header" role="columnheader">角色</div>
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="grid-cell grid-header"
|
||||
role="columnheader"
|
||||
:title="column.description"
|
||||
>
|
||||
{{ column.label }}
|
||||
</div>
|
||||
|
||||
<template v-for="row in rows" :key="row.id">
|
||||
<div class="grid-cell role-name" role="rowheader">
|
||||
<span class="role-color" :style="{ backgroundColor: row.color }" aria-hidden="true" />
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<div v-for="column in columns" :key="column.key" class="grid-cell permission-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="permission-toggle"
|
||||
:class="{ active: row.permissions[column.key] }"
|
||||
:aria-pressed="row.permissions[column.key]"
|
||||
:aria-label="`${row.name}:${column.label}${row.permissions[column.key] ? ',已开启' : ',已关闭'}`"
|
||||
:title="`${column.label}:${column.description}`"
|
||||
@click="emit('toggle', row.id, column.key)"
|
||||
>
|
||||
<span class="switch-track" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 140px repeat(3, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.grid-header {
|
||||
justify-content: center;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-header {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.role-name {
|
||||
gap: 7px;
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.permission-cell {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.permission-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.permission-toggle:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: #cbd8de;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-track::after {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: left 0.2s ease;
|
||||
}
|
||||
|
||||
.permission-toggle.active .switch-track {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.permission-toggle.active .switch-track::after {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.permission-grid {
|
||||
grid-template-columns: 120px repeat(3, minmax(76px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import DepartmentPermissionTable from './components/DepartmentPermissionTable.vue'
|
||||
import RolePermissionMatrix from './components/RolePermissionMatrix.vue'
|
||||
import { departmentPermissionRows, permissionColumns, rolePermissionRows } from './mock'
|
||||
import type { DepartmentPermissionRow, PermissionKey, RolePermissionRow } from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const roles = ref<RolePermissionRow[]>([])
|
||||
const departments = ref<DepartmentPermissionRow[]>([])
|
||||
|
||||
async function loadPermissions() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
roles.value = rolePermissionRows.map((role) => ({
|
||||
...role,
|
||||
permissions: { ...role.permissions },
|
||||
}))
|
||||
departments.value = departmentPermissionRows.map((row) => ({ ...row }))
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function toggleRolePermission(roleId: string, permission: PermissionKey) {
|
||||
const role = roles.value.find((item) => item.id === roleId)
|
||||
|
||||
if (!role) {
|
||||
return
|
||||
}
|
||||
|
||||
role.permissions[permission] = !role.permissions[permission]
|
||||
ElMessage.info('权限已调整并记录审计日志')
|
||||
}
|
||||
|
||||
function adjustDepartmentPermission(row: DepartmentPermissionRow) {
|
||||
ElMessage.info(`原型演示:调整${row.department}文档权限`)
|
||||
}
|
||||
|
||||
onMounted(loadPermissions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder
|
||||
group="管理"
|
||||
title="文档权限"
|
||||
icon="⊞"
|
||||
description="文档权限页面占位,待根据 Web 端需求实现。"
|
||||
/>
|
||||
<section class="permissions-page">
|
||||
<section class="page-card intro-card">
|
||||
<div class="card-heading">
|
||||
<h2>文档库权限矩阵</h2>
|
||||
<span>可见 / 可用 / 可维护 分级授权,下级继承上级</span>
|
||||
</div>
|
||||
<p>
|
||||
可见=可浏览模板;可用=可发起签署;可维护=可编辑/停用模板。科室签署员默认继承本科室「可用」权限。调整即时生效并记录审计日志。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<template v-if="loading">
|
||||
<section class="page-card loading-card" aria-label="正在加载权限配置">
|
||||
<span class="loading-title" />
|
||||
<span class="loading-row" />
|
||||
<span class="loading-row" />
|
||||
<span class="loading-row short" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="page-card">
|
||||
<div class="card-heading">
|
||||
<h2>按角色授权</h2>
|
||||
</div>
|
||||
<RolePermissionMatrix
|
||||
:rows="roles"
|
||||
:columns="permissionColumns"
|
||||
@toggle="toggleRolePermission"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="page-card department-card">
|
||||
<div class="card-heading">
|
||||
<h2>按科室授权</h2>
|
||||
<span>科室级别的文档可用范围</span>
|
||||
</div>
|
||||
<DepartmentPermissionTable :rows="departments" @adjust="adjustDepartmentPermission" />
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permissions-page {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-heading span {
|
||||
margin-left: auto;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.intro-card p {
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.department-card {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.loading-card span {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||
background-size: 400% 100%;
|
||||
border-radius: 6px;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
width: 28%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.loading-row {
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.loading-row.short {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-heading span {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
108
clinical-web/src/views/management/document-permissions/mock.ts
Normal file
108
clinical-web/src/views/management/document-permissions/mock.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { DepartmentPermissionRow, PermissionColumn, RolePermissionRow } from './types'
|
||||
|
||||
export const permissionColumns: PermissionColumn[] = [
|
||||
{
|
||||
key: 'visible',
|
||||
label: '可见',
|
||||
description: '可浏览模板',
|
||||
},
|
||||
{
|
||||
key: 'usable',
|
||||
label: '可用',
|
||||
description: '可发起签署',
|
||||
},
|
||||
{
|
||||
key: 'maintainable',
|
||||
label: '可维护',
|
||||
description: '可编辑、停用模板',
|
||||
},
|
||||
]
|
||||
|
||||
export const rolePermissionRows: RolePermissionRow[] = [
|
||||
{
|
||||
id: 'system-admin',
|
||||
name: '系统管理员',
|
||||
color: '#6b5bd2',
|
||||
description: '系统配置、用户与权限管理、审计日志',
|
||||
userCount: 2,
|
||||
permissions: {
|
||||
visible: true,
|
||||
usable: true,
|
||||
maintainable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'medical-management',
|
||||
name: '医务管理',
|
||||
color: '#0e6e8c',
|
||||
description: '全院文档库维护、报表分析、任务监督',
|
||||
userCount: 4,
|
||||
permissions: {
|
||||
visible: true,
|
||||
usable: true,
|
||||
maintainable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'department-signer',
|
||||
name: '科室签署员',
|
||||
color: '#0f9d6c',
|
||||
description: '本科室文档发起签署、手写板 / 短信操作',
|
||||
userCount: 23,
|
||||
permissions: {
|
||||
visible: false,
|
||||
usable: false,
|
||||
maintainable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'audit-viewer',
|
||||
name: '查询审计员',
|
||||
color: '#d9821f',
|
||||
description: '只读查询报表与审计留痕,不可发起',
|
||||
userCount: 3,
|
||||
permissions: {
|
||||
visible: false,
|
||||
usable: false,
|
||||
maintainable: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const departmentPermissionRows: DepartmentPermissionRow[] = [
|
||||
{
|
||||
id: 'radiology',
|
||||
department: '放射科',
|
||||
ownDocuments: 'available',
|
||||
commonDocuments: 'available',
|
||||
otherDocuments: 'invisible',
|
||||
},
|
||||
{
|
||||
id: 'cardiology',
|
||||
department: '心内科',
|
||||
ownDocuments: 'available',
|
||||
commonDocuments: 'available',
|
||||
otherDocuments: 'invisible',
|
||||
},
|
||||
{
|
||||
id: 'orthopedics',
|
||||
department: '骨科',
|
||||
ownDocuments: 'available',
|
||||
commonDocuments: 'available',
|
||||
otherDocuments: 'invisible',
|
||||
},
|
||||
{
|
||||
id: 'health-management',
|
||||
department: '健康管理中心',
|
||||
ownDocuments: 'available',
|
||||
commonDocuments: 'available',
|
||||
otherDocuments: 'invisible',
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
department: '全院通用',
|
||||
ownDocuments: 'available',
|
||||
commonDocuments: 'available',
|
||||
otherDocuments: 'invisible',
|
||||
},
|
||||
]
|
||||
@@ -1,15 +1,26 @@
|
||||
export type PermissionSubjectFilter = 'all' | 'user' | 'role'
|
||||
export type PermissionKey = 'visible' | 'usable' | 'maintainable'
|
||||
|
||||
export interface PermissionFilterForm {
|
||||
keyword: string
|
||||
subjectType: PermissionSubjectFilter
|
||||
export interface PermissionColumn {
|
||||
key: PermissionKey
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PermissionTableRow {
|
||||
export interface RolePermissionRow {
|
||||
id: string
|
||||
subjectType: PermissionSubjectFilter
|
||||
subjectName: string
|
||||
documentName: string
|
||||
permission: 'view' | 'sign' | 'manage'
|
||||
updatedAt: string
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
userCount: number
|
||||
permissions: Record<PermissionKey, boolean>
|
||||
}
|
||||
|
||||
export type DepartmentDocumentAccess = 'available' | 'invisible'
|
||||
|
||||
export interface DepartmentPermissionRow {
|
||||
id: string
|
||||
department: string
|
||||
ownDocuments: DepartmentDocumentAccess
|
||||
commonDocuments: DepartmentDocumentAccess
|
||||
otherDocuments: DepartmentDocumentAccess
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import type { DepartmentTab, DocumentStatus, StatusTab } from '../types'
|
||||
|
||||
defineProps<{
|
||||
department: string | null
|
||||
status: DocumentStatus
|
||||
departments: DepartmentTab[]
|
||||
statuses: StatusTab[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:department': [value: string | null]
|
||||
'update:status': [value: DocumentStatus]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-card">
|
||||
<div class="department-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 }"
|
||||
:aria-selected="department === tab.key"
|
||||
role="tab"
|
||||
@click="emit('update:department', tab.key)"
|
||||
>
|
||||
{{ tab.label }}({{ tab.count }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-tabs" role="tablist" aria-label="按状态筛选">
|
||||
<button
|
||||
v-for="tab in statuses"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="status-tab"
|
||||
:class="{ active: 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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.department-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.department-tab {
|
||||
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;
|
||||
}
|
||||
|
||||
.department-tab:hover {
|
||||
color: var(--brand);
|
||||
background: #eaf2f5;
|
||||
}
|
||||
|
||||
.department-tab.active {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
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;
|
||||
}
|
||||
|
||||
.status-tab:hover {
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.status-tab.active {
|
||||
color: var(--brand-d);
|
||||
font-weight: 700;
|
||||
background: var(--brand-l);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
total: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
add: []
|
||||
export: []
|
||||
}>()
|
||||
|
||||
function handleInput(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="library-header">
|
||||
<div class="library-title">
|
||||
<h2>文档库管理</h2>
|
||||
<p>
|
||||
按科室分类的知情同意书模板库,支持预览、编辑与版本管理。
|
||||
<span>共 {{ total }} 份模板</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="library-tools">
|
||||
<input
|
||||
:value="props.modelValue"
|
||||
class="library-search"
|
||||
type="search"
|
||||
placeholder="搜索模板名称 / 编号 / 科室"
|
||||
aria-label="搜索模板名称、编号或科室"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<button type="button" class="tool-button primary" @click="emit('add')">
|
||||
<span aria-hidden="true">+</span>
|
||||
新增文档
|
||||
</button>
|
||||
<button type="button" class="tool-button secondary" @click="emit('export')">
|
||||
导出模板包
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.library-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
padding: 6px 12px;
|
||||
color: var(--ink);
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.library-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgb(14 110 140 / 10%);
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
padding: 7px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border-radius: 7px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tool-button.primary {
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
.tool-button.primary:hover {
|
||||
background: var(--brand-d);
|
||||
border-color: var(--brand-d);
|
||||
}
|
||||
|
||||
.tool-button.secondary {
|
||||
color: var(--brand);
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
|
||||
.tool-button.secondary:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.library-tools {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.library-search {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { documentStatusLabels } from '../mock'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
template: DocumentTemplate | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
use: [template: DocumentTemplate]
|
||||
}>()
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!props.template) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return documentStatusLabels[props.template.status]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && template"
|
||||
class="dialog-mask"
|
||||
role="presentation"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<section
|
||||
class="preview-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="document-preview-title"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div class="dialog-title">
|
||||
<h2 id="document-preview-title">{{ template.name }}</h2>
|
||||
<span class="word-badge">类 Word 版式</span>
|
||||
<span class="status-badge">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<button type="button" class="close-button" aria-label="关闭预览" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="dialog-body">
|
||||
<article class="preview-paper">
|
||||
<h3>{{ template.name }}</h3>
|
||||
<p class="paper-number">模板编号:{{ template.code }} · 版本 {{ template.version }}</p>
|
||||
<p>
|
||||
患者:<strong>{患者姓名}</strong> · 性别:<strong>{性别}</strong> · 年龄:<strong
|
||||
>{年龄}</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
门诊/住院号:<strong>{就诊号}</strong> · 科室:<strong>{{
|
||||
template.department
|
||||
}}</strong>
|
||||
· 日期:<strong>{签署日期}</strong>
|
||||
</p>
|
||||
<p>
|
||||
医务人员已就本项检查、治疗的适应证、禁忌证、医疗风险、替代方案及可能出现的不良后果向本人(或授权委托人)充分说明,本人已完全理解上述内容,自愿接受并签署确认。
|
||||
</p>
|
||||
<p>
|
||||
本文档采用电子方式签署,患者手写签名图片将合成至下方签名域,文档原件与签名原图双留存。
|
||||
</p>
|
||||
|
||||
<div class="signature-area">
|
||||
<div>
|
||||
<span>患者 / 授权委托人签名:</span>
|
||||
<div class="signature-line">签名域(自动合成)</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>日期:</span>
|
||||
<div class="signature-line date-line">自动填充</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<p>占位符由系统在签署时自动填充,当前仅用于原型演示。</p>
|
||||
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
|
||||
<button type="button" class="footer-button primary" @click="emit('use', template)">
|
||||
用此模板发起签署
|
||||
</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%);
|
||||
}
|
||||
|
||||
.preview-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 720px;
|
||||
max-width: 96vw;
|
||||
max-height: calc(100vh - 32px);
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--sh-modal);
|
||||
animation: dialog-pop 0.18s ease;
|
||||
}
|
||||
|
||||
@keyframes dialog-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-title h2 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.word-badge,
|
||||
.status-badge {
|
||||
padding: 3px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.word-badge {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.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: 12px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-paper {
|
||||
min-height: 420px;
|
||||
padding: 26px 34px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
background: #fff;
|
||||
border: 1px solid #eef4f6;
|
||||
box-shadow: 0 1px 5px rgb(13 62 81 / 5%);
|
||||
}
|
||||
|
||||
.preview-paper h3 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--ink);
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.preview-paper p {
|
||||
margin: 0 0 7px;
|
||||
text-indent: 2em;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.preview-paper .paper-number {
|
||||
margin-bottom: 18px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
.signature-area {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
align-items: flex-end;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.signature-area > div > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.signature-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 200px;
|
||||
min-height: 64px;
|
||||
color: var(--mut);
|
||||
border-bottom: 2px dashed var(--line);
|
||||
}
|
||||
|
||||
.signature-line.date-line {
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 18px;
|
||||
background: #f8fbfc;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dialog-footer p {
|
||||
max-width: 46%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer-button {
|
||||
padding: 7px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.footer-button.secondary {
|
||||
margin-left: auto;
|
||||
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 {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.preview-paper {
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialog-footer p {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.footer-button.secondary {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { documentStatusLabels } from '../mock'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
template: DocumentTemplate
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: [template: DocumentTemplate]
|
||||
edit: [template: DocumentTemplate]
|
||||
initiate: [template: DocumentTemplate]
|
||||
}>()
|
||||
|
||||
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="template-card">
|
||||
<div class="template-head">
|
||||
<h3 :title="template.name">{{ template.name }}</h3>
|
||||
<span class="template-status" :class="'status-' + template.status">
|
||||
<i aria-hidden="true" />
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="template-meta">
|
||||
{{ template.code }} · {{ template.version }} · {{ template.department }}
|
||||
</p>
|
||||
<p class="template-description">{{ template.description }}</p>
|
||||
|
||||
<div class="template-footer">
|
||||
<span class="department-tag">
|
||||
<i aria-hidden="true" />
|
||||
{{ template.department }}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
background: var(--card);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--sh-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.template-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.template-head h3 {
|
||||
display: -webkit-box;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.template-status {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 3px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.template-status i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.status-archived {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.status-review {
|
||||
color: var(--warn);
|
||||
background: var(--warn-l);
|
||||
}
|
||||
|
||||
.template-meta {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
min-height: 58px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.template-footer {
|
||||
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-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
padding: 4px 9px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.card-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.card-button.primary {
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.card-button.primary:hover {
|
||||
background: var(--brand-d);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.template-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
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 { documentLibrary, documentStatusOptions } from './mock'
|
||||
import type {
|
||||
DepartmentTab,
|
||||
DocumentDepartment,
|
||||
DocumentFilterForm,
|
||||
DocumentTemplate,
|
||||
StatusTab,
|
||||
} from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const library = ref<DocumentDepartment[]>([])
|
||||
const selectedTemplate = ref<DocumentTemplate | null>(null)
|
||||
|
||||
const filters = reactive<DocumentFilterForm>({
|
||||
keyword: '',
|
||||
department: null,
|
||||
status: 'enabled',
|
||||
})
|
||||
|
||||
const allTemplates = computed<DocumentTemplate[]>(() =>
|
||||
library.value.flatMap((department) =>
|
||||
department.categories.flatMap((category) =>
|
||||
category.documents.map((template) => ({
|
||||
...template,
|
||||
departmentKey: department.key,
|
||||
department: department.name,
|
||||
category: category.name,
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const departmentTabs = computed<DepartmentTab[]>(() => [
|
||||
{ key: null, label: '全部', count: allTemplates.value.length },
|
||||
...library.value.map((department) => ({
|
||||
key: department.key,
|
||||
label: department.name,
|
||||
count: allTemplates.value.filter((template) => template.departmentKey === department.key)
|
||||
.length,
|
||||
})),
|
||||
])
|
||||
|
||||
const statusTabs = computed<StatusTab[]>(() =>
|
||||
documentStatusOptions.map((option) => ({
|
||||
...option,
|
||||
count: allTemplates.value.filter((template) => template.status === option.key).length,
|
||||
})),
|
||||
)
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
|
||||
return allTemplates.value.filter((template) => {
|
||||
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
||||
const matchesStatus = template.status === filters.status
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
||||
|
||||
return matchesDepartment && matchesStatus && matchesKeyword
|
||||
})
|
||||
})
|
||||
|
||||
async function loadLibrary() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
library.value = documentLibrary
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function handlePreview(template: DocumentTemplate) {
|
||||
selectedTemplate.value = template
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
selectedTemplate.value = null
|
||||
}
|
||||
|
||||
function showAddMessage() {
|
||||
ElMessage.info('原型演示:新增文档模板(类 Word 在线编辑器)')
|
||||
}
|
||||
|
||||
function showExportMessage() {
|
||||
ElMessage.info('原型演示:导出模板包(ZIP)')
|
||||
}
|
||||
|
||||
function showEditMessage(template: DocumentTemplate) {
|
||||
ElMessage.info('原型演示:编辑模板《' + template.name + '》')
|
||||
}
|
||||
|
||||
function showInitiateMessage(template: DocumentTemplate) {
|
||||
ElMessage.info('已选择《' + template.name + '》,签署工作台暂未实现')
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.keyword = ''
|
||||
filters.department = null
|
||||
}
|
||||
|
||||
function useTemplate(template: DocumentTemplate) {
|
||||
closePreview()
|
||||
ElMessage.info('原型演示:从模板《' + template.name + '》发起签署')
|
||||
}
|
||||
|
||||
onMounted(loadLibrary)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder
|
||||
group="管理"
|
||||
title="文档库管理"
|
||||
icon="▤"
|
||||
description="文档库管理页面占位,待根据 Web 端需求实现。"
|
||||
/>
|
||||
<section class="documents-page">
|
||||
<DocumentLibraryHeader
|
||||
v-model="filters.keyword"
|
||||
:total="filteredTemplates.length"
|
||||
@add="showAddMessage"
|
||||
@export="showExportMessage"
|
||||
/>
|
||||
|
||||
<DocumentFilterTabs
|
||||
v-model:department="filters.department"
|
||||
v-model:status="filters.status"
|
||||
:departments="departmentTabs"
|
||||
:statuses="statusTabs"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="template-grid" aria-label="正在加载文档模板">
|
||||
<div v-for="index in 8" :key="index" class="template-skeleton">
|
||||
<span class="skeleton-title" />
|
||||
<span class="skeleton-line wide" />
|
||||
<span class="skeleton-line" />
|
||||
<span class="skeleton-line short" />
|
||||
<span class="skeleton-footer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTemplates.length" class="template-grid">
|
||||
<DocumentTemplateCard
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:template="template"
|
||||
@preview="handlePreview"
|
||||
@edit="showEditMessage"
|
||||
@initiate="showInitiateMessage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon" aria-hidden="true">⌕</div>
|
||||
<p>没有符合条件的模板</p>
|
||||
<button
|
||||
v-if="filters.keyword || filters.department"
|
||||
type="button"
|
||||
class="reset-button"
|
||||
@click="clearFilters"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DocumentPreviewDialog
|
||||
:visible="Boolean(selectedTemplate)"
|
||||
:template="selectedTemplate"
|
||||
@close="closePreview"
|
||||
@use="useTemplate"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.documents-page {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.template-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 206px;
|
||||
padding: 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.template-skeleton span {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||
background-size: 400% 100%;
|
||||
border-radius: 6px;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 70%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
width: 90%;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.skeleton-line.wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
.skeleton-footer {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 56px 24px;
|
||||
color: var(--mut);
|
||||
text-align: center;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 8px;
|
||||
color: var(--brand);
|
||||
font-size: 38px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
padding: 6px 12px;
|
||||
margin-top: 14px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.template-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.template-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.template-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
240
clinical-web/src/views/management/documents/mock.ts
Normal file
240
clinical-web/src/views/management/documents/mock.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { DocumentDepartment, DocumentStatus, LibraryDocument } from './types'
|
||||
|
||||
const documentStatusLabels: Record<DocumentStatus, string> = {
|
||||
enabled: '启用中',
|
||||
draft: '草稿',
|
||||
archived: '归档',
|
||||
review: '需复审',
|
||||
}
|
||||
|
||||
export const documentStatusOptions = Object.entries(documentStatusLabels).map(([key, label]) => ({
|
||||
key: key as DocumentStatus,
|
||||
label,
|
||||
}))
|
||||
|
||||
export { documentStatusLabels }
|
||||
|
||||
function createDocument(
|
||||
id: string,
|
||||
name: string,
|
||||
code: string,
|
||||
version: string,
|
||||
description: string,
|
||||
status: DocumentStatus = 'enabled',
|
||||
): LibraryDocument {
|
||||
return { id, name, code, version, description, status }
|
||||
}
|
||||
|
||||
export const documentLibrary: DocumentDepartment[] = [
|
||||
{
|
||||
key: 'radiology',
|
||||
name: '放射科',
|
||||
campus: 'all',
|
||||
categories: [
|
||||
{
|
||||
name: '核磁相关',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-rad-mri-001',
|
||||
'MRI磁共振检查知情同意书',
|
||||
'TPL-RAD-MRI-001',
|
||||
'v2.3.1',
|
||||
'适用于 MRI 检查前风险告知,含幽闭恐惧症、金属植入物、造影剂过敏等注意事项。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-rad-mri-002',
|
||||
'磁共振安全风险告知书',
|
||||
'TPL-RAD-MRI-002',
|
||||
'v1.8.0',
|
||||
'磁共振室安全筛查及禁忌证告知,适用于所有进入磁体间人员。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-rad-mri-003',
|
||||
'造影剂注射知情同意书',
|
||||
'TPL-RAD-CM-001',
|
||||
'v2.1.0',
|
||||
'含碘、钆造影剂使用风险、过敏史询问及肾功能评估要求。',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'CT相关',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-rad-ct-001',
|
||||
'CT增强检查知情同意书',
|
||||
'TPL-RAD-CT-001',
|
||||
'v2.0.1',
|
||||
'CT 增强扫描造影剂风险、注意事项及不良反应处理告知。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-rad-ct-002',
|
||||
'CT检查辐射告知书',
|
||||
'TPL-RAD-CT-002',
|
||||
'v1.5.0',
|
||||
'CT 检查电离辐射风险说明,覆盖孕妇、儿童等特殊人群注意事项。',
|
||||
'review',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '普放相关',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-rad-xr-001',
|
||||
'X线检查告知书',
|
||||
'TPL-RAD-XR-001',
|
||||
'v1.2.0',
|
||||
'普通 X 线检查注意事项及辐射防护告知。',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cardiology',
|
||||
name: '心内科',
|
||||
campus: 'all',
|
||||
categories: [
|
||||
{
|
||||
name: '介入手术',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-cvd-iv-001',
|
||||
'心脏介入手术知情同意书',
|
||||
'TPL-CVD-IV-001',
|
||||
'v2.4.0',
|
||||
'冠状动脉造影、PCI 等介入操作风险、并发症及替代方案告知。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-cvd-cag-001',
|
||||
'冠状动脉造影知情同意书',
|
||||
'TPL-CVD-CAG-001',
|
||||
'v2.2.1',
|
||||
'冠脉造影检查适应证、禁忌证、术中术后风险及造影剂相关风险。',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '常规诊疗',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-cvd-pc-001',
|
||||
'心包穿刺知情同意书',
|
||||
'TPL-CVD-PC-001',
|
||||
'v1.9.0',
|
||||
'心包穿刺操作风险、可能并发症及配合事项告知。',
|
||||
'draft',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'orthopedics',
|
||||
name: '骨科',
|
||||
campus: '本部院区',
|
||||
categories: [
|
||||
{
|
||||
name: '手术相关',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-ort-op-001',
|
||||
'骨科手术知情同意书',
|
||||
'TPL-ORT-OP-001',
|
||||
'v2.1.0',
|
||||
'骨科手术麻醉方式、术中术后风险、内固定材料及术后康复说明。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-ort-ane-001',
|
||||
'麻醉知情同意书',
|
||||
'TPL-ORT-ANE-001',
|
||||
'v1.7.0',
|
||||
'麻醉方式选择、麻醉风险、术后镇痛及可能出现的不良反应。',
|
||||
'draft',
|
||||
),
|
||||
createDocument(
|
||||
'doc-ort-bt-001',
|
||||
'输血治疗同意书',
|
||||
'TPL-ORT-BT-001',
|
||||
'v1.4.0',
|
||||
'输血适应证、输血反应及经血传播疾病风险告知。',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'health-management',
|
||||
name: '健康管理中心',
|
||||
campus: '东院区',
|
||||
categories: [
|
||||
{
|
||||
name: '体检告知',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-pe-rpt-001',
|
||||
'体检报告发放及知情同意书',
|
||||
'TPL-PE-RPT-001',
|
||||
'v1.6.0',
|
||||
'体检报告领取方式、隐私保护、异常结果随访告知。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-pe-pre-001',
|
||||
'检前告知书',
|
||||
'TPL-PE-PRE-001',
|
||||
'v1.3.0',
|
||||
'体检前准备事项、空腹要求及特殊检查注意事项。',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
name: '全院通用',
|
||||
campus: 'all',
|
||||
categories: [
|
||||
{
|
||||
name: '入院告知',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-gen-adm-001',
|
||||
'住院须知及告知书',
|
||||
'TPL-GEN-ADM-001',
|
||||
'v2.0.0',
|
||||
'住院期间权利义务、探视制度、费用结算及安全注意事项。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-gen-crit-001',
|
||||
'病危通知书签收单',
|
||||
'TPL-GEN-CRIT-001',
|
||||
'v1.5.0',
|
||||
'病危、病重病情告知、家属签收及医患沟通记录。',
|
||||
'review',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '授权委托',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-gen-pow-001',
|
||||
'患者授权委托书',
|
||||
'TPL-GEN-POW-001',
|
||||
'v2.1.0',
|
||||
'患者授权委托代理人办理医疗相关事务及签署文件的授权书。',
|
||||
'archived',
|
||||
),
|
||||
createDocument(
|
||||
'doc-gen-ref-001',
|
||||
'拒绝检查治疗告知书',
|
||||
'TPL-GEN-REF-001',
|
||||
'v1.8.0',
|
||||
'患者或代理人拒绝推荐检查、治疗时的风险告知及签字确认。',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,16 +1,50 @@
|
||||
export type DocumentFilterStatus = 'all' | 'draft' | 'published' | 'archived'
|
||||
export type DocumentStatus = 'enabled' | 'draft' | 'archived' | 'review'
|
||||
|
||||
export type DocumentFilterStatus = 'all' | DocumentStatus
|
||||
|
||||
export type DocumentCampus = 'all' | '本部院区' | '东院区' | '西院区'
|
||||
|
||||
export interface LibraryDocument {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
version: string
|
||||
status: DocumentStatus
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface DocumentCategory {
|
||||
name: string
|
||||
documents: LibraryDocument[]
|
||||
}
|
||||
|
||||
export interface DocumentDepartment {
|
||||
key: string
|
||||
name: string
|
||||
campus: DocumentCampus
|
||||
categories: DocumentCategory[]
|
||||
}
|
||||
|
||||
export interface DocumentTemplate extends LibraryDocument {
|
||||
departmentKey: string
|
||||
department: string
|
||||
category: string
|
||||
}
|
||||
|
||||
export interface DocumentFilterForm {
|
||||
keyword: string
|
||||
status: DocumentFilterStatus
|
||||
department: string | null
|
||||
status: DocumentStatus
|
||||
}
|
||||
|
||||
export interface DocumentTableRow {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
version: string
|
||||
status: DocumentFilterStatus
|
||||
updatedBy: string
|
||||
updatedAt: string
|
||||
export interface DepartmentTab {
|
||||
key: string | null
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface StatusTab {
|
||||
key: DocumentStatus
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ReportDepartmentPoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: ReportDepartmentPoint[]
|
||||
}>()
|
||||
|
||||
const maxValue = computed(() => Math.max(...props.points.map((point) => point.value), 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="points.length" class="distribution-list" aria-label="科室签署分布">
|
||||
<div v-for="point in points" :key="point.department" class="distribution-row">
|
||||
<span class="department-name" :title="point.department">{{ point.department }}</span>
|
||||
<div class="distribution-track" aria-hidden="true">
|
||||
<span
|
||||
class="distribution-value"
|
||||
:style="{
|
||||
width: `${(point.value / maxValue) * 100}%`,
|
||||
backgroundColor: point.color,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<strong>{{ point.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-chart">暂无科室数据</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.distribution-list {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.distribution-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.department-name {
|
||||
flex: 0 0 86px;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.distribution-track {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
background: #eef4f6;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.distribution-value {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.distribution-row strong {
|
||||
flex: 0 0 30px;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-chart {
|
||||
display: grid;
|
||||
min-height: 150px;
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReportTask, ReportTaskStatus } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: ReportTask[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
view: [row: ReportTask]
|
||||
}>()
|
||||
|
||||
const statusLabels: Record<ReportTaskStatus, string> = {
|
||||
signed: '已签署',
|
||||
pending: '待签署',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
}
|
||||
|
||||
function getStatusLabel(row: ReportTask) {
|
||||
if (row.status === 'pending') {
|
||||
return `${statusLabels[row.status]}·${row.method === 'pad' ? '手写板' : '短信'}`
|
||||
}
|
||||
|
||||
return statusLabels[row.status]
|
||||
}
|
||||
|
||||
function getStatusClass(status: ReportTaskStatus) {
|
||||
return `status-${status}`
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-scroll">
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务号</th>
|
||||
<th>患者</th>
|
||||
<th>文档</th>
|
||||
<th>科室</th>
|
||||
<th>方式</th>
|
||||
<th>状态</th>
|
||||
<th>发起时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td>
|
||||
<strong>{{ row.patientName }}</strong>
|
||||
<span class="patient-id">{{ row.patientId }}</span>
|
||||
</td>
|
||||
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="getStatusClass(row.status)">
|
||||
{{ getStatusLabel(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(row.createdAt) }}</td>
|
||||
<td>
|
||||
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="8" class="empty-cell">暂无符合条件的签署明细</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table th {
|
||||
padding: 9px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
background: #f5f9fa;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
padding: 9px 12px;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
.detail-table tbody tr:hover td {
|
||||
background: #f7fbfc;
|
||||
}
|
||||
|
||||
.patient-id {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--mut);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.document-name {
|
||||
max-width: 230px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-signed {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
}
|
||||
|
||||
.status-void {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.view-button {
|
||||
padding: 4px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.view-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
padding: 40px 12px !important;
|
||||
color: var(--mut);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDocumentCategory,
|
||||
ReportPeriodOption,
|
||||
ReportSelectOption,
|
||||
} from '../types'
|
||||
|
||||
defineProps<{
|
||||
period: ReportPeriodOption
|
||||
campus: ReportCampus
|
||||
department: string
|
||||
category: ReportDocumentCategory
|
||||
periodOptions: ReportSelectOption<ReportPeriodOption>[]
|
||||
campusOptions: ReportSelectOption<ReportCampus>[]
|
||||
departmentOptions: ReportSelectOption[]
|
||||
categoryOptions: ReportSelectOption<ReportDocumentCategory>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:period': [value: ReportPeriodOption]
|
||||
'update:campus': [value: ReportCampus]
|
||||
'update:department': [value: string]
|
||||
'update:category': [value: ReportDocumentCategory]
|
||||
export: []
|
||||
}>()
|
||||
|
||||
function readSelectValue(event: Event) {
|
||||
return (event.target as HTMLSelectElement).value
|
||||
}
|
||||
|
||||
function handlePeriodChange(event: Event) {
|
||||
emit('update:period', readSelectValue(event) as ReportPeriodOption)
|
||||
}
|
||||
|
||||
function handleCampusChange(event: Event) {
|
||||
emit('update:campus', readSelectValue(event) as ReportCampus)
|
||||
}
|
||||
|
||||
function handleDepartmentChange(event: Event) {
|
||||
emit('update:department', readSelectValue(event))
|
||||
}
|
||||
|
||||
function handleCategoryChange(event: Event) {
|
||||
emit('update:category', readSelectValue(event) as ReportDocumentCategory)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="filter-card">
|
||||
<div class="filter-bar">
|
||||
<label class="filter-field">
|
||||
<span>时间</span>
|
||||
<select :value="period" aria-label="选择报表时间" @change="handlePeriodChange">
|
||||
<option v-for="option in periodOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>院区</span>
|
||||
<select :value="campus" aria-label="选择报表院区" @change="handleCampusChange">
|
||||
<option v-for="option in campusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>科室</span>
|
||||
<select :value="department" aria-label="选择报表科室" @change="handleDepartmentChange">
|
||||
<option v-for="option in departmentOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>文档类别</span>
|
||||
<select :value="category" aria-label="选择文档类别" @change="handleCategoryChange">
|
||||
<option v-for="option in categoryOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span class="filter-spacer" />
|
||||
<button type="button" class="export-button" @click="emit('export')">导出</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.filter-field select {
|
||||
width: auto;
|
||||
min-width: 98px;
|
||||
padding: 7px 10px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.filter-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
padding: 8px 16px;
|
||||
color: var(--brand);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReportMetricCard as ReportMetricCardData } from '../types'
|
||||
|
||||
defineProps<{
|
||||
metric: ReportMetricCardData
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="metric-card">
|
||||
<div class="metric-icon" :class="'tone-' + metric.tone" aria-hidden="true">
|
||||
<svg v-if="metric.key === 'total'" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m-6 9 2 2 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="metric.key === 'signed'"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M20 6 9 17l-5-5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else-if="metric.key === 'rate'" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M12 7v5l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
|
||||
<path
|
||||
d="M12 8v5M12 16h.01"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="metric-value">
|
||||
{{ metric.value }}<span v-if="metric.unit" class="metric-unit">{{ metric.unit }}</span>
|
||||
</div>
|
||||
<div class="metric-label">{{ metric.label }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.metric-icon.tone-brand {
|
||||
color: var(--brand);
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.metric-icon.tone-ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.metric-icon.tone-info {
|
||||
color: var(--info);
|
||||
background: var(--info-l);
|
||||
}
|
||||
|
||||
.metric-icon.tone-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-l);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--ink);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
margin-left: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
margin-top: 3px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ReportTrendPoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: ReportTrendPoint[]
|
||||
}>()
|
||||
|
||||
const chartBars = computed(() => {
|
||||
const width = 640
|
||||
const height = 200
|
||||
const paddingX = 30
|
||||
const paddingY = 30
|
||||
const slotWidth = (width - paddingX * 2) / Math.max(props.points.length, 1)
|
||||
const barWidth = slotWidth * 0.55
|
||||
const maxValue = Math.max(...props.points.map((point) => point.value), 3) * 1.2
|
||||
|
||||
return props.points.map((point, index) => {
|
||||
const barHeight = (point.value / maxValue) * (height - paddingY * 2)
|
||||
const x = paddingX + slotWidth * index + (slotWidth - barWidth) / 2
|
||||
const y = height - paddingY - barHeight
|
||||
|
||||
return {
|
||||
...point,
|
||||
x,
|
||||
y,
|
||||
width: barWidth,
|
||||
height: Math.max(barHeight, 2),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const gridLines = [0, 1, 2, 3].map((index) => 30 + ((200 - 60) / 3) * index)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="points.length" class="chart-wrap">
|
||||
<svg viewBox="0 0 640 200" role="img" aria-label="每日签署量趋势柱状图">
|
||||
<defs>
|
||||
<linearGradient id="report-bar-gradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#18a0c0" />
|
||||
<stop offset="1" stop-color="#0e6e8c" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<line
|
||||
v-for="line in gridLines"
|
||||
:key="line"
|
||||
x1="30"
|
||||
:y1="line"
|
||||
x2="610"
|
||||
:y2="line"
|
||||
stroke="#e6eef2"
|
||||
stroke-dasharray="3 4"
|
||||
/>
|
||||
|
||||
<g v-for="bar in chartBars" :key="bar.date">
|
||||
<rect
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.width"
|
||||
:height="bar.height"
|
||||
rx="4"
|
||||
:fill="bar.value ? 'url(#report-bar-gradient)' : '#e2ebef'"
|
||||
>
|
||||
<title>{{ bar.label }}:{{ bar.value }} 单</title>
|
||||
</rect>
|
||||
<text
|
||||
v-if="bar.value"
|
||||
:x="bar.x + bar.width / 2"
|
||||
:y="bar.y - 6"
|
||||
text-anchor="middle"
|
||||
font-size="10.5"
|
||||
fill="#0a4f66"
|
||||
font-weight="bold"
|
||||
>
|
||||
{{ bar.value }}
|
||||
</text>
|
||||
<text
|
||||
:x="bar.x + bar.width / 2"
|
||||
y="190"
|
||||
text-anchor="middle"
|
||||
font-size="9.5"
|
||||
fill="#687f8b"
|
||||
>
|
||||
{{ bar.label }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="empty-chart">暂无趋势数据</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-wrap {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-wrap svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.empty-chart {
|
||||
display: grid;
|
||||
min-height: 200px;
|
||||
color: var(--mut);
|
||||
font-size: 13px;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
|
||||
import ReportDetailTable from './components/ReportDetailTable.vue'
|
||||
import ReportFilterBar from './components/ReportFilterBar.vue'
|
||||
import ReportMetricCard from './components/ReportMetricCard.vue'
|
||||
import SignatureTrendChart from './components/SignatureTrendChart.vue'
|
||||
import {
|
||||
reportCampusOptions,
|
||||
reportCategoryOptions,
|
||||
reportDepartmentColors,
|
||||
reportDepartmentOptions,
|
||||
reportPeriodOptions,
|
||||
reportTasks,
|
||||
} from './mock'
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDepartmentPoint,
|
||||
ReportDocumentCategory,
|
||||
ReportFilterForm,
|
||||
ReportMetricCard as ReportMetricCardData,
|
||||
ReportPeriodOption,
|
||||
ReportTask,
|
||||
ReportTrendPoint,
|
||||
} from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const tasks = ref<ReportTask[]>([])
|
||||
|
||||
const filters = reactive<ReportFilterForm>({
|
||||
period: '14d',
|
||||
campus: 'all',
|
||||
department: 'all',
|
||||
category: 'all',
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const startDate = getStartDate(filters.period)
|
||||
|
||||
return tasks.value.filter((task) => {
|
||||
const matchesDate = !startDate || task.createdAt >= startDate
|
||||
const matchesCampus = filters.campus === 'all' || task.campus === filters.campus
|
||||
const matchesDepartment = filters.department === 'all' || task.department === filters.department
|
||||
const matchesCategory = filters.category === 'all' || task.category === filters.category
|
||||
|
||||
return matchesDate && matchesCampus && matchesDepartment && matchesCategory
|
||||
})
|
||||
})
|
||||
|
||||
const metrics = computed<ReportMetricCardData[]>(() => {
|
||||
const list = filteredTasks.value
|
||||
const signedTasks = list.filter((task) => task.status === 'signed')
|
||||
const completionRate = list.length ? Math.round((signedTasks.length / list.length) * 100) : 0
|
||||
const averageMinutes = signedTasks.length
|
||||
? Math.round(
|
||||
signedTasks.reduce((total, task) => total + (task.durationMinutes ?? 7), 0) /
|
||||
signedTasks.length,
|
||||
)
|
||||
: 0
|
||||
|
||||
return [
|
||||
{ key: 'total', label: '累计发起任务', value: list.length, tone: 'brand' },
|
||||
{ key: 'signed', label: '签署完成', value: signedTasks.length, tone: 'ok' },
|
||||
{ key: 'rate', label: '按时签署率', value: `${completionRate}%`, tone: 'info' },
|
||||
{ key: 'average', label: '平均签署用时(分钟)', value: averageMinutes, tone: 'warn' },
|
||||
]
|
||||
})
|
||||
|
||||
const trendPoints = computed<ReportTrendPoint[]>(() => {
|
||||
const days = filters.period === '7d' ? 7 : 14
|
||||
const today = startOfDay(new Date())
|
||||
const signedTasks = filteredTasks.value.filter((task) => task.status === 'signed')
|
||||
|
||||
return Array.from({ length: days }, (_, index) => {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - (days - index - 1))
|
||||
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
label: `${date.getMonth() + 1}/${date.getDate()}`,
|
||||
value: signedTasks.filter((task) => isSameDay(task.createdAt, date)).length,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const departmentPoints = computed<ReportDepartmentPoint[]>(() => {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
filteredTasks.value.forEach((task) => {
|
||||
counts.set(task.department, (counts.get(task.department) ?? 0) + 1)
|
||||
})
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([department, value]) => ({
|
||||
department,
|
||||
value,
|
||||
color: reportDepartmentColors[department] ?? '#0e6e8c',
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value)
|
||||
})
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const result = new Date(date)
|
||||
result.setHours(0, 0, 0, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
function getStartDate(period: ReportPeriodOption) {
|
||||
if (period === 'custom') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (period === 'month') {
|
||||
const result = startOfDay(new Date())
|
||||
result.setDate(1)
|
||||
return result
|
||||
}
|
||||
|
||||
const days = period === '7d' ? 7 : 14
|
||||
const result = startOfDay(new Date())
|
||||
result.setDate(result.getDate() - days + 1)
|
||||
return result
|
||||
}
|
||||
|
||||
function isSameDay(left: Date, right: Date) {
|
||||
return startOfDay(left).getTime() === startOfDay(right).getTime()
|
||||
}
|
||||
|
||||
function updatePeriod(value: ReportPeriodOption) {
|
||||
filters.period = value
|
||||
}
|
||||
|
||||
function updateCampus(value: ReportCampus) {
|
||||
filters.campus = value
|
||||
}
|
||||
|
||||
function updateDepartment(value: string) {
|
||||
filters.department = value
|
||||
}
|
||||
|
||||
function updateCategory(value: ReportDocumentCategory) {
|
||||
filters.category = value
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
ElMessage.info('原型演示:导出 Excel')
|
||||
}
|
||||
|
||||
function handleView(row: ReportTask) {
|
||||
ElMessage.info(`原型演示:查看任务${row.id},签署工作台暂未实现`)
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
tasks.value = reportTasks.map((task) => ({
|
||||
...task,
|
||||
createdAt: new Date(task.createdAt),
|
||||
}))
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadReports)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder
|
||||
group="管理"
|
||||
title="报表分析"
|
||||
icon="↗"
|
||||
description="报表分析页面占位,待根据 Web 端需求实现。"
|
||||
/>
|
||||
<section class="reports-page">
|
||||
<ReportFilterBar
|
||||
:period="filters.period"
|
||||
:campus="filters.campus"
|
||||
:department="filters.department"
|
||||
:category="filters.category"
|
||||
:period-options="reportPeriodOptions"
|
||||
:campus-options="reportCampusOptions"
|
||||
:department-options="reportDepartmentOptions"
|
||||
:category-options="reportCategoryOptions"
|
||||
@update:period="updatePeriod"
|
||||
@update:campus="updateCampus"
|
||||
@update:department="updateDepartment"
|
||||
@update:category="updateCategory"
|
||||
@export="handleExport"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="loading-card" aria-label="正在加载报表">
|
||||
<span v-for="index in 4" :key="index" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="metric-grid">
|
||||
<ReportMetricCard v-for="metric in metrics" :key="metric.key" :metric="metric" />
|
||||
</div>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="page-card trend-card">
|
||||
<div class="card-heading">
|
||||
<h2>每日签署量趋势</h2>
|
||||
</div>
|
||||
<SignatureTrendChart :points="trendPoints" />
|
||||
</section>
|
||||
|
||||
<section class="page-card department-chart-card">
|
||||
<div class="card-heading">
|
||||
<h2>科室签署分布</h2>
|
||||
</div>
|
||||
<DepartmentDistributionChart :points="departmentPoints" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="page-card detail-card">
|
||||
<div class="card-heading">
|
||||
<h2>签署明细</h2>
|
||||
<span>点击行可在工作台查看原件</span>
|
||||
</div>
|
||||
<ReportDetailTable :rows="filteredTasks" @view="handleView" />
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reports-page {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
flex: 1.5;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.department-chart-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-heading span {
|
||||
margin-left: auto;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-card span {
|
||||
display: block;
|
||||
height: 80px;
|
||||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||
background-size: 400% 100%;
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.metric-grid,
|
||||
.loading-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-heading span {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
175
clinical-web/src/views/management/reports/mock.ts
Normal file
175
clinical-web/src/views/management/reports/mock.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDepartmentPoint,
|
||||
ReportDocumentCategory,
|
||||
ReportPeriodOption,
|
||||
ReportSelectOption,
|
||||
ReportTask,
|
||||
} from './types'
|
||||
|
||||
function daysAgo(days: number, hour: number, minute: number) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - days)
|
||||
date.setHours(hour, minute, 0, 0)
|
||||
return date
|
||||
}
|
||||
|
||||
export const reportCampusOptions: ReportSelectOption<ReportCampus>[] = [
|
||||
{ value: 'all', label: '全部院区' },
|
||||
{ value: '本部院区', label: '本部院区' },
|
||||
{ value: '东院区', label: '东院区' },
|
||||
{ value: '西院区', label: '西院区' },
|
||||
]
|
||||
|
||||
export const reportDepartmentOptions: ReportSelectOption[] = [
|
||||
{ value: 'all', label: '全部科室' },
|
||||
{ value: '放射科', label: '放射科' },
|
||||
{ value: '心内科', label: '心内科' },
|
||||
{ value: '骨科', label: '骨科' },
|
||||
{ value: '健康管理中心', label: '健康管理中心' },
|
||||
{ value: '全院通用', label: '全院通用' },
|
||||
]
|
||||
|
||||
export const reportCategoryOptions: ReportSelectOption<ReportDocumentCategory>[] = [
|
||||
{ value: 'all', label: '全部类别' },
|
||||
{ value: 'consent', label: '知情同意书' },
|
||||
{ value: 'risk', label: '风险告知书' },
|
||||
{ value: 'admission', label: '入院告知' },
|
||||
]
|
||||
|
||||
export const reportPeriodOptions: ReportSelectOption<ReportPeriodOption>[] = [
|
||||
{ value: '7d', label: '近7天' },
|
||||
{ value: '14d', label: '近14天' },
|
||||
{ value: 'month', label: '本月' },
|
||||
{ value: 'custom', label: '自定义…' },
|
||||
]
|
||||
|
||||
export const reportTasks: ReportTask[] = [
|
||||
{
|
||||
id: 'T0818001',
|
||||
patientName: '赵美英',
|
||||
patientId: 'P202610004',
|
||||
documentName: '体检报告发放及知情同意书',
|
||||
department: '健康管理中心',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(0, 9, 12),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0818002',
|
||||
patientName: '李淑芬',
|
||||
patientId: 'P202610002',
|
||||
documentName: 'MRI磁共振检查知情同意书',
|
||||
department: '放射科',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'pending',
|
||||
createdAt: daysAgo(0, 10, 5),
|
||||
},
|
||||
{
|
||||
id: 'T0818003',
|
||||
patientName: '周雅琴',
|
||||
patientId: 'P202610006',
|
||||
documentName: '住院须知及告知书',
|
||||
department: '全院通用',
|
||||
campus: '本部院区',
|
||||
category: 'admission',
|
||||
method: 'pad',
|
||||
status: 'pending',
|
||||
createdAt: daysAgo(0, 11, 20),
|
||||
},
|
||||
{
|
||||
id: 'T0818004',
|
||||
patientName: '孙浩然',
|
||||
patientId: 'P202610005',
|
||||
documentName: '心脏介入手术知情同意书',
|
||||
department: '心内科',
|
||||
campus: '西院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(1, 15, 40),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0817001',
|
||||
patientName: '王建国',
|
||||
patientId: 'P202610001',
|
||||
documentName: '冠状动脉造影知情同意书',
|
||||
department: '心内科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(1, 9, 30),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0817002',
|
||||
patientName: '陈志强',
|
||||
patientId: 'P202610003',
|
||||
documentName: '骨科手术知情同意书',
|
||||
department: '骨科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(2, 10, 15),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0816001',
|
||||
patientName: '陈志强',
|
||||
patientId: 'P202610003',
|
||||
documentName: '麻醉知情同意书',
|
||||
department: '骨科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(3, 14, 25),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0815001',
|
||||
patientName: '王建国',
|
||||
patientId: 'P202610001',
|
||||
documentName: '心包穿刺知情同意书',
|
||||
department: '心内科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'expired',
|
||||
createdAt: daysAgo(4, 16, 0),
|
||||
},
|
||||
{
|
||||
id: 'T0814001',
|
||||
patientName: '周雅琴',
|
||||
patientId: 'P202610006',
|
||||
documentName: '患者授权委托书',
|
||||
department: '全院通用',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'void',
|
||||
createdAt: daysAgo(5, 11, 45),
|
||||
},
|
||||
]
|
||||
|
||||
export const reportDepartmentColors: Record<string, string> = {
|
||||
放射科: '#0e6e8c',
|
||||
心内科: '#18a0c0',
|
||||
骨科: '#0f9d6c',
|
||||
健康管理中心: '#d9821f',
|
||||
全院通用: '#7a4dbb',
|
||||
}
|
||||
|
||||
export const fallbackDepartmentPoint: ReportDepartmentPoint = {
|
||||
department: '暂无数据',
|
||||
value: 0,
|
||||
color: '#cbd8de',
|
||||
}
|
||||
@@ -1,21 +1,57 @@
|
||||
export type ReportPeriodOption = 'today' | 'week' | 'month' | 'year'
|
||||
export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
|
||||
|
||||
export type ReportCampus = 'all' | '本部院区' | '东院区' | '西院区'
|
||||
|
||||
export type ReportDocumentCategory = 'all' | 'consent' | 'risk' | 'admission'
|
||||
|
||||
export interface ReportFilterForm {
|
||||
period: ReportPeriodOption
|
||||
campus: ReportCampus
|
||||
department: string
|
||||
category: ReportDocumentCategory
|
||||
}
|
||||
|
||||
export type ReportMetricTone = 'brand' | 'ok' | 'info' | 'warn'
|
||||
|
||||
export interface ReportMetricCard {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
trend: number
|
||||
value: string | number
|
||||
unit?: string
|
||||
tone: ReportMetricTone
|
||||
}
|
||||
|
||||
export interface ReportChartPoint {
|
||||
date: string
|
||||
signed: number
|
||||
rejected: number
|
||||
pending: number
|
||||
export type ReportTaskStatus = 'signed' | 'pending' | 'expired' | 'void'
|
||||
|
||||
export type ReportSigningMethod = 'pad' | 'sms'
|
||||
|
||||
export interface ReportTask {
|
||||
id: string
|
||||
patientName: string
|
||||
patientId: string
|
||||
documentName: string
|
||||
department: string
|
||||
campus: Exclude<ReportCampus, 'all'>
|
||||
category: Exclude<ReportDocumentCategory, 'all'>
|
||||
method: ReportSigningMethod
|
||||
status: ReportTaskStatus
|
||||
createdAt: Date
|
||||
durationMinutes?: number
|
||||
}
|
||||
|
||||
export interface ReportTrendPoint {
|
||||
date: string
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface ReportDepartmentPoint {
|
||||
department: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface ReportSelectOption<T extends string = string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
rotateKey: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="setting-card">
|
||||
<div class="card-heading">
|
||||
<h2>第三方系统嵌入(免登录接口页)</h2>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
签署工作台可作为 iframe 嵌入 HIS / LIS / 体检等第三方系统,URL 携带签名参数免登录直达:
|
||||
</p>
|
||||
<div class="code-block embed-url">
|
||||
https://medisign.hospital.cn<strong>/embed/workbench</strong>?uid=<strong>{操作员ID}</strong>&pid=<strong>{患者ID}</strong>&visitNo=<strong>{就诊号}</strong>&ts=<strong>{时间戳}</strong>&sign=<strong
|
||||
>{HMAC签名}</strong
|
||||
>
|
||||
</div>
|
||||
<p class="description rule-description">
|
||||
生成规则:HMAC-SHA256(密钥, uid+pid+visitNo+ts),有效期 ≤ 5 分钟,防篡改防重放。
|
||||
</p>
|
||||
<div class="button-row">
|
||||
<button type="button" class="outline-button" @click="emit('copy')">复制嵌入 URL</button>
|
||||
<button type="button" class="outline-button" @click="emit('rotateKey')">密钥轮换</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-card {
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0 0 10px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 10px 12px;
|
||||
color: var(--ink);
|
||||
font-family: Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
background: #f4f8fa;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.code-block strong {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.rule-description {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.outline-button {
|
||||
padding: 4px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.outline-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingToggleKey, SignatureSettings } from '../types'
|
||||
|
||||
defineProps<{
|
||||
settings: SignatureSettings
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:linkExpireHours': [value: number]
|
||||
toggle: [key: SettingToggleKey]
|
||||
}>()
|
||||
|
||||
function handleHoursInput(event: Event) {
|
||||
emit('update:linkExpireHours', Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="setting-card">
|
||||
<div class="card-heading">
|
||||
<h2>签署参数</h2>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">线上链接有效期</span>
|
||||
<input
|
||||
:value="settings.linkExpireHours"
|
||||
class="hours-input"
|
||||
type="number"
|
||||
min="1"
|
||||
aria-label="线上链接有效期"
|
||||
@input="handleHoursInput"
|
||||
/>
|
||||
<span class="setting-hint">小时</span>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">链接超时自动提醒</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
:class="{ active: settings.timeoutReminder }"
|
||||
role="switch"
|
||||
:aria-checked="settings.timeoutReminder"
|
||||
aria-label="链接超时自动提醒"
|
||||
@click="emit('toggle', 'timeoutReminder')"
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">签名原图双留存</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
:class="{ active: settings.dualRetention }"
|
||||
role="switch"
|
||||
:aria-checked="settings.dualRetention"
|
||||
aria-label="签名原图双留存"
|
||||
@click="emit('toggle', 'dualRetention')"
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
<span class="setting-hint">PDF 原件 + 签名图片</span>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">预留 CA 数字证书</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
:class="{ active: settings.caCertificate }"
|
||||
role="switch"
|
||||
:aria-checked="settings.caCertificate"
|
||||
aria-label="预留 CA 数字证书"
|
||||
@click="emit('toggle', 'caCertificate')"
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
<span class="setting-hint">扩展位,本期未启用</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-card {
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.setting-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 0 0 150px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hours-input {
|
||||
width: 80px;
|
||||
padding: 7px 10px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.hours-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: #cbd8de;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-button::after {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: left 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-button.active {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.toggle-button.active::after {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.toggle-button:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.setting-row {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import type { SmsChannel, SmsChannelOption } from '../types'
|
||||
|
||||
defineProps<{
|
||||
channel: SmsChannel
|
||||
channelOptions: SmsChannelOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:channel': [value: SmsChannel]
|
||||
test: []
|
||||
}>()
|
||||
|
||||
function handleChannelChange(event: Event) {
|
||||
emit('update:channel', (event.target as HTMLSelectElement).value as SmsChannel)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="setting-card">
|
||||
<div class="card-heading">
|
||||
<h2>短信模板</h2>
|
||||
</div>
|
||||
|
||||
<div class="code-block sms-template">
|
||||
【xx医院】尊敬的<strong>{患者姓名}</strong>,您有一份《<strong>{文档名称}</strong>》待签署,请点击
|
||||
<strong>{签署链接}</strong> 完成签署,链接 {有效期} 小时内有效。
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<label class="channel-row">
|
||||
<span>短信通道</span>
|
||||
<select :value="channel" aria-label="选择短信通道" @change="handleChannelChange">
|
||||
<option v-for="option in channelOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="button" class="outline-button" @click="emit('test')">发送测试短信</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-card {
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 10px 12px;
|
||||
color: var(--ink);
|
||||
font-family: Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
background: #f4f8fa;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.code-block strong {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 12px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.channel-row > span {
|
||||
flex: 0 0 110px;
|
||||
}
|
||||
|
||||
.channel-row select {
|
||||
width: 200px;
|
||||
padding: 7px 10px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.channel-row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.outline-button {
|
||||
padding: 4px 10px;
|
||||
margin-top: 14px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.outline-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.channel-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.channel-row > span {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.channel-row select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import type { DeviceStatus, TabletDevice } from '../types'
|
||||
|
||||
defineProps<{
|
||||
devices: TabletDevice[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reconnect: [device: TabletDevice]
|
||||
}>()
|
||||
|
||||
const statusLabels: Record<DeviceStatus, string> = {
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="setting-card">
|
||||
<div class="card-heading">
|
||||
<h2>手写板设备</h2>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="device-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in devices" :key="device.id">
|
||||
<td>{{ device.name }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="'status-' + device.status">
|
||||
{{ statusLabels[device.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="outline-button" @click="emit('reconnect', device)">
|
||||
重连
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-card {
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.device-table {
|
||||
width: 100%;
|
||||
min-width: 430px;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.device-table th {
|
||||
padding: 9px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
background: #f5f9fa;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.device-table td {
|
||||
padding: 9px 12px;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
.device-table tbody tr:hover td {
|
||||
background: #f7fbfc;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: var(--info);
|
||||
background: var(--info-l);
|
||||
}
|
||||
|
||||
.outline-button {
|
||||
padding: 4px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.outline-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
|
||||
import SignatureSettingsCard from './components/SignatureSettingsCard.vue'
|
||||
import SmsSettingsCard from './components/SmsSettingsCard.vue'
|
||||
import TabletDevicesCard from './components/TabletDevicesCard.vue'
|
||||
import {
|
||||
defaultSmsChannel,
|
||||
mockSignatureSettings,
|
||||
mockTabletDevices,
|
||||
smsChannelOptions,
|
||||
} from './mock'
|
||||
import type { SettingToggleKey, SignatureSettings, SmsChannel, TabletDevice } from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const signatureSettings = reactive<SignatureSettings>({ ...mockSignatureSettings })
|
||||
const smsChannel = ref<SmsChannel>(defaultSmsChannel)
|
||||
const tabletDevices = ref<TabletDevice[]>([])
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
tabletDevices.value = mockTabletDevices.map((device) => ({ ...device }))
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function updateLinkExpireHours(value: number) {
|
||||
signatureSettings.linkExpireHours = value
|
||||
}
|
||||
|
||||
function toggleSetting(key: SettingToggleKey) {
|
||||
signatureSettings[key] = !signatureSettings[key]
|
||||
|
||||
if (key === 'caCertificate' && signatureSettings.caCertificate) {
|
||||
ElMessage.info('已开启 CA 扩展位(当前版本未启用)')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info('设置已更新')
|
||||
}
|
||||
|
||||
function updateSmsChannel(value: SmsChannel) {
|
||||
smsChannel.value = value
|
||||
}
|
||||
|
||||
function sendTestSms() {
|
||||
ElMessage.success('测试短信已发送至 138****5678')
|
||||
}
|
||||
|
||||
function reconnectDevice(device: TabletDevice) {
|
||||
if (device.status === 'connected') {
|
||||
ElMessage.success('已重新连接')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info('正在重连…')
|
||||
}
|
||||
|
||||
function copyEmbedUrl() {
|
||||
ElMessage.success('原型演示:已复制嵌入 URL')
|
||||
}
|
||||
|
||||
function rotateKey() {
|
||||
ElMessage.success('原型演示:密钥轮换成功')
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder
|
||||
group="管理"
|
||||
title="系统设置"
|
||||
icon="⚙"
|
||||
description="系统设置页面占位,待根据 Web 端需求实现。"
|
||||
/>
|
||||
<section class="settings-page">
|
||||
<div v-if="loading" class="loading-grid" aria-label="正在加载系统设置">
|
||||
<span v-for="index in 4" :key="index" />
|
||||
</div>
|
||||
|
||||
<div v-else class="settings-grid">
|
||||
<SignatureSettingsCard
|
||||
:settings="signatureSettings"
|
||||
@update:link-expire-hours="updateLinkExpireHours"
|
||||
@toggle="toggleSetting"
|
||||
/>
|
||||
<SmsSettingsCard
|
||||
:channel="smsChannel"
|
||||
:channel-options="smsChannelOptions"
|
||||
@update:channel="updateSmsChannel"
|
||||
@test="sendTestSms"
|
||||
/>
|
||||
<TabletDevicesCard :devices="tabletDevices" @reconnect="reconnectDevice" />
|
||||
<EmbedSettingsCard @copy="copyEmbedUrl" @rotate-key="rotateKey" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.loading-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-grid span {
|
||||
display: block;
|
||||
height: 240px;
|
||||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||
background-size: 400% 100%;
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.settings-grid,
|
||||
.loading-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
33
clinical-web/src/views/management/settings/mock.ts
Normal file
33
clinical-web/src/views/management/settings/mock.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { SignatureSettings, SmsChannel, SmsChannelOption, TabletDevice } from './types'
|
||||
|
||||
export const mockSignatureSettings: SignatureSettings = {
|
||||
linkExpireHours: 48,
|
||||
timeoutReminder: true,
|
||||
dualRetention: true,
|
||||
caCertificate: false,
|
||||
}
|
||||
|
||||
export const smsChannelOptions: SmsChannelOption[] = [
|
||||
{ value: 'hospital-primary', label: '医院短信网关(主)' },
|
||||
{ value: 'third-party-backup', label: '第三方短信通道(备)' },
|
||||
]
|
||||
|
||||
export const defaultSmsChannel: SmsChannel = 'hospital-primary'
|
||||
|
||||
export const mockTabletDevices: TabletDevice[] = [
|
||||
{
|
||||
id: 'tablet-01',
|
||||
name: '汉王 GP-1108U(护士站01)',
|
||||
status: 'connected',
|
||||
},
|
||||
{
|
||||
id: 'tablet-02',
|
||||
name: '汉王 GP-1108U(护士站02)',
|
||||
status: 'connected',
|
||||
},
|
||||
{
|
||||
id: 'tablet-03',
|
||||
name: 'Wacom STU-430(放射登记台)',
|
||||
status: 'disconnected',
|
||||
},
|
||||
]
|
||||
@@ -1,10 +1,32 @@
|
||||
export type SettingValue = string | number | boolean
|
||||
export type SettingToggleKey = 'timeoutReminder' | 'dualRetention' | 'caCertificate'
|
||||
|
||||
export type DeviceStatus = 'connected' | 'disconnected'
|
||||
|
||||
export type SmsChannel = 'hospital-primary' | 'third-party-backup'
|
||||
|
||||
export interface SignatureSettings {
|
||||
linkExpireHours: number
|
||||
timeoutReminder: boolean
|
||||
dualRetention: boolean
|
||||
caCertificate: boolean
|
||||
}
|
||||
|
||||
export interface TabletDevice {
|
||||
id: string
|
||||
name: string
|
||||
status: DeviceStatus
|
||||
}
|
||||
|
||||
export interface SmsChannelOption {
|
||||
value: SmsChannel
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SettingFormItem {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
value: SettingValue
|
||||
value: string | number | boolean
|
||||
valueType: 'string' | 'number' | 'boolean'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { RoleSummary } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: RoleSummary[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="role-cards">
|
||||
<article v-for="role in rows" :key="role.id" class="role-card">
|
||||
<h3>
|
||||
<span class="role-color" :style="{ backgroundColor: role.color }" aria-hidden="true" />
|
||||
{{ role.name }}
|
||||
</h3>
|
||||
<p>{{ role.description }}</p>
|
||||
<span class="role-count">{{ role.userCount }} 人</span>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
padding: 13px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.role-card h3 {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.role-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.role-card p {
|
||||
min-height: 35px;
|
||||
margin: 6px 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-count {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
color: var(--brand);
|
||||
font-size: 11px;
|
||||
background: var(--brand-l);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.role-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.role-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
182
clinical-web/src/views/management/users/components/UserTable.vue
Normal file
182
clinical-web/src/views/management/users/components/UserTable.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserStatus, UserTableRow } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: UserTableRow[]
|
||||
roleColors: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [row: UserTableRow]
|
||||
resetPassword: [row: UserTableRow]
|
||||
}>()
|
||||
|
||||
const statusLabels: Record<UserStatus, string> = {
|
||||
enabled: '启用',
|
||||
disabled: '停用',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-scroll">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>工号</th>
|
||||
<th>院区</th>
|
||||
<th>科室</th>
|
||||
<th>角色</th>
|
||||
<th>数据范围</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<span
|
||||
class="user-avatar"
|
||||
:style="{ backgroundColor: roleColors[row.role] ?? '#8a9aa3' }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ row.name.slice(0, 1) }}
|
||||
</span>
|
||||
<strong>{{ row.name }}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ row.employeeNo }}</td>
|
||||
<td>{{ row.campus }}</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="role-tag"
|
||||
:style="{
|
||||
color: roleColors[row.role] ?? '#8a9aa3',
|
||||
backgroundColor: `${roleColors[row.role] ?? '#8a9aa3'}22`,
|
||||
}"
|
||||
>
|
||||
{{ row.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ row.dataScope }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="'status-' + row.status">
|
||||
{{ statusLabels[row.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="table-button" @click="emit('edit', row)">编辑</button>
|
||||
<button type="button" class="table-button" @click="emit('resetPassword', row)">
|
||||
重置密码
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="8" class="empty-cell">没有找到匹配的用户</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
min-width: 1040px;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
padding: 9px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
background: #f5f9fa;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.user-table td {
|
||||
padding: 9px 12px;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
|
||||
.user-table tbody tr:hover td {
|
||||
background: #f7fbfc;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.role-tag,
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-button {
|
||||
padding: 4px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.table-button + .table-button {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.table-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
padding: 40px 12px !important;
|
||||
color: var(--mut);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
||||
import UserTable from './components/UserTable.vue'
|
||||
import { roleColors, roleSummaries, users } from './mock'
|
||||
import type { RoleSummary, UserTableRow } from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const keyword = ref('')
|
||||
const roleRows = ref<RoleSummary[]>([])
|
||||
const userRows = ref<UserTableRow[]>([])
|
||||
|
||||
const visibleUsers = computed(() => {
|
||||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||
|
||||
return userRows.value.filter((user) => {
|
||||
const matchesCampus = user.campus === '本部院区'
|
||||
const matchesKeyword =
|
||||
!normalizedKeyword ||
|
||||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
|
||||
|
||||
return matchesCampus && matchesKeyword
|
||||
})
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
roleRows.value = roleSummaries.map((role) => ({ ...role }))
|
||||
userRows.value = users.map((user) => ({ ...user }))
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function showAddUserMessage() {
|
||||
ElMessage.info('原型演示:新增用户')
|
||||
}
|
||||
|
||||
function showImportMessage() {
|
||||
ElMessage.info('原型演示:批量导入')
|
||||
}
|
||||
|
||||
function editUser(user: UserTableRow) {
|
||||
ElMessage.info(`原型演示:编辑用户《${user.name}》`)
|
||||
}
|
||||
|
||||
function resetPassword(user: UserTableRow) {
|
||||
ElMessage.success(`已重置${user.name}的密码`)
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder
|
||||
group="管理"
|
||||
title="用户与权限"
|
||||
icon="◉"
|
||||
description="用户与权限页面占位,待根据 Web 端需求实现。"
|
||||
/>
|
||||
<section class="users-page">
|
||||
<div v-if="loading" class="loading-card" aria-label="正在加载用户与角色">
|
||||
<span v-for="index in 4" :key="index" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<RoleSummaryCards :rows="roleRows" />
|
||||
|
||||
<section class="page-card">
|
||||
<div class="card-heading">
|
||||
<h2>用户列表</h2>
|
||||
<span class="heading-spacer" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="user-search"
|
||||
type="search"
|
||||
placeholder="搜索姓名 / 工号"
|
||||
aria-label="搜索姓名或工号"
|
||||
/>
|
||||
<button type="button" class="table-button" @click="showAddUserMessage">+新增用户</button>
|
||||
<button type="button" class="table-button" @click="showImportMessage">批量导入</button>
|
||||
</div>
|
||||
<UserTable
|
||||
:rows="visibleUsers"
|
||||
:role-colors="roleColors"
|
||||
@edit="editUser"
|
||||
@reset-password="resetPassword"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.heading-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-search {
|
||||
width: 180px;
|
||||
padding: 6px 10px;
|
||||
color: var(--ink);
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.table-button {
|
||||
padding: 4px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.table-button:hover {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-card span {
|
||||
display: block;
|
||||
height: 116px;
|
||||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||
background-size: 400% 100%;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--sh);
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heading-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-search {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.loading-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
109
clinical-web/src/views/management/users/mock.ts
Normal file
109
clinical-web/src/views/management/users/mock.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { RoleSummary, UserTableRow } from './types'
|
||||
|
||||
export const roleSummaries: RoleSummary[] = [
|
||||
{
|
||||
id: 'system-admin',
|
||||
name: '系统管理员',
|
||||
color: '#6b5bd2',
|
||||
description: '系统配置、用户与权限管理、审计日志',
|
||||
userCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'medical-management',
|
||||
name: '医务管理',
|
||||
color: '#0e6e8c',
|
||||
description: '全院文档库维护、报表分析、任务监督',
|
||||
userCount: 4,
|
||||
},
|
||||
{
|
||||
id: 'department-signer',
|
||||
name: '科室签署员',
|
||||
color: '#0f9d6c',
|
||||
description: '本科室文档发起签署、手写板 / 短信操作',
|
||||
userCount: 23,
|
||||
},
|
||||
{
|
||||
id: 'audit-viewer',
|
||||
name: '查询审计员',
|
||||
color: '#d9821f',
|
||||
description: '只读查询报表与审计留痕,不可发起',
|
||||
userCount: 3,
|
||||
},
|
||||
]
|
||||
|
||||
export const users: UserTableRow[] = [
|
||||
{
|
||||
id: 'user-001',
|
||||
name: '张文静',
|
||||
employeeNo: '10234',
|
||||
campus: '本部院区',
|
||||
department: '医务处',
|
||||
role: '医务管理',
|
||||
dataScope: '全院',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
id: 'user-002',
|
||||
name: '刘明辉',
|
||||
employeeNo: '10301',
|
||||
campus: '本部院区',
|
||||
department: '放射科',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
id: 'user-003',
|
||||
name: '王芳',
|
||||
employeeNo: '10455',
|
||||
campus: '本部院区',
|
||||
department: '心内科',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
id: 'user-004',
|
||||
name: '李国栋',
|
||||
employeeNo: '10512',
|
||||
campus: '东院区',
|
||||
department: '骨科',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
id: 'user-005',
|
||||
name: '陈晓云',
|
||||
employeeNo: '10188',
|
||||
campus: '本部院区',
|
||||
department: '信息科',
|
||||
role: '系统管理员',
|
||||
dataScope: '全院',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
id: 'user-006',
|
||||
name: '赵雪梅',
|
||||
employeeNo: '10602',
|
||||
campus: '东院区',
|
||||
department: '健康管理中心',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
id: 'user-007',
|
||||
name: '孙立',
|
||||
employeeNo: '10733',
|
||||
campus: '本部院区',
|
||||
department: '纪检监察室',
|
||||
role: '查询审计员',
|
||||
dataScope: '全院只读',
|
||||
status: 'enabled',
|
||||
},
|
||||
]
|
||||
|
||||
export const roleColors = Object.fromEntries(
|
||||
roleSummaries.map((role) => [role.name, role.color]),
|
||||
) as Record<string, string>
|
||||
@@ -1,17 +1,20 @@
|
||||
export type UserFilterStatus = 'all' | 'enabled' | 'disabled'
|
||||
export type UserStatus = 'enabled' | 'disabled'
|
||||
|
||||
export interface UserFilterForm {
|
||||
keyword: string
|
||||
department: string
|
||||
status: UserFilterStatus
|
||||
export interface RoleSummary {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
userCount: number
|
||||
}
|
||||
|
||||
export interface UserTableRow {
|
||||
id: string
|
||||
name: string
|
||||
account: string
|
||||
employeeNo: string
|
||||
campus: string
|
||||
department: string
|
||||
role: string
|
||||
status: UserFilterStatus
|
||||
lastLoginAt: string
|
||||
dataScope: string
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user