Files
xh-medical-sign-web/clinical-web/src/views/workbench/home/index.vue

348 lines
7.8 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { getWorkbenchOverview } from '@/api/workbench/home'
import type { WorkbenchCampus, WorkbenchOverviewResponse } from '@/api/workbench/types'
import { useAppStore } from '@/stores/app'
import DocumentRankingPanel from './components/DocumentRankingPanel.vue'
import HomeMetricCard from './components/HomeMetricCard.vue'
import SigningTrendPanel from './components/SigningTrendPanel.vue'
import TodoPanel from './components/TodoPanel.vue'
import type {
HomeDocumentRanking,
HomeMetricCard as HomeMetricCardData,
HomeMetricKey,
HomeRankingPeriod,
HomeTodoItem,
HomeTrendPoint,
} from './types'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const loadFailed = ref(false)
const overview = ref<WorkbenchOverviewResponse | null>(null)
const rankingPeriod = ref<HomeRankingPeriod>(14)
let overviewRequestId = 0
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
const metrics = computed<HomeMetricCardData[]>(() => {
const summary = overview.value?.summary
const signedChange = summary?.todaySignedChange ?? 0
const signedChangeTone: HomeMetricCardData['detailTone'] =
signedChange > 0 ? 'up' : signedChange < 0 ? 'down' : 'muted'
const signedChangeDetail =
signedChange > 0
? `▲ 较昨日 +${signedChange} · 点击查看`
: signedChange < 0
? `▼ 较昨日 ${signedChange} · 点击查看`
: '较昨日持平 · 点击查看'
return [
{
key: 'todaySigned',
label: '今日已签署(单)',
value: summary?.todaySigned ?? 0,
tone: 'success',
detail: signedChangeDetail,
detailTone: signedChangeTone,
},
{
key: 'pending',
label: '待患者签署',
value: summary?.pendingPatientSigning ?? 0,
tone: 'brand',
detail: '含手写板 / 短信 · 点击查看',
detailTone: 'muted',
},
{
key: 'overdue',
label: '今日已超时',
value: summary?.todayOverdue ?? 0,
tone: 'danger',
detail: '需重新发起 · 点击查看',
detailTone: 'down',
},
{
key: 'templates',
label: '可用文档模板(份)',
value: summary?.availableTemplates ?? 0,
tone: 'brand',
detail: `覆盖 ${summary?.coveredDepartments ?? 0} 个科室 · 点击查看`,
detailTone: 'muted',
},
]
})
const trendPoints = computed<HomeTrendPoint[]>(() => overview.value?.trend ?? [])
const todoItems = computed<HomeTodoItem[]>(() => overview.value?.todos ?? [])
const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? [])
async function loadOverview() {
const requestId = ++overviewRequestId
loading.value = true
loadFailed.value = false
try {
const nextOverview = await getWorkbenchOverview({
campus: selectedCampus.value,
rankingPeriod: rankingPeriod.value,
})
if (requestId !== overviewRequestId) {
return
}
overview.value = nextOverview
} catch {
if (requestId !== overviewRequestId) {
return
}
overview.value = null
loadFailed.value = true
ElMessage.error('首页数据加载失败,请稍后重试')
} finally {
if (requestId === overviewRequestId) {
loading.value = false
}
}
}
function goToSigning(query: Record<string, string> = {}) {
void router.push({
name: 'workbench-signing',
query: {
campus: selectedCampus.value,
...query,
},
})
}
function handleMetricClick(key: HomeMetricKey) {
if (key === 'templates') {
void router.push({ name: 'management-documents' })
return
}
const statusMap: Record<Exclude<HomeMetricKey, 'templates'>, string> = {
todaySigned: 'signed',
pending: 'pending',
overdue: 'expired',
}
goToSigning({ status: statusMap[key], range: 'today' })
}
function handleTodoSelect(item: HomeTodoItem) {
goToSigning({ taskId: item.id })
}
function handlePeriodChange(period: HomeRankingPeriod) {
rankingPeriod.value = period
}
function retryLoad() {
void loadOverview()
}
watch([selectedCampus, rankingPeriod], () => {
void loadOverview()
})
onMounted(() => {
void loadOverview()
})
</script>
<template>
<section class="home-page">
<div v-if="loading" class="home-loading" aria-label="正在加载首页">
<div class="metric-loading-grid">
<span v-for="index in 4" :key="index" />
</div>
<div class="panel-loading-grid">
<span />
<span />
</div>
<span class="ranking-loading" />
</div>
<div v-else-if="loadFailed" class="home-error">
<div class="error-icon" aria-hidden="true">!</div>
<p>首页数据暂时无法加载</p>
<button type="button" class="retry-button" @click="retryLoad">重新加载</button>
</div>
<template v-else>
<div class="metric-grid">
<HomeMetricCard
v-for="metric in metrics"
:key="metric.key"
:metric="metric"
@click="handleMetricClick"
/>
</div>
<div class="overview-grid">
<SigningTrendPanel :points="trendPoints" />
<TodoPanel :items="todoItems" @select="handleTodoSelect" @view-all="goToSigning()" />
</div>
<DocumentRankingPanel
:rows="rankingRows"
:period="rankingPeriod"
@update:period="handlePeriodChange"
@manage="router.push({ name: 'management-documents' })"
/>
</template>
</section>
</template>
<style scoped>
.home-page {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
gap: 16px;
margin-top: 16px;
}
.overview-grid > * {
min-width: 0;
}
.ranking-panel {
margin-top: 16px;
}
.home-loading {
display: flex;
flex-direction: column;
gap: 16px;
}
.metric-loading-grid,
.panel-loading-grid {
display: grid;
gap: 16px;
}
.metric-loading-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.panel-loading-grid {
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
}
.metric-loading-grid span,
.panel-loading-grid span,
.ranking-loading {
display: block;
min-height: 82px;
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;
}
.panel-loading-grid span {
min-height: 274px;
}
.ranking-loading {
min-height: 286px;
}
.home-error {
display: flex;
min-height: 380px;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--mut);
text-align: center;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.error-icon {
display: grid;
width: 46px;
height: 46px;
margin-bottom: 12px;
color: var(--err);
font-size: 24px;
font-weight: 700;
place-items: center;
background: var(--err-l);
border-radius: 50%;
}
.home-error p {
margin: 0;
font-size: 13px;
}
.retry-button {
margin-top: 14px;
padding: 7px 16px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 6px;
}
.retry-button:hover {
background: var(--brand-l);
}
@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));
}
.overview-grid,
.panel-loading-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
.metric-grid,
.metric-loading-grid {
grid-template-columns: 1fr;
}
}
</style>