348 lines
7.8 KiB
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>
|