- 新增 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
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<span class="forecast-tag" :title="title">{{ label }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 预估标签(predefined「预估」标记)
|
||||
*
|
||||
* 口径:财务裁定 2026-10-02 —— 预估行(data_status='estimated' 或 source_type='forecast')
|
||||
* 单列展示、带本标签;不得与实测相加、不得同线绘制、不得作预警依据。
|
||||
* 无 emoji(前端护栏)。
|
||||
*/
|
||||
withDefaults(defineProps<{ label?: string; title?: string }>(), {
|
||||
label: '预估',
|
||||
title: '预估值:来源 data_status=estimated / source_type=forecast,单列展示,不计入实测汇总、不进趋势与预警',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forecast-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
color: #b88230;
|
||||
background: #fdf6ec;
|
||||
border: 1px solid #f5dab1;
|
||||
}
|
||||
</style>
|
||||
@@ -93,6 +93,11 @@
|
||||
<span class="kpi-row-val">{{ fmtValue(k.actual_value, k.kpi_name) }}</span>
|
||||
<span class="kpi-row-target">/ {{ k.target_value ?? '-' }}</span>
|
||||
</div>
|
||||
<!-- 预估区(单列,不与上面的实测值相加) -->
|
||||
<div v-if="k.forecast_value != null" class="kpi-row-forecast">
|
||||
<ForecastTag />
|
||||
<span class="kpi-row-fc-val">{{ fmtValue(k.forecast_value, k.kpi_name) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +129,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预估区(单列展示:不与实测相加、不进趋势序列、不作预警依据) -->
|
||||
<div v-if="forecastList.length > 0" class="right-card forecast-card">
|
||||
<div class="right-card-title">预估区(不计入实测)</div>
|
||||
<div class="forecast-note">以下为预估值(data_status=estimated / source_type=forecast),单列展示</div>
|
||||
<div v-for="f in forecastList" :key="f.kpi_code + '|' + f.period" class="forecast-row">
|
||||
<span class="forecast-name">{{ f.kpi_name }}</span>
|
||||
<span class="forecast-period">{{ f.period }}</span>
|
||||
<span class="forecast-val">{{ fmtValue(f.value, f.kpi_code) }}</span>
|
||||
<ForecastTag />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,6 +166,11 @@
|
||||
<span class="item-unit">{{ k.unit }}</span>
|
||||
</div>
|
||||
<div class="card-item-target">目标 {{ k.target_value ?? '-' }}</div>
|
||||
<!-- 预估区(单列,不与上面的实测值相加) -->
|
||||
<div v-if="k.forecast_value != null" class="card-item-forecast">
|
||||
<ForecastTag />
|
||||
<span class="card-item-fc-val">{{ fmtValue(k.forecast_value, k.kpi_name) }}</span>
|
||||
</div>
|
||||
<div class="card-item-trend" v-if="k.trend && k.trend.length >= 2">
|
||||
<span v-for="(t, i) in k.trend.slice(-3)" :key="i" class="tdot" :style="{ background: trendColor(t.value, k) }"></span>
|
||||
<span class="trend-hint">近3月</span>
|
||||
@@ -185,6 +207,21 @@
|
||||
<div class="tbar-label">{{ t.period.slice(-2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 预估区(单列;不进上方趋势条,趋势序列仅实测区) -->
|
||||
<div v-if="k.forecast_value != null" class="card-item-forecast">
|
||||
<ForecastTag />
|
||||
<span class="card-item-fc-val">{{ fmtValue(k.forecast_value, k.kpi_code) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 预估区(单列展示:不与实测相加、不进趋势序列) -->
|
||||
<div v-if="forecastList.length > 0" class="forecast-block">
|
||||
<div class="forecast-block-title">预估区(不计入实测)</div>
|
||||
<div v-for="f in forecastList" :key="f.kpi_code + '|' + f.period" class="forecast-row">
|
||||
<span class="forecast-name">{{ f.kpi_name }}</span>
|
||||
<span class="forecast-period">{{ f.period }}</span>
|
||||
<span class="forecast-val">{{ fmtValue(f.value, f.kpi_code) }}</span>
|
||||
<ForecastTag />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -277,6 +314,8 @@ import CountUp from '../components/CountUp.vue'
|
||||
import GrowthQuality from '../components/GrowthQuality.vue'
|
||||
import BEPBreakEvenCard from '../components/BEPBreakEvenCard.vue'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线)
|
||||
import ForecastTag from '../components/ForecastTag.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -327,6 +366,8 @@ const yellowKpis = computed(() => kpis.value.filter((k: any) => k.alert_level ==
|
||||
const myKpis = ref<any[]>([])
|
||||
const finKpis = ref<any[]>([])
|
||||
const finSummary = ref<any>({})
|
||||
// 预估区(单列,不计入实测汇总;财务裁定 2026-10-02)
|
||||
const forecastList = ref<any[]>([])
|
||||
const enabledChannels = ref(0)
|
||||
const aiAnalysis = ref('')
|
||||
const aiLoading = ref(false)
|
||||
@@ -456,25 +497,29 @@ async function loadData() {
|
||||
try {
|
||||
const entityId = Number(localStorage.getItem('cma_entity_id') || 1)
|
||||
const params = { ...getPeriodParams(), entity_id: entityId }
|
||||
// include_forecast=true:预估行**单列返回**(forecast 字段),实测取值不受影响
|
||||
if (userRole.value === 'ceo') {
|
||||
const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([
|
||||
dashboardApi.kpis({ role: 'ceo', ...params }),
|
||||
dashboardApi.kpis({ role: 'ceo', ...params, include_forecast: true }),
|
||||
alertApi.list({ limit: 10, entity_id: entityId }),
|
||||
dashboardApi.summary({ role: 'ceo', ...params }),
|
||||
dashboardApi.predict({ entity_id: entityId }).catch(() => ({ data: [] })),
|
||||
])
|
||||
kpis.value = (kpiRes as any).data || []
|
||||
forecastList.value = (kpiRes as any).forecast || []
|
||||
alerts.value = (alertRes as any).data?.filter((a: any) => a.status === 'pending') || []
|
||||
summary.value = { kpi_total: (sumRes as any).kpi_total || 0, alert_count: (sumRes as any).alert_count || 0 }
|
||||
predictions.value = (predRes as any)?.predictions || []
|
||||
dataRange.value = (kpiRes as any).range?.start + ' ~ ' + (kpiRes as any).range?.end
|
||||
} else if (userRole.value === 'business') {
|
||||
const r: any = await dashboardApi.myKpis?.({ ...params })
|
||||
const r: any = await dashboardApi.myKpis?.({ ...params, include_forecast: true })
|
||||
myKpis.value = (r as any)?.data || []
|
||||
forecastList.value = (r as any)?.forecast || []
|
||||
dataRange.value = (r as any)?.period || ''
|
||||
} else if (userRole.value === 'finance') {
|
||||
const r: any = await dashboardApi.financeAnalysis?.({ ...params })
|
||||
const r: any = await dashboardApi.financeAnalysis?.({ ...params, include_forecast: true })
|
||||
finKpis.value = (r as any)?.kpis || []
|
||||
forecastList.value = (r as any)?.forecast || []
|
||||
finSummary.value = (r as any)?.summary || {}
|
||||
dataRange.value = (r as any)?.period || ''
|
||||
} else if (userRole.value === 'it') {
|
||||
@@ -546,6 +591,9 @@ onMounted(() => { loadData() })
|
||||
.kpi-row-btm { display:flex; align-items:baseline; gap:4px; }
|
||||
.kpi-row-val { font-size:18px; font-weight:700; color:#1a1a2e; }
|
||||
.kpi-row-target { font-size:12px; color:#aaa; }
|
||||
/* 预估区(单列,不与实测值相加;财务裁定 2026-10-02) */
|
||||
.kpi-row-forecast { display:flex; align-items:center; gap:4px; margin-top:2px; }
|
||||
.kpi-row-fc-val { font-size:12px; color:#b88230; }
|
||||
|
||||
/* 右侧预测 */
|
||||
.right-card { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border:1px solid #f0f0f0; }
|
||||
@@ -581,6 +629,18 @@ onMounted(() => { loadData() })
|
||||
.card-item-val { font-size:26px; font-weight:700; margin-bottom:4px; }
|
||||
.item-unit { font-size:13px; color:#999; font-weight:400; margin-left:4px; }
|
||||
.card-item-target { font-size:12px; color:#aaa; margin-bottom:6px; }
|
||||
/* 预估区(单列:卡片内独立一行 + 「预估」标签,不与实测相加/同线) */
|
||||
.card-item-forecast { display:flex; align-items:center; gap:4px; margin-top:2px; }
|
||||
.card-item-fc-val { font-size:12px; color:#b88230; }
|
||||
.forecast-card { margin-top:16px; }
|
||||
.forecast-note { font-size:12px; color:#999; margin:-8px 0 10px; }
|
||||
.forecast-block { margin-top:16px; background:#fff; border:1px solid #f0f0f0; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
|
||||
.forecast-block-title { font-size:15px; font-weight:600; margin-bottom:10px; }
|
||||
.forecast-row { display:flex; align-items:center; gap:8px; padding:6px 0; border-top:1px dashed #f2f2f2; }
|
||||
.forecast-row:first-of-type { border-top:none; }
|
||||
.forecast-name { flex:1; font-size:13px; color:#333; }
|
||||
.forecast-period { font-size:12px; color:#999; }
|
||||
.forecast-val { font-size:13px; font-weight:600; color:#b88230; }
|
||||
.card-item-trend { display:flex; align-items:center; gap:4px; }
|
||||
.tdot { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||
.trend-hint { font-size:10px; color:#ccc; margin-left:2px; }
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
: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>
|
||||
@@ -128,6 +133,8 @@
|
||||
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), 不改变现有展示次序
|
||||
@@ -146,6 +153,8 @@ 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) {
|
||||
@@ -224,8 +233,10 @@ function goToDetail(r: any) {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r: any = await api.get('/dashboard/my-dashboard')
|
||||
// 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) {
|
||||
@@ -342,6 +353,9 @@ onMounted(async () => {
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user