Files
cma-management/frontend/src/views/MyDashboard.vue
T
Hermes CI Fix 5d7b3cc728
ci/woodpecker/push/main Pipeline failed
feat(h2-forecast): 实测区/预估区口径分离 —— 对外读路径默认只取实测区
- 新增 app/utils/kpi_value_scope.py(口径单一事实源:actual_only_clause / forecast_only_clause / is_forecast_row)
- dashboard.py: /kpis /my-kpis /finance-analysis /predict /my-dashboard / latest_kpi_value 默认剔预估,include_forecast=true 单列返回
- alert_rules.py: 动态阈值白名单去 estimated + 4 处读路径加实测区判据
- 前端 ForecastTag.vue 新增;Dashboard.vue / MyDashboard.vue 预估单列 + 标签(不与实测相加)

Task: finance-20261002-h2-forecast-separation-deploy-fullstack
2026-10-02 23:33:15 +08:00

377 lines
16 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">
<!-- 待关注提醒(行动逾期 + KPI预警) -->
<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 sortedReminders" :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>
<el-tag size="small" :type="r.related_type === 'action_plan' ? 'primary' : 'warning'" class="reminder-tag">{{ r.related_type === 'action_plan' ? '行动' : '指标' }}</el-tag>
</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 v-if="dataAsOf" class="section-asof">数据截至 {{ dataAsOf }}</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" :class="{ 'kpi-actual-empty': k.actual_value == null, 'kpi-actual-stale': isStale(k) }">
{{ k.actual_value == null ? '暂无数据' : periodActual(k) }}
</div>
<div class="kpi-target">
<span v-if="isStale(k)" class="kpi-stale-tag">非当期</span>{{ targetLabel(k) }} {{ k.target_value ?? '-' }}
</div>
</div>
<!-- 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual 缺失时不得显示 0% 进度 -->
<el-progress
v-if="k.actual_value != null && k.target_value"
:percentage="Math.min(100, Math.round(k.actual_value / k.target_value * 100))"
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
:stroke-width="6"
:show-text="false"
/>
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
<!-- 预估区(单列,不与上方实测值相加;财务裁定 2026-10-02) -->
<div v-if="k.forecast_value != null" class="kpi-forecast-row">
<ForecastTag />
<span class="kpi-forecast-val">{{ k.forecast_value }}</span>
</div>
</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 }"
@click="$router.push('/action-plans?plan_id=' + p.id)">
<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, computed, onMounted } from "vue"
import { useRouter } from "vue-router"
import api from "../api/index"
// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线)
import ForecastTag from "../components/ForecastTag.vue"
// 排序固化: danger(逾期行动/红色预警) 在前, warning(黄色预警) 在后;
// 同 severity 保持后端生成顺序 (overdue→red→yellow), 不改变现有展示次序
const severityOrder: Record<string, number> = { danger: 0, warning: 1 }
const sortedReminders = computed(() =>
[...reminders.value].sort((a: any, b: any) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2))
)
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[]>([])
// 预估区(单列,不计入卡片实测值;财务裁定 2026-10-02)
const forecastList = 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
}
// 实际值期间格式化: KPIValue.period 为 'YYYY-MM',本年显示 '8月',跨年显示 '2025年12月'
function fmtPeriod(p: string | null | undefined): string {
if (!p) return '最近'
const m = /^(\d{4})-(\d{1,2})/.exec(String(p))
if (!m) return String(p)
const now = new Date()
const y = Number(m[1])
const mo = Number(m[2])
return (y === now.getFullYear() ? '' : y + '年') + mo + '月'
}
// 实际值带期间口径展示,如 "8月: 12,345",员工能分辨该值属于哪个采集期间
function periodActual(k: any): string {
return fmtPeriod(k.period) + ': ' + fmtValue(k.actual_value, k.kpi_code)
}
function targetLabel(k: any) {
const freq = k.frequency || 'monthly'
if (freq === 'quarterly' || freq === 'half_year') return '目标(季)'
if (freq === 'yearly') return '目标(年)'
return '目标(月)'
}
// 2026-09-18 口径小改(工作台期间缺口):月期间正则 + 页面最新可得月
const MONTH_PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/
const latestMonth = computed(() => {
const months = kpis.value
.map((k: any) => String(k.period || ''))
.filter((p: string) => MONTH_PERIOD_RE.test(p))
.sort()
return months.length ? months[months.length - 1] : ''
})
// 页头「数据截至 X 月」= 本页各卡中最新的月期间(无月期间时不出该文案,不编造时点)
const dataAsOf = computed(() => (latestMonth.value ? fmtPeriod(latestMonth.value) : ''))
// 非当期 = 该卡的值期间早于页面最新可得月(仍保留原期间标签,只加灰标提示,不隐藏)
function isStale(k: any): boolean {
const p = String(k.period || '')
return MONTH_PERIOD_RE.test(p) && !!latestMonth.value && p < latestMonth.value
}
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?plan_id=' + r.related_id)
}
onMounted(async () => {
try {
// include_forecast=true:预估行**单列返回**(forecast 字段),卡片实测值不受影响
const r: any = await api.get('/dashboard/my-dashboard', { params: { include_forecast: true } })
kpis.value = r.kpis || []
forecastList.value = r.forecast || []
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; }
/* 数据截至 X 月(2026-09-18):说明本页各卡的最新可得期间,不改渲染结构 */
.section-asof { font-size: 12px; color: #999; margin-right: 10px; }
.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; display: flex; align-items: center; gap: 6px; }
.reminder-icon { font-size: 18px; }
.reminder-tag { font-size: 11px; }
.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-actual-empty { font-size: 15px; font-weight: 500; color: #999; }
/* 非当期灰标(2026-09-18):值期间落后于页面最新可得月时置灰,期间标签照旧显示 */
.kpi-actual-stale { color: #999; }
.kpi-stale-tag {
font-size: 11px; color: #999; background: #f0f0f0;
padding: 0 6px; border-radius: 4px; margin-right: 6px;
}
.kpi-target { font-size: 12px; color: #999; }
/* 预估区(单列一行 + 「预估」标签,不与卡片实测值相加) */
.kpi-forecast-row { display: flex; align-items: center; gap: 4px; margin-top: 6px; }
.kpi-forecast-val { font-size: 12px; color: #b88230; }
/* ── 改善行动 ── */
.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; cursor: pointer;
}
.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>