Files
cma-management/frontend/src/views/MyDashboard.vue
T
Hermes CI Fix dd53212bcc feat: KPI多粒度目标值补提交 — dashboard API + 前端3视图(目标值月/季/年)
已上线未提交的历史功能(2026-08-17): kpi_target_by_frequency + target_monthly/quarterly/yearly 字段透传
2026-08-20 06:57:13 +08:00

303 lines
12 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="my-dashboard-page">
<!-- 顶部问候+概览 -->
<div class="welcome-section">
<div class="welcome-left">
<div class="welcome-avatar">{{ userName[0] }}</div>
<div>
<div class="welcome-greeting">早上好{{ userName }}</div>
<div class="welcome-sub">{{ roleLabel }} · {{ todayStr }}</div>
</div>
</div>
<div class="welcome-stats" v-if="!loading">
<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 v-if="loading" class="loading-wrap">
<el-skeleton :rows="5" animated />
</div>
<template v-if="!loading">
<!-- 待办提醒 -->
<div v-if="reminders.length > 0" class="reminder-section">
<div class="section-header">
<span class="section-icon">📌</span>
<span class="section-title">待办提醒</span>
<span class="section-badge">{{ reminders.length }}</span>
</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>
<!-- 我的KPI -->
<div class="kpi-section">
<div class="section-header">
<span class="section-icon">📊</span>
<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 class="kpi-head">
<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 class="kpi-value-row">
<div class="kpi-actual">{{ fmtValue(k.actual_value, k.kpi_code) }}</div>
<div class="kpi-target">{{ targetLabel(k) }} {{ k.target_value ?? '-' }}</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 class="plans-section">
<div class="section-header">
<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-for="p in actionPlans" :key="p.id" class="plan-card" :class="{ 'plan-overdue': p.overdue }">
<div class="plan-icon">{{ planIcon(p) }}</div>
<div class="plan-body">
<div class="plan-title">{{ p.title }}</div>
<div class="plan-meta">
<el-tag size="small" :type="planTagType(p)">{{ planLabel(p) }}</el-tag>
<span v-if="p.due_date" class="plan-due" :class="{ 'overdue-text': p.overdue }">
{{ p.overdue ? '逾期' : '截止' }} {{ p.due_date.slice(0,10) }}
</span>
</div>
<el-progress v-if="p.status !== 'completed'" :percentage="p.progress || 0" :stroke-width="4" />
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import api from "../api/index"
const router = useRouter()
const userStr = localStorage.getItem('cma_user') || '{}'
const user = JSON.parse(userStr)
const userName = ref(user.name || user.username || '用户')
const roleLabel = ref({ ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部' }[user.role] || user.role)
const loading = ref(true)
const kpis = ref<any[]>([])
const actionPlans = ref<any[]>([])
const reminders = ref<any[]>([])
const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
function fmtValue(val: any, code: string) {
if (val == null) return '-'
if (typeof val === 'number') {
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
return val
}
function targetLabel(k: any) {
const freq = k.frequency || 'monthly'
if (freq === 'quarterly' || freq === 'half_year') return '目标(季)'
if (freq === 'yearly') return '目标(年)'
return '目标(月)'
}
function planIcon(p: any) {
if (p.status === 'completed') return '✅'
if (p.overdue) return '🔴'
if (p.status === 'in_progress') return '🔄'
return '⏳'
}
function planLabel(p: any) {
if (p.overdue) return '逾期'
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[p.status] || p.status
}
function planTagType(p: any) {
if (p.overdue) return 'danger'
if (p.status === 'completed') return 'success'
if (p.status === 'in_progress') return 'warning'
return 'info'
}
function goToDetail(r: any) {
if (r.related_type === 'kpi') router.push('/kpis/' + r.related_id)
else if (r.related_type === 'action_plan') router.push('/action-plans')
}
onMounted(async () => {
try {
const r: any = await api.get('/dashboard/my-dashboard')
kpis.value = r.kpis || []
actionPlans.value = r.action_plans || []
reminders.value = r.reminders || []
} catch (e: any) {
console.error('Failed to load my dashboard', e)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.my-dashboard-page {
padding: 24px;
max-width: 900px;
margin: 0 auto;
min-height: calc(100vh - 60px);
}
/* ── 顶部欢迎 ── */
.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; }
/* ── 空状态 ── */
.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-card {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-radius: 10px; margin-bottom: 8px;
}
.reminder-card.sev-danger { background: #fef0f0; border: 1px solid #fde2e2; }
.reminder-card.sev-warning { background: #fdf6ec; border: 1px solid #f5dab1; }
.reminder-left { flex-shrink: 0; }
.reminder-icon { font-size: 18px; }
.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-yellow { background: #fdf6ec; border: 1px solid #f5dab1; }
.kpi-red { background: #fef0f0; border: 1px solid #fde2e2; }
.kpi-gray { background: #fafafa; border: 1px solid #eee; }
.kpi-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.kpi-name { font-size: 14px; font-weight: 600; }
.kpi-tag { font-size: 11px; padding: 1px 8px; border-radius: 4px; }
.tag-green { background: #e1f3d8; color: #67c23a; }
.tag-yellow { background: #f5dab1; color: #e6a23c; }
.tag-red { background: #fde2e2; color: #f56c6c; }
.tag-gray { background: #eee; color: #999; }
.kpi-value-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.kpi-actual { font-size: 24px; font-weight: 700; }
.kpi-target { font-size: 12px; color: #999; }
/* ── 改善行动 ── */
.plans-section { margin-bottom: 28px; }
.plans-list { display: flex; flex-direction: column; gap: 8px; }
.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-icon { font-size: 20px; padding-top: 2px; }
.plan-body { flex: 1; min-width: 0; }
.plan-title { font-weight: 500; font-size: 14px; margin-bottom: 6px; }
.plan-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.plan-due { font-size: 12px; color: #999; }
.plan-due.overdue-text { color: #f56c6c; font-weight: 500; }
</style>