Files
danyang-match/admin-web/src/views/DashboardView.vue
T
WorkBuddy 3c2b967274
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
release: freeze 2026-10-15 validated release baseline
2026-10-15 09:47:31 +08:00

785 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="dashboard-page">
<section class="dashboard-welcome">
<div>
<h1>你好,{{ operatorName }}</h1>
<p>用专业的服务,帮助更多人遇见对的人。今天也是传递幸福的一天!</p>
</div>
<div class="dashboard-date">
<span>{{ today }}</span>
<small>丹阳 · 公益服务</small>
</div>
</section>
<div v-if="loading" class="dashboard-loading" aria-label="工作台加载中">
<el-skeleton v-for="index in 6" :key="index" animated>
<template #template><el-skeleton-item variant="rect" class="metric-skeleton" /></template>
</el-skeleton>
</div>
<template v-else>
<div v-if="fatalError" class="dashboard-error" role="alert">
<el-icon><Warning /></el-icon>
<span><strong>工作台数据加载失败</strong><small>{{ fatalError }}</small></span>
<el-button type="primary" plain @click="retryLoad">重新加载</el-button>
</div>
<div v-else-if="isForbidden" class="dashboard-forbidden">
<el-icon><Lock /></el-icon>
<span><strong>当前账号暂无统计权限</strong><small>仅展示您职责范围内可访问的业务入口。</small></span>
</div>
<div v-else-if="dashboard.notice" class="dashboard-notice">
<el-icon><InfoFilled /></el-icon><span>{{ dashboard.notice }}</span>
</div>
<section class="dashboard-metrics" aria-label="工作台核心指标">
<button
v-for="metric in dashboard.metrics"
:key="metric.key"
type="button"
class="metric-card"
:class="[`tone-${metric.tone}`, { unavailable: !metric.available }]"
@click="goTo(metric.route)"
>
<span class="metric-icon"><el-icon><component :is="metricIcon(metric.key)" /></el-icon></span>
<span class="metric-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.available ? formatNumber(metric.value) : '—' }}</strong>
<em>{{ metric.trendText }}</em>
</span>
<el-icon class="metric-arrow"><ArrowRight /></el-icon>
</button>
</section>
<section class="dashboard-grid dashboard-grid-top">
<article class="dashboard-panel todo-panel">
<header class="panel-header">
<div><h2>今日待办事项 <span>{{ todoCount }}</span></h2></div>
<button type="button" @click="goTo('/review')">查看更多 <el-icon><ArrowRight /></el-icon></button>
</header>
<div v-if="dashboard.todos.length" class="todo-list">
<button
v-for="item in dashboard.todos"
:key="item.key"
type="button"
class="todo-row"
@click="goTo(item.route)"
>
<span class="todo-icon" :class="`tone-${item.tone}`"><el-icon><component :is="todoIcon(item.key)" /></el-icon></span>
<span class="todo-copy"><strong>{{ item.title }}</strong><small>{{ item.description }}</small></span>
<b>{{ item.count }}</b>
<em>{{ item.actionText }}</em>
</button>
</div>
<div v-else class="dashboard-empty"><el-icon><CircleCheck /></el-icon><span>当前没有授权范围内的待办事项</span></div>
</article>
<article class="dashboard-panel activity-panel">
<header class="panel-header">
<div><h2>近期公益活动</h2></div>
<button type="button" @click="goTo('/activities/interests')">查看更多 <el-icon><ArrowRight /></el-icon></button>
</header>
<div v-if="dashboard.activities.length" class="activity-list">
<button
v-for="activity in dashboard.activities"
:key="activity.id"
type="button"
class="activity-row"
@click="goTo('/activities/interests')"
>
<img
:src="activity.coverUrl || activityFallback"
:alt="activity.title"
@error="useActivityFallback"
/>
<span class="activity-copy">
<strong>{{ activity.title }}</strong>
<small>{{ formatDateTime(activity.startAt) }} · {{ activity.locationName }}</small>
</span>
<span class="activity-status">{{ statusLabel(activity.statusText) }}</span>
<em>{{ activityRegistration(activity) }}</em>
</button>
</div>
<div v-else class="dashboard-empty">
<el-icon><Calendar /></el-icon>
<span>{{ dashboard.activitiesAvailable ? '近期暂无公益活动' : '当前账号无活动列表权限' }}</span>
</div>
</article>
</section>
<article v-if="dashboard.followups.length" class="dashboard-panel">
<header class="panel-header"><div><h2>最新红娘跟进记录</h2></div></header>
<div class="todo-list">
<button v-for="item in dashboard.followups" :key="item.serviceId" type="button" class="todo-row"
@click="goTo(`/matchmaker?serviceId=${encodeURIComponent(item.serviceId)}`)">
<span class="todo-copy"><strong>{{ item.userDisplayNumber }}</strong><small>{{ item.content }}</small></span>
<em>{{ formatDateTime(item.createdAt) }} · 查看服务</em>
</button>
</div>
</article>
<section class="dashboard-grid dashboard-grid-bottom">
<article class="dashboard-panel service-overview-panel">
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
<div v-if="dashboard.serviceOverview.length" class="overview-grid">
<div v-for="item in dashboard.serviceOverview" :key="item.key" :class="`tone-${item.tone}`">
<small>{{ item.label }}</small>
<strong>{{ formatNumber(item.value) }}</strong>
<em>{{ item.compareText }}</em>
</div>
</div>
<div v-else class="dashboard-empty"><el-icon><DataAnalysis /></el-icon><span>暂无可查看的服务统计</span></div>
</article>
<article class="dashboard-panel effectiveness-panel">
<header class="panel-header"><div><h2>项目服务成效</h2></div><span>实时数据</span></header>
<div v-if="dashboard.effectiveness.length" class="effectiveness-list">
<div v-for="item in dashboard.effectiveness" :key="item.key" class="effectiveness-row">
<span>{{ item.label }}</span>
<div><i :style="{ width: `${item.max > 0 ? Math.min(100, item.value / item.max * 100) : 0}%` }"></i></div>
<strong>{{ formatNumber(item.value) }} {{ item.unit }}</strong>
</div>
</div>
<div v-else class="dashboard-empty"><el-icon><TrendCharts /></el-icon><span>暂无可查看的成效数据</span></div>
</article>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onActivated, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowRight,
Calendar,
ChatDotRound,
CircleCheck,
DataAnalysis,
DocumentChecked,
House,
InfoFilled,
Link,
Lock,
StarFilled,
TrendCharts,
UserFilled,
Warning,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { createEmptyDashboard, loadDashboard } from '@/services/dashboard'
import { BRAND_ACTIVITY_FALLBACK } from '@/brand'
import type { DashboardActivity, DashboardModel } from '@/types/dashboard'
const router = useRouter()
const authStore = useAuthStore()
const activityFallback = BRAND_ACTIVITY_FALLBACK
const dashboard = ref<DashboardModel>(createEmptyDashboard())
const loading = ref(true)
const fatalError = ref('')
const permissions = computed(() => authStore.permissions || [])
const operatorName = computed(() => authStore.operator?.name || '管理员')
const isPreviewData = computed(() => authStore.token === 'ui-preview-only')
const hasAnyData = computed(() => dashboard.value.metrics.some((item) => item.available) || dashboard.value.activitiesAvailable)
const isForbidden = computed(() => !hasAnyData.value && !fatalError.value && !isPreviewData.value)
const todoCount = computed(() => dashboard.value.todos.reduce((total, item) => total + item.count, 0))
const today = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
}).format(new Date())
const metricIcons: Record<string, unknown> = {
pending_reviews: DocumentChecked,
matchmaker_todos: StarFilled,
counseling_todos: ChatDotRound,
station_appointments: House,
effective_matches: Link,
activity_registrations: UserFilled,
}
const todoIcons: Record<string, unknown> = {
review: DocumentChecked,
counseling: ChatDotRound,
appointment: Calendar,
risk: Warning,
}
function metricIcon(key: string) {
return metricIcons[key] || DataAnalysis
}
function todoIcon(key: string) {
return todoIcons[key] || CircleCheck
}
function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value)
}
function formatDateTime(value: string) {
if (!value) return '时间待确认'
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: 'numeric',
day: 'numeric',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
draft: '草稿',
registration_open: '报名中',
published: '报名中',
ongoing: '进行中',
completed: '已结束',
cancelled: '已取消',
}
return labels[status] || status
}
function activityRegistration(activity: DashboardActivity) {
if (activity.registered == null) return activity.capacity == null ? '报名数据未提供' : `名额 ${activity.capacity}`
return activity.capacity == null ? `已报名 ${activity.registered}` : `已报名 ${activity.registered}/${activity.capacity}`
}
function useActivityFallback(event: Event) {
const image = event.currentTarget as HTMLImageElement
if (!image.src.endsWith(activityFallback)) image.src = activityFallback
}
function canAccess(path: string) {
const matched = router.resolve(path).matched
const required = matched.flatMap((route) => (route.meta.permissions as string[] | undefined) || [])
return required.every((permission) => permissions.value.includes(permission))
}
function goTo(path: string) {
if (!path) return
if (!canAccess(path)) {
ElMessage.info('当前账号无权访问该模块')
return
}
router.push(path)
}
async function retryLoad() {
loading.value = true
fatalError.value = ''
try {
if (authStore.token === 'ui-preview-only') {
dashboard.value = createEmptyDashboard('界面预览模式不展示虚构业务数字')
return
}
dashboard.value = await loadDashboard(permissions.value)
if (!hasAnyData.value && dashboard.value.notice.includes('加载失败')) {
fatalError.value = dashboard.value.notice
}
} catch (error) {
fatalError.value = error instanceof Error ? error.message : '服务暂时不可用,请稍后重试'
} finally {
loading.value = false
}
}
onActivated(retryLoad)
</script>
<style scoped>
.dashboard-page {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 100%;
max-width: var(--admin-content-max-width);
margin: 0 auto;
padding: var(--space-5) 26px 28px;
box-sizing: border-box;
}
.dashboard-welcome {
display: flex;
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
}
.dashboard-welcome h1 {
font-size: var(--font-size-3xl);
line-height: var(--font-line-snug);
}
.dashboard-welcome p {
margin-top: 6px;
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
.dashboard-date {
display: flex;
align-items: center;
gap: 14px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-date small {
padding: 6px 10px;
color: var(--admin-coral-strong);
background: var(--admin-coral-soft);
border-radius: var(--border-radius-pill);
font-size: var(--font-size-micro);
}
.dashboard-loading,
.dashboard-metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
}
.dashboard-loading {
margin-top: 10px;
}
.metric-skeleton {
height: 105px;
border-radius: var(--border-radius-md);
}
.dashboard-error,
.dashboard-forbidden,
.dashboard-notice {
display: flex;
min-height: 48px;
margin: var(--space-2) 0 var(--space-3);
padding: 10px 14px;
align-items: center;
gap: 10px;
color: var(--color-danger-strong);
background: var(--color-danger-soft);
border: 1px solid var(--color-border-danger);
border-radius: var(--border-radius-sm);
}
.dashboard-error > span,
.dashboard-forbidden > span {
display: flex;
flex: 1;
flex-direction: column;
}
.dashboard-error small,
.dashboard-forbidden small {
margin-top: 3px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.dashboard-metrics {
margin-top: 10px;
}
.metric-card {
position: relative;
display: grid;
min-width: 0;
min-height: 108px;
padding: 14px var(--space-3);
grid-template-columns: 38px 1fr;
align-items: center;
gap: 9px;
overflow: hidden;
text-align: left;
cursor: pointer;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
transition: transform var(--motion-normal) var(--ease-standard),
box-shadow var(--motion-normal) var(--ease-standard);
}
.metric-card:hover {
box-shadow: var(--shadow-raise);
transform: translateY(-2px);
}
.metric-card:active {
transform: translateY(0) scale(var(--press-scale));
}
.metric-card.unavailable {
cursor: default;
opacity: 0.72;
}
/* 图标字形属「有意义的非文本内容」,按 WCAG 需 ≥3:1。
原先用鲜亮色调压同色系浅底(amber 仅 2.2:1),统一改用 -ink 档(≥4.6:1)。 */
.metric-icon,
.todo-icon {
display: grid;
color: var(--tone-ink);
background: var(--tone-soft);
border-radius: 50%;
place-items: center;
}
.metric-icon {
width: 38px;
height: 38px;
font-size: var(--font-size-xl);
}
.metric-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.metric-copy small {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-copy strong {
margin-top: 3px;
color: var(--color-text-primary);
font-size: var(--font-size-3xl);
line-height: 1;
}
/* 原为 9px(低于可读下限),提到 11px */
.metric-copy em {
margin-top: 6px;
overflow: hidden;
color: var(--tone-ink);
font-size: var(--font-size-micro);
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-arrow {
position: absolute;
right: var(--space-2);
bottom: var(--space-2);
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
/* 色调组改为引用 theme/index.css 的 --tone-*-ink / --tone-*-soft。
--tone-ink 是本组件内的局部变量(由下面各 tone-* 类赋值),供图标与文字统一取用。 */
.tone-rose { --tone-ink: var(--tone-rose-ink); --tone-soft: var(--tone-rose-soft); }
.tone-amber { --tone-ink: var(--tone-amber-ink); --tone-soft: var(--tone-amber-soft); }
.tone-blue { --tone-ink: var(--tone-blue-ink); --tone-soft: var(--tone-blue-soft); }
.tone-green { --tone-ink: var(--tone-green-ink); --tone-soft: var(--tone-green-soft); }
.tone-purple { --tone-ink: var(--tone-purple-ink); --tone-soft: var(--tone-purple-soft); }
.tone-red { --tone-ink: var(--tone-red-ink); --tone-soft: var(--tone-red-soft); }
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
margin-top: 14px;
}
.dashboard-grid-top {
flex: 0 0 auto;
}
.dashboard-grid-bottom {
flex: 1;
min-height: 0;
}
.dashboard-grid-bottom .dashboard-panel {
height: 100%;
}
.dashboard-panel {
min-width: 0;
padding: 14px var(--space-4);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--admin-card-radius);
box-shadow: var(--shadow-panel);
}
.panel-header {
display: flex;
min-height: 30px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border-hairline);
}
.panel-header h2 {
padding-bottom: 10px;
font-size: var(--font-size-lg);
}
.panel-header h2 span {
display: inline-grid;
min-width: var(--space-5);
height: var(--space-5);
margin-left: 6px;
padding: 0 5px;
color: var(--color-text-inverse);
font-size: var(--font-size-micro);
line-height: var(--space-5);
background: var(--admin-coral-solid);
border-radius: var(--border-radius-pill);
place-items: center;
}
.panel-header button {
display: flex;
min-height: var(--tap-control);
align-items: center;
gap: var(--space-1);
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-xs);
transition: color var(--motion-fast) var(--ease-standard);
}
.panel-header button:hover {
color: var(--admin-coral-strong);
}
.panel-header > span {
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.todo-list,
.activity-list {
min-height: 190px;
}
.todo-row,
.activity-row {
display: flex;
width: 100%;
min-height: 47px;
padding: 6px 5px;
align-items: center;
gap: 9px;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-bottom: 1px solid var(--color-border-hairline);
transition: background var(--motion-fast) var(--ease-standard);
}
.todo-row:hover,
.activity-row:hover {
background: var(--color-bg-secondary);
}
.todo-icon {
width: 29px;
height: 29px;
flex: none;
}
.todo-copy,
.activity-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.todo-copy strong,
.activity-copy strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-copy small,
.activity-copy small {
margin-top: 3px;
overflow: hidden;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-row > b {
min-width: 29px;
color: var(--admin-coral-strong);
font-size: var(--font-size-sm);
text-align: center;
}
.todo-row > em {
color: var(--admin-coral-strong);
font-size: var(--font-size-micro);
font-style: normal;
}
.activity-row img {
width: 72px;
height: 38px;
flex: none;
object-fit: cover;
border-radius: var(--border-radius-xs);
}
.activity-status {
padding: 3px 6px;
color: var(--color-success-strong);
font-size: var(--font-size-micro);
background: var(--color-success-soft);
border-radius: var(--border-radius-pill);
}
.activity-row > em {
min-width: 76px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
text-align: right;
}
.dashboard-empty {
display: flex;
min-height: 160px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-empty .el-icon {
color: var(--color-icon-muted);
font-size: var(--font-size-5xl);
}
.overview-grid {
display: grid;
min-height: 118px;
padding-top: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
}
.overview-grid > div {
display: flex;
padding: var(--space-3) 10px;
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--tone-soft);
border-radius: var(--border-radius-sm);
}
.overview-grid small {
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
.overview-grid strong {
margin-top: 7px;
font-size: var(--font-size-3xl);
}
.overview-grid em {
margin-top: 7px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
}
.effectiveness-list {
min-height: 118px;
padding-top: 9px;
}
.effectiveness-row {
display: grid;
min-height: 23px;
grid-template-columns: 92px 1fr 76px;
align-items: center;
gap: 9px;
font-size: var(--font-size-micro);
}
.effectiveness-row > span {
color: var(--color-text-tertiary);
}
/* 轨道与填充的对比原为 2.53:1(条几乎看不出进度),改后 4.01:1 */
.effectiveness-row > div {
height: var(--space-2);
overflow: hidden;
background: var(--color-primary-light);
border-radius: var(--border-radius-pill);
}
.effectiveness-row i {
display: block;
height: 100%;
background: var(--gradient-brand-bar);
border-radius: inherit;
}
.effectiveness-row strong {
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
text-align: right;
}
@media (max-width: 1450px), (max-height: 820px) {
.dashboard-page { padding: 12px 18px 20px; }
.dashboard-welcome { min-height: 54px; }
.dashboard-welcome h1 { font-size: var(--font-size-2xl); }
.dashboard-welcome p { margin-top: 3px; font-size: var(--font-size-xs); }
.dashboard-metrics { margin-top: 6px; gap: 7px; }
.metric-card { min-height: 90px; padding: 10px var(--space-2); grid-template-columns: var(--space-8) 1fr; gap: 6px; }
.metric-icon { width: var(--space-8); height: var(--space-8); font-size: var(--font-size-lg); }
.metric-copy strong { font-size: var(--font-size-2xl); }
.dashboard-grid { margin-top: 9px; gap: 9px; }
.dashboard-panel { padding: 10px var(--space-3); }
.todo-list, .activity-list { min-height: 152px; }
.todo-row, .activity-row { min-height: 38px; padding: var(--space-1); }
.activity-row img { width: 58px; height: 31px; }
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
.overview-grid, .effectiveness-list { min-height: 98px; }
.overview-grid > div { padding: var(--space-2) 6px; }
.overview-grid strong { font-size: var(--font-size-2xl); }
}
@media (max-width: 1180px) {
.dashboard-loading,
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 880px) {
.dashboard-grid { grid-template-columns: 1fr; }
.dashboard-date { display: none; }
}
</style>