feat: recreate admin dashboard from reference
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user