feat: recreate admin dashboard from reference

This commit is contained in:
Codex
2026-09-13 21:50:44 +08:00
parent fbafe693b6
commit 72b0758e8c
2 changed files with 726 additions and 28 deletions
+726 -28
View File
@@ -1,36 +1,734 @@
<template>
<div class="page-container page-shell dashboard" v-loading="loading">
<section class="welcome">
<div><div class="eyebrow">{{ profile.badge }}</div><h1>{{ greeting }},{{ operatorName }}</h1><p>{{ profile.subtitle }}</p></div>
<div class="date-card"><span>{{ today }}</span><strong>今日服务</strong></div>
<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>
<section class="metrics">
<article v-for="item in metrics" :key="item.label" class="metric-card surface-card"><div class="metric-top"><span>{{item.label}}</span><span class="metric-icon" :class="item.tone"><el-icon><component :is="item.icon"/></el-icon></span></div><strong>{{item.value}}</strong><small>{{item.hint}}</small></article>
</section>
<section class="dashboard-grid">
<article class="surface-card focus-card"><header><div><span class="section-kicker">今日优先</span><h2>{{profile.focusTitle}}</h2></div><el-button type="primary" plain @click="go(profile.primaryPath)">{{profile.primaryAction}}</el-button></header><div class="task-list"><button v-for="task in profile.tasks" :key="task.title" type="button" class="task-row" @click="go(task.path)"><span class="task-dot" :class="task.tone"></span><span class="task-copy"><strong>{{task.title}}</strong><small>{{task.desc}}</small></span><span class="task-count">{{task.count}}</span><el-icon><ArrowRight/></el-icon></button></div></article>
<aside class="surface-card quick-card"><header><span class="section-kicker">常用操作</span><h2>快速开始</h2></header><button v-for="action in profile.actions" :key="action.label" type="button" class="quick-action" @click="go(action.path)"><span class="quick-icon"><el-icon><component :is="actionIcon(action.label)"/></el-icon></span><span><strong>{{action.label}}</strong><small>{{action.desc}}</small></span><el-icon><ArrowRight/></el-icon></button><div class="privacy-note"><el-icon><Lock/></el-icon><span><strong>数据访问受保护</strong><small>仅可查看职责范围内的服务信息,敏感操作全程留痕。</small></span></div></aside>
</section>
<section class="surface-card service-overview"><header><div><span class="section-kicker">公益服务概况</span><h2>本周服务进展</h2></div><span class="preview-label" v-if="preview">界面预览数据</span></header><div class="progress-grid"><div v-for="item in progress" :key="item.label" class="progress-item"><div><span>{{item.label}}</span><strong>{{item.value}}</strong></div><el-progress :percentage="item.percent" :stroke-width="8" :show-text="false" :color="item.color"/><small>{{item.note}}</small></div></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>
<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,onMounted,ref} from 'vue';import {useRouter} from 'vue-router';import {ArrowRight,ChatDotRound,CircleCheck,Connection,DocumentChecked,Location,Lock,Memo,Service,TrendCharts,User,Warning} from '@element-plus/icons-vue';import {get} from '@/api/client';import {useAuthStore} from '@/stores/auth'
type Task={title:string;desc:string;count:number;tone:string;path:string};type Action={label:string;desc:string;icon:any;path:string};type Profile={badge:string;subtitle:string;focusTitle:string;primaryAction:string;primaryPath:string;tasks:Task[];actions:Action[]}
const router=useRouter();const auth=useAuthStore();const loading=ref(false);const preview=ref(false);const stats=ref<Record<string,number>>({})
const operatorName=computed(()=>auth.operator?.name||'工作人员');const today=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'long'}).format(new Date());const greeting=computed(()=>new Date().getHours()<12?'上午好':new Date().getHours()<18?'下午好':'晚上好')
const p=computed(()=>auth.permissions||[]);const has=(code:string)=>p.value.includes(code)
const profile=computed<Profile>(()=>{if(has('counseling.service.view'))return{badge:'婚恋咨询工作台',subtitle:'只处理明确分配给你的咨询服务,尊重每一位来访者的隐私。',focusTitle:'我的待办咨询',primaryAction:'进入咨询服务',primaryPath:'/counseling',tasks:[{title:'待首次联系',desc:'请在服务时限内完成首次沟通',count:stats.value.pendingCounseling||3,tone:'rose',path:'/counseling'},{title:'今日预约',desc:'确认时间与服务方式',count:stats.value.todayAppointments||2,tone:'amber',path:'/counseling'},{title:'待补充记录',desc:'仅记录必要的服务事实',count:1,tone:'blue',path:'/counseling'}],actions:[{label:'查看我的咨询',desc:'仅限已分配服务',icon:'咨',path:'/counseling'},{label:'转介协作',desc:'需要其他专业支持',icon:'转',path:'/referral'}]};if(has('service_appointment.view'))return{badge:'服务站工作台',subtitle:'聚焦今天的现场预约与指定服务,不浏览全平台用户库。',focusTitle:'今日到站服务',primaryAction:'管理预约',primaryPath:'/service-appointment',tasks:[{title:'待确认预约',desc:'确认服务时间与到站方式',count:stats.value.pendingAppointments||6,tone:'rose',path:'/service-appointment'},{title:'即将到站',desc:'提前准备现场服务资料',count:4,tone:'green',path:'/service-appointment'},{title:'待完成回访',desc:'记录服务结果与后续安排',count:2,tone:'blue',path:'/service-appointment'}],actions:[{label:'预约管理',desc:'查看今日预约',icon:'约',path:'/service-appointment'},{label:'服务站信息',desc:'维护开放时间与项目',icon:'站',path:'/service-station'}]};if(has('review.view'))return{badge:'资料审核工作台',subtitle:'核验真实信息,帮助平台建立安全、可信的相遇环境。',focusTitle:'今日审核队列',primaryAction:'开始审核',primaryPath:'/review',tasks:[{title:'待初审资料',desc:'按提交时间依次处理',count:stats.value.pendingReviews||18,tone:'rose',path:'/review'},{title:'待复核资料',desc:'关注证件与资料一致性',count:5,tone:'amber',path:'/review'},{title:'待补充材料',desc:'清晰说明需要用户补充的内容',count:7,tone:'blue',path:'/review'}],actions:[{label:'进入资料审核',desc:'处理待审资料',icon:'审',path:'/review'},{label:'用户查询',desc:'按权限查询用户',icon:'查',path:'/users'}]};return{badge:'公益服务工作台',subtitle:'从今天最重要的待办开始,让每一项服务都有回应。',focusTitle:'今日待办',primaryAction:'查看全部',primaryPath:'/dashboard',tasks:[{title:'待处理服务',desc:'请按服务时限及时响应',count:stats.value.pendingTasks||12,tone:'rose',path:'/dashboard'},{title:'待跟进事项',desc:'查看最近更新与反馈',count:8,tone:'amber',path:'/dashboard'},{title:'安全提醒',desc:'关注需要优先处理的风险',count:2,tone:'blue',path:'/risk/reports'}],actions:[{label:'用户服务',desc:'进入当前职责模块',icon:'服',path:'/dashboard'},{label:'数据概览',desc:'查看授权范围统计',icon:'数',path:'/analytics'}]}})
const metrics=computed(()=>[{label:'今日待办',value:profile.value.tasks.reduce((n,t)=>n+t.count,0),hint:'按优先级及时处理',icon:DocumentChecked,tone:'rose'},{label:'本周已完成',value:stats.value.weekCompleted??0,hint:'已完成服务事项',icon:CircleCheck,tone:'green'},{label:'服务中',value:stats.value.activeServices??0,hint:'处于正常服务流程',icon:Service,tone:'blue'},{label:'需关注',value:stats.value.attention??0,hint:'包含临期与风险事项',icon:Warning,tone:'amber'}])
const progress=[{label:'资料审核及时率',value:'96%',percent:96,note:'目标 95%',color:'#2E9B68'},{label:'预约按时完成率',value:'91%',percent:91,note:'本周 31 次服务',color:'#4A7CD4'},{label:'服务回访完成率',value:'84%',percent:84,note:'还有 5 项待回访',color:'#E59A32'}]
function actionIcon(label:string){return ({'查看我的咨询':ChatDotRound,'转介协作':Connection,'预约管理':Memo,'服务站信息':Location,'进入资料审核':DocumentChecked,'用户查询':User,'数据概览':TrendCharts} as Record<string,any>)[label]||Service}
function go(path:string){if(path)router.push(path)}
async function load(){if(auth.token==='ui-preview-only'){preview.value=true;return}loading.value=true;try{const response:any=await get('/operator/analytics/dashboard');stats.value=response.data||{}}finally{loading.value=false}}
onMounted(load)
import { computed, onMounted, 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 type { DashboardActivity, DashboardModel } from '@/types/dashboard'
const router = useRouter()
const authStore = useAuthStore()
const activityFallback = '/brand/activity-default.jpg'
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
}
}
onMounted(retryLoad)
</script>
<style scoped>
.dashboard{padding-top:28px;padding-bottom:40px}.welcome{min-height:150px;padding:30px 34px;display:flex;align-items:center;justify-content:space-between;border-radius:18px;background:linear-gradient(120deg,#FFF1EE,#FFF9F2 56%,#F4F8F6);border:1px solid #F6E4DE;position:relative;overflow:hidden}.welcome:after{content:'缘';position:absolute;right:130px;top:-54px;font-size:210px;font-weight:700;color:rgba(233,107,107,.07)}.eyebrow,.section-kicker{font-size:12px;color:var(--color-primary);font-weight:650;letter-spacing:1px}.welcome h1{margin-top:9px;font-size:28px}.welcome p{margin-top:10px;color:var(--color-text-secondary)}.date-card{z-index:1;padding:14px 18px;border-radius:13px;background:rgba(255,255,255,.75);border:1px solid rgba(255,255,255,.9);display:flex;flex-direction:column;text-align:right}.date-card span{font-size:12px;color:var(--color-text-tertiary)}.date-card strong{margin-top:4px;font-size:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:18px}.metric-card{padding:20px}.metric-top{display:flex;justify-content:space-between;color:var(--color-text-secondary)}.metric-icon{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;font-weight:650}.metric-icon.rose{background:var(--color-primary-light);color:var(--color-primary)}.metric-icon.green{background:#ECF8F2;color:var(--color-success)}.metric-icon.blue{background:#EEF4FF;color:var(--color-info)}.metric-icon.amber{background:#FFF7E9;color:var(--color-warning)}.metric-card>strong{display:block;margin-top:8px;font-size:30px}.metric-card>small{display:block;margin-top:6px;color:var(--color-text-tertiary)}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(300px,.8fr);gap:18px;margin-top:18px}.focus-card,.quick-card,.service-overview{padding:24px}.focus-card header,.quick-card header,.service-overview header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.focus-card h2,.quick-card h2,.service-overview h2{margin-top:5px;font-size:18px}.task-row,.quick-action{width:100%;border:0;background:transparent;display:flex;align-items:center;text-align:left;cursor:pointer}.task-row{min-height:76px;border-top:1px solid var(--color-border-light);gap:14px}.task-row:hover,.quick-action:hover{background:var(--color-primary-lighter)}.task-dot{width:9px;height:9px;border-radius:50%;background:var(--color-primary)}.task-dot.green{background:var(--color-success)}.task-dot.blue{background:var(--color-info)}.task-dot.amber{background:var(--color-warning)}.task-copy{flex:1;display:flex;flex-direction:column}.task-copy small,.quick-action small{margin-top:5px;color:var(--color-text-tertiary)}.task-count{font-size:22px;font-weight:650;color:var(--color-primary)}.quick-action{padding:15px 8px;gap:12px;border-top:1px solid var(--color-border-light)}.quick-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:12px;background:var(--color-primary-light);color:var(--color-primary);font-weight:650}.quick-action>span:nth-child(2){flex:1;display:flex;flex-direction:column}.privacy-note{margin-top:18px;padding:16px;display:flex;gap:10px;background:#F4F8F6;color:var(--color-success);border-radius:12px}.privacy-note span{display:flex;flex-direction:column}.privacy-note small{margin-top:4px;color:var(--color-text-secondary);line-height:1.5}.service-overview{margin-top:18px}.preview-label{font-size:11px;color:var(--color-warning);background:#FFF7E9;padding:5px 10px;border-radius:99px}.progress-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:30px}.progress-item>div{display:flex;justify-content:space-between;margin-bottom:12px}.progress-item strong{font-size:18px}.progress-item small{display:block;margin-top:8px;color:var(--color-text-tertiary)}
@media(max-width:1100px){.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}.progress-grid{grid-template-columns:1fr}.welcome:after{right:20px}}
@media(max-width:680px){.metrics{grid-template-columns:1fr}.welcome{padding:24px}.date-card{display:none}}
.welcome{background:var(--admin-soft-pink)}.welcome:after{content:none}
.dashboard-page {
min-width: 0;
max-width: 1500px;
margin: 0 auto;
padding: 20px 26px 28px;
}
.dashboard-welcome {
display: flex;
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.dashboard-welcome h1 {
font-size: 24px;
line-height: 1.25;
}
.dashboard-welcome p {
margin-top: 6px;
color: #78828b;
font-size: 13px;
}
.dashboard-date {
display: flex;
align-items: center;
gap: 14px;
color: #7b858e;
font-size: 12px;
}
.dashboard-date small {
padding: 6px 10px;
color: #d75a63;
background: #fff0f1;
border-radius: 999px;
}
.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: 12px;
}
.dashboard-error,
.dashboard-forbidden,
.dashboard-notice {
display: flex;
min-height: 48px;
margin: 8px 0 12px;
padding: 10px 14px;
align-items: center;
gap: 10px;
color: #6f5355;
background: #fff6f6;
border: 1px solid #f7dfe1;
border-radius: 11px;
}
.dashboard-error > span,
.dashboard-forbidden > span {
display: flex;
flex: 1;
flex-direction: column;
}
.dashboard-error small,
.dashboard-forbidden small {
margin-top: 3px;
color: #8c7d7e;
}
.dashboard-metrics {
margin-top: 10px;
}
.metric-card {
position: relative;
display: grid;
min-width: 0;
min-height: 108px;
padding: 14px 12px;
grid-template-columns: 38px 1fr;
align-items: center;
gap: 9px;
overflow: hidden;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid rgba(235, 235, 238, 0.9);
border-radius: 12px;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.metric-card:hover {
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
transform: translateY(-2px);
}
.metric-card.unavailable {
cursor: default;
opacity: 0.72;
}
.metric-icon,
.todo-icon {
display: grid;
color: var(--tone-color);
background: var(--tone-soft);
border-radius: 50%;
place-items: center;
}
.metric-icon {
width: 38px;
height: 38px;
font-size: 19px;
}
.metric-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.metric-copy small {
overflow: hidden;
color: #55616a;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-copy strong {
margin-top: 3px;
color: #171a1d;
font-size: 25px;
line-height: 1;
}
.metric-copy em {
margin-top: 6px;
overflow: hidden;
color: var(--tone-color);
font-size: 9px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-arrow {
position: absolute;
right: 8px;
bottom: 8px;
color: var(--tone-color);
font-size: 11px;
}
.tone-rose { --tone-color: #f05e69; --tone-soft: #ffe9eb; }
.tone-amber { --tone-color: #e58b39; --tone-soft: #fff1df; }
.tone-blue { --tone-color: #568ce9; --tone-soft: #eaf2ff; }
.tone-green { --tone-color: #4aad76; --tone-soft: #e9f7ef; }
.tone-purple { --tone-color: #9c69df; --tone-soft: #f2eaff; }
.tone-red { --tone-color: #ed5b64; --tone-soft: #ffe9eb; }
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
margin-top: 14px;
}
.dashboard-panel {
min-width: 0;
padding: 14px 16px;
background: #fff;
border: 1px solid #e9edf0;
border-radius: var(--admin-card-radius);
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
}
.panel-header {
display: flex;
min-height: 30px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f2;
}
.panel-header h2 {
padding-bottom: 10px;
font-size: 16px;
}
.panel-header h2 span {
display: inline-grid;
min-width: 20px;
height: 20px;
margin-left: 6px;
padding: 0 5px;
color: #fff;
font-size: 10px;
line-height: 20px;
background: var(--admin-coral);
border-radius: 999px;
place-items: center;
}
.panel-header button {
display: flex;
align-items: center;
gap: 4px;
color: #89929a;
font-size: 11px;
cursor: pointer;
background: transparent;
border: 0;
}
.panel-header > span {
color: #98a0a7;
font-size: 10px;
}
.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 #f2f3f4;
}
.todo-row:hover,
.activity-row:hover {
background: #fffafa;
}
.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: #30373d;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-copy small,
.activity-copy small {
margin-top: 3px;
overflow: hidden;
color: #939ba2;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-row > b {
min-width: 29px;
color: var(--admin-coral);
font-size: 13px;
text-align: center;
}
.todo-row > em {
color: var(--admin-coral);
font-size: 10px;
font-style: normal;
}
.activity-row img {
width: 72px;
height: 38px;
flex: none;
object-fit: cover;
border-radius: 7px;
}
.activity-status {
padding: 3px 6px;
color: #318b5a;
font-size: 9px;
background: #eaf7ef;
border-radius: 999px;
}
.activity-row > em {
min-width: 76px;
color: #7d878f;
font-size: 9px;
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: #a0a7ad;
font-size: 11px;
}
.dashboard-empty .el-icon {
color: #d7dce0;
font-size: 34px;
}
.overview-grid {
display: grid;
min-height: 118px;
padding-top: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.overview-grid > div {
display: flex;
padding: 12px 10px;
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--tone-soft);
border-radius: 8px;
}
.overview-grid small {
color: var(--tone-color);
font-size: 10px;
}
.overview-grid strong {
margin-top: 7px;
font-size: 24px;
}
.overview-grid em {
margin-top: 7px;
color: #8a9298;
font-size: 9px;
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: 10px;
}
.effectiveness-row > span {
color: #747e87;
}
.effectiveness-row > div {
height: 8px;
overflow: hidden;
background: #fdebee;
border-radius: 999px;
}
.effectiveness-row i {
display: block;
height: 100%;
background: linear-gradient(90deg, #f56b75, #ef5a65);
border-radius: inherit;
}
.effectiveness-row strong {
color: #75808a;
font-size: 10px;
font-weight: 500;
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: 20px; }
.dashboard-welcome p { margin-top: 3px; font-size: 11px; }
.dashboard-metrics { margin-top: 6px; gap: 7px; }
.metric-card { min-height: 90px; padding: 10px 8px; grid-template-columns: 32px 1fr; gap: 6px; }
.metric-icon { width: 32px; height: 32px; font-size: 16px; }
.metric-copy strong { font-size: 21px; }
.dashboard-grid { margin-top: 9px; gap: 9px; }
.dashboard-panel { padding: 10px 12px; }
.todo-list, .activity-list { min-height: 152px; }
.todo-row, .activity-row { min-height: 38px; padding: 4px; }
.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: 8px 6px; }
.overview-grid strong { font-size: 20px; }
}
@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>