feat(home): implement workbench dashboard
This commit is contained in:
@@ -1,12 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
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)
|
||||
|
||||
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
|
||||
|
||||
const metrics = computed<HomeMetricCardData[]>(() => {
|
||||
const summary = overview.value?.summary
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'todaySigned',
|
||||
label: '今日已签署(单)',
|
||||
value: summary?.todaySigned ?? 0,
|
||||
tone: 'success',
|
||||
detail: '▲ 较昨日 +2 · 点击查看',
|
||||
detailTone: 'up',
|
||||
},
|
||||
{
|
||||
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() {
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
|
||||
try {
|
||||
overview.value = await getWorkbenchOverview({
|
||||
campus: selectedCampus.value,
|
||||
rankingPeriod: rankingPeriod.value,
|
||||
})
|
||||
} catch {
|
||||
loadFailed.value = true
|
||||
ElMessage.error('首页数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToSigning(query: Record<string, string> = {}) {
|
||||
void router.push({
|
||||
name: 'workbench-signing',
|
||||
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>
|
||||
<PagePlaceholder
|
||||
group="工作台"
|
||||
title="首页"
|
||||
icon="⌂"
|
||||
description="首页页面占位,待根据 Web 端需求实现。"
|
||||
/>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user