ui: 我的工作台全面美化 — 渐变顶栏+KPI网格+空状态卡片

This commit is contained in:
Hermes CI Fix
2026-07-20 10:33:35 +08:00
parent 5710b28730
commit 5bb10dbba5
+178 -88
View File
@@ -1,13 +1,29 @@
<template> <template>
<div class="my-dashboard-page"> <div class="my-dashboard-page">
<!-- 顶部问候 --> <!-- 顶部问候+概览 -->
<div class="dash-header"> <div class="welcome-section">
<div class="header-left"> <div class="welcome-left">
<h3>你好{{ userName }}</h3> <div class="welcome-avatar">{{ userName[0] }}</div>
<span class="header-role">{{ roleLabel }}</span> <div>
<div class="welcome-greeting">早上好{{ userName }}</div>
<div class="welcome-sub">{{ roleLabel }} · {{ todayStr }}</div>
</div>
</div> </div>
<div class="header-right"> <div class="welcome-stats" v-if="!loading">
<span class="date-text">📅 {{ todayStr }}</span> <div class="stat-item">
<div class="stat-num">{{ kpis.length }}</div>
<div class="stat-label">我的KPI</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-num">{{ kpis.filter(k => k.level === 'red').length }}</div>
<div class="stat-label stat-red">预警</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-num">{{ actionPlans.length }}</div>
<div class="stat-label">待办</div>
</div>
</div> </div>
</div> </div>
@@ -18,61 +34,78 @@
<template v-if="!loading"> <template v-if="!loading">
<!-- 待办提醒 --> <!-- 待办提醒 -->
<div v-if="reminders.length > 0" class="reminder-section"> <div v-if="reminders.length > 0" class="reminder-section">
<h4 class="section-title">📌 待办提醒</h4> <div class="section-header">
<div v-for="r in reminders" :key="r.type + r.related_id" class="reminder-item" :class="'sev-' + r.severity"> <span class="section-icon">📌</span>
<span class="reminder-icon">{{ r.severity === 'danger' ? '🔴' : '🟡' }}</span> <span class="section-title">待办提醒</span>
<span class="reminder-msg">{{ r.message }}</span> <span class="section-badge">{{ reminders.length }}</span>
<el-button size="small" text @click="goToDetail(r)">查看详情 </el-button> </div>
<div v-for="r in reminders" :key="r.type + r.related_id" class="reminder-card" :class="'sev-' + r.severity">
<div class="reminder-left">
<span class="reminder-icon">{{ r.severity === 'danger' ? '🔴' : '🟡' }}</span>
</div>
<div class="reminder-body">
<div class="reminder-msg">{{ r.message }}</div>
</div>
<el-button size="small" text type="primary" @click="goToDetail(r)">查看</el-button>
</div> </div>
</div> </div>
<!-- 我的KPI --> <!-- 我的KPI -->
<div class="kpi-section"> <div class="kpi-section">
<h4 class="section-title">📊 我的KPI</h4> <div class="section-header">
<div v-if="kpis.length === 0" class="empty-state">暂无关联到你的KPI请管理员在KPI字典中设置负责人</div> <span class="section-icon">📊</span>
<div v-else class="kpi-list"> <span class="section-title">我的KPI</span>
<span class="section-action" @click="$router.push('/kpis')">查看全部 </span>
</div>
<div v-if="kpis.length === 0" class="empty-card">
<div class="empty-icon">📋</div>
<div class="empty-text">暂无关联KPI</div>
<div class="empty-hint">请管理员在KPI字典中设置负责人</div>
</div>
<div v-else class="kpi-grid">
<div v-for="k in kpis" :key="k.id" class="kpi-card" :class="'kpi-' + k.level" @click="$router.push('/kpis/' + k.id)"> <div v-for="k in kpis" :key="k.id" class="kpi-card" :class="'kpi-' + k.level" @click="$router.push('/kpis/' + k.id)">
<div class="kpi-card-left"> <div class="kpi-head">
<span class="kpi-dot" :class="'dot-' + k.level"></span> <span class="kpi-name">{{ k.kpi_name }}</span>
<span class="kpi-tag" :class="'tag-' + k.level">{{ k.level === 'green' ? '达标' : k.level === 'yellow' ? '预警' : k.level === 'red' ? '危险' : '未评' }}</span>
</div> </div>
<div class="kpi-card-body"> <div class="kpi-value-row">
<div class="kpi-card-top"> <div class="kpi-actual">{{ fmtValue(k.actual_value, k.kpi_code) }}</div>
<span class="kpi-name">{{ k.kpi_name }}</span> <div class="kpi-target">目标 {{ k.target_value ?? '-' }}</div>
<span class="kpi-code">{{ k.kpi_code }}</span>
</div>
<div class="kpi-card-bar">
<el-progress
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0"
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
:stroke-width="16"
:text-inside="true"
:format="() => fmtValue(k.actual_value, k.kpi_code) + ' / ' + (k.target_value ?? '-')"
/>
</div>
</div> </div>
<el-progress
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0"
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
:stroke-width="6"
:show-text="false"
/>
</div> </div>
</div> </div>
</div> </div>
<!-- 我的改善行动 --> <!-- 我的改善行动 -->
<div class="plans-section"> <div class="plans-section">
<h4 class="section-title">📋 我的改善行动</h4> <div class="section-header">
<div v-if="actionPlans.length === 0" class="empty-state">暂无分配给你的改善行动计划</div> <span class="section-icon">📋</span>
<span class="section-title">我的改善行动</span>
<span class="section-action" @click="$router.push('/action-plans')">查看全部 </span>
</div>
<div v-if="actionPlans.length === 0" class="empty-card">
<div class="empty-icon"></div>
<div class="empty-text">暂无待办</div>
<div class="empty-hint">分配给你的行动计划将显示在这里</div>
</div>
<div v-else class="plans-list"> <div v-else class="plans-list">
<div v-for="p in actionPlans" :key="p.id" class="plan-card" :class="{ 'plan-overdue': p.overdue }"> <div v-for="p in actionPlans" :key="p.id" class="plan-card" :class="{ 'plan-overdue': p.overdue }">
<div class="plan-card-left"> <div class="plan-icon">{{ planIcon(p) }}</div>
<span class="plan-status-icon">{{ planIcon(p) }}</span> <div class="plan-body">
</div>
<div class="plan-card-body">
<div class="plan-title">{{ p.title }}</div> <div class="plan-title">{{ p.title }}</div>
<div class="plan-meta"> <div class="plan-meta">
<span class="plan-kpi" v-if="p.kpi_name">{{ p.kpi_name }}</span> <el-tag size="small" :type="planTagType(p)">{{ planLabel(p) }}</el-tag>
<el-tag size="small" :type="planTagType(p)" class="plan-status-tag">{{ planLabel(p) }}</el-tag> <span v-if="p.due_date" class="plan-due" :class="{ 'overdue-text': p.overdue }">
<span class="plan-due" v-if="p.due_date" :class="{ 'overdue-text': p.overdue }">
{{ p.overdue ? '逾期' : '截止' }} {{ p.due_date.slice(0,10) }} {{ p.overdue ? '逾期' : '截止' }} {{ p.due_date.slice(0,10) }}
</span> </span>
</div> </div>
<el-progress v-if="p.status !== 'completed'" :percentage="p.progress || 0" :stroke-width="8" /> <el-progress v-if="p.status !== 'completed'" :percentage="p.progress || 0" :stroke-width="4" />
</div> </div>
</div> </div>
</div> </div>
@@ -87,24 +120,17 @@ import { useRouter } from "vue-router"
import api from "../api/index" import api from "../api/index"
const router = useRouter() const router = useRouter()
// ── 用户信息 ──
const userStr = localStorage.getItem('cma_user') || '{}' const userStr = localStorage.getItem('cma_user') || '{}'
const user = JSON.parse(userStr) const user = JSON.parse(userStr)
const userName = ref(user.name || user.username || '用户') const userName = ref(user.name || user.username || '用户')
const roleLabel = ref({ const roleLabel = ref({ ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部' }[user.role] || user.role)
ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部'
}[user.role] || user.role)
// ── 状态 ──
const loading = ref(true) const loading = ref(true)
const kpis = ref<any[]>([]) const kpis = ref<any[]>([])
const actionPlans = ref<any[]>([]) const actionPlans = ref<any[]>([])
const reminders = ref<any[]>([]) const reminders = ref<any[]>([])
const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }) const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
// ── 工具函数 ──
function fmtValue(val: any, code: string) { function fmtValue(val: any, code: string) {
if (val == null) return '-' if (val == null) return '-'
if (typeof val === 'number') { if (typeof val === 'number') {
@@ -115,9 +141,8 @@ function fmtValue(val: any, code: string) {
} }
function planIcon(p: any) { function planIcon(p: any) {
const overdue = p.overdue
if (p.status === 'completed') return '✅' if (p.status === 'completed') return '✅'
if (overdue) return '🔴' if (p.overdue) return '🔴'
if (p.status === 'in_progress') return '🔄' if (p.status === 'in_progress') return '🔄'
return '⏳' return '⏳'
} }
@@ -140,7 +165,6 @@ function goToDetail(r: any) {
else if (r.related_type === 'action_plan') router.push('/action-plans') else if (r.related_type === 'action_plan') router.push('/action-plans')
} }
// ── 加载数据 ──
onMounted(async () => { onMounted(async () => {
try { try {
const r: any = await api.get('/dashboard/my-dashboard') const r: any = await api.get('/dashboard/my-dashboard')
@@ -156,50 +180,116 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.my-dashboard-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; } .my-dashboard-page {
.dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } padding: 24px;
.header-left { display: flex; align-items: center; gap: 12px; } max-width: 900px;
.header-left h3 { margin: 0; font-size: 20px; } margin: 0 auto;
.header-role { color: #999; font-size: 14px; } min-height: calc(100vh - 60px);
.header-right .date-text { color: #666; font-size: 14px; } }
/* ── 顶部欢迎 ── */
.welcome-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 20px 24px;
background: linear-gradient(135deg, #409eff, #337ecc);
border-radius: 12px;
color: #fff;
}
.welcome-left { display: flex; align-items: center; gap: 16px; }
.welcome-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: rgba(255,255,255,0.25);
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 600;
}
.welcome-greeting { font-size: 18px; font-weight: 600; }
.welcome-sub { font-size: 13px; opacity: 0.8; margin-top: 2px; }
.welcome-stats { display: flex; align-items: center; gap: 20px; }
.stat-item { text-align: center; }
.stat-num { font-size: 28px; font-weight: 700; line-height: 1; }
.stat-label { font-size: 12px; opacity: 0.8; margin-top: 4px; }
.stat-label.stat-red { color: #ff6b6b; }
.stat-divider { width: 1px; height: 36px; background: rgba(255,255,255,0.3); }
/* ── 通用 ── */
.section-header {
display: flex; align-items: center; gap: 8px;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.section-icon { font-size: 16px; }
.section-title { font-size: 16px; font-weight: 600; flex: 1; }
.section-badge {
background: #f56c6c; color: #fff; font-size: 11px;
padding: 0 8px; border-radius: 10px; line-height: 18px;
}
.section-action { font-size: 13px; color: #409eff; cursor: pointer; }
.section-action:hover { text-decoration: underline; }
.loading-wrap { padding: 40px; } .loading-wrap { padding: 40px; }
.section-title { font-size: 16px; margin: 0 0 12px 0; padding-bottom: 8px; border-bottom: 1px solid #eee; }
.empty-state { text-align: center; color: #999; padding: 30px; } /* ── 空状态 ── */
.empty-card {
text-align: center; padding: 40px;
background: #fafafa; border-radius: 10px;
border: 1px dashed #e0e0e0;
}
.empty-icon { font-size: 36px; margin-bottom: 8px; }
.empty-text { font-size: 15px; color: #666; }
.empty-hint { font-size: 13px; color: #bbb; margin-top: 4px; }
/* ── 待办提醒 ── */
.reminder-section { margin-bottom: 24px; } .reminder-section { margin-bottom: 24px; }
.reminder-item { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; margin-bottom: 6px; font-size: 14px; } .reminder-card {
.reminder-item.sev-danger { background: #fef0f0; border: 1px solid #fde2e2; } display: flex; align-items: center; gap: 12px;
.reminder-item.sev-warning { background: #fdf6ec; border: 1px solid #f5dab1; } padding: 12px 16px; border-radius: 10px; margin-bottom: 8px;
.reminder-icon { flex-shrink: 0; } }
.reminder-msg { flex: 1; } .reminder-card.sev-danger { background: #fef0f0; border: 1px solid #fde2e2; }
.kpi-section { margin-bottom: 24px; } .reminder-card.sev-warning { background: #fdf6ec; border: 1px solid #f5dab1; }
.kpi-list { display: flex; flex-direction: column; gap: 8px; } .reminder-left { flex-shrink: 0; }
.kpi-card { display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 10px; cursor: pointer; transition: box-shadow 0.2s; } .reminder-icon { font-size: 18px; }
.kpi-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.08); } .reminder-body { flex: 1; }
.reminder-msg { font-size: 14px; }
/* ── KPI网格 ── */
.kpi-section { margin-bottom: 28px; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.kpi-card {
padding: 16px; border-radius: 10px; cursor: pointer;
transition: transform 0.15s, box-shadow 0.2s;
}
.kpi-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
.kpi-green { background: #f0f9eb; border: 1px solid #e1f3d8; } .kpi-green { background: #f0f9eb; border: 1px solid #e1f3d8; }
.kpi-yellow { background: #fdf6ec; border: 1px solid #f5dab1; } .kpi-yellow { background: #fdf6ec; border: 1px solid #f5dab1; }
.kpi-red { background: #fef0f0; border: 1px solid #fde2e2; } .kpi-red { background: #fef0f0; border: 1px solid #fde2e2; }
.kpi-gray { background: #fafafa; border: 1px solid #eee; } .kpi-gray { background: #fafafa; border: 1px solid #eee; }
.kpi-card-left { flex-shrink: 0; } .kpi-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.kpi-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; } .kpi-name { font-size: 14px; font-weight: 600; }
.dot-green { background: #67c23a; } .kpi-tag { font-size: 11px; padding: 1px 8px; border-radius: 4px; }
.dot-yellow { background: #e6a23c; } .tag-green { background: #e1f3d8; color: #67c23a; }
.dot-red { background: #f56c6c; } .tag-yellow { background: #f5dab1; color: #e6a23c; }
.dot-gray { background: #dcdfe6; } .tag-red { background: #fde2e2; color: #f56c6c; }
.kpi-card-body { flex: 1; min-width: 0; } .tag-gray { background: #eee; color: #999; }
.kpi-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .kpi-value-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.kpi-name { font-weight: 600; font-size: 14px; } .kpi-actual { font-size: 24px; font-weight: 700; }
.kpi-code { font-size: 11px; color: #999; } .kpi-target { font-size: 12px; color: #999; }
.kpi-card-bar { margin-top: 4px; }
.plans-section { margin-bottom: 24px; } /* ── 改善行动 ── */
.plans-section { margin-bottom: 28px; }
.plans-list { display: flex; flex-direction: column; gap: 8px; } .plans-list { display: flex; flex-direction: column; gap: 8px; }
.plan-card { display: flex; gap: 12px; padding: 14px; border-radius: 10px; background: #fff; border: 1px solid #eee; } .plan-card {
display: flex; gap: 12px; padding: 14px 16px;
border-radius: 10px; background: #fff;
border: 1px solid #eee; transition: box-shadow 0.2s;
}
.plan-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.plan-card.plan-overdue { border-left: 4px solid #f56c6c; background: #fef0f0; } .plan-card.plan-overdue { border-left: 4px solid #f56c6c; background: #fef0f0; }
.plan-card-left { flex-shrink: 0; font-size: 18px; padding-top: 2px; } .plan-icon { font-size: 20px; padding-top: 2px; }
.plan-card-body { flex: 1; min-width: 0; } .plan-body { flex: 1; min-width: 0; }
.plan-title { font-weight: 500; font-size: 14px; margin-bottom: 6px; } .plan-title { font-weight: 500; font-size: 14px; margin-bottom: 6px; }
.plan-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-bottom: 6px; } .plan-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.plan-kpi { color: #409eff; } .plan-due { font-size: 12px; color: #999; }
.plan-status-tag { font-size: 11px; }
.plan-due { color: #999; }
.plan-due.overdue-text { color: #f56c6c; font-weight: 500; } .plan-due.overdue-text { color: #f56c6c; font-weight: 500; }
</style> </style>