Compare commits

..

6 Commits

Author SHA1 Message Date
yelan
4412a85adc fix(layout): constrain clinical layout viewport 2026-08-27 16:49:39 +08:00
yelan
74df2ed741 feat(settings): implement system settings 2026-08-27 16:49:10 +08:00
yelan
ac8eff5286 feat(users): implement user and permission management 2026-08-27 16:48:41 +08:00
yelan
332fb53cfa feat(reports): implement report analysis 2026-08-27 16:48:17 +08:00
yelan
94a0d88f8b feat(permissions): implement document permission matrix 2026-08-27 16:47:55 +08:00
yelan
e7aa4843b7 feat(documents): implement document library management 2026-08-27 16:47:31 +08:00
33 changed files with 4498 additions and 76 deletions

View File

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

View File

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

View File

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

View File

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

View 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',
},
]

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
'患者或代理人拒绝推荐检查、治疗时的风险告知及签字确认。',
),
],
},
],
},
]

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
}

View File

@@ -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
}

View File

@@ -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>&amp;pid=<strong>{患者ID}</strong>&amp;visitNo=<strong>{就诊号}</strong>&amp;ts=<strong>{时间戳}</strong>&amp;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>

View File

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

View File

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

View File

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

View File

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

View 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',
},
]

View File

@@ -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'
}

View File

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

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

View File

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

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

View File

@@ -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
}