Files
cma-management/frontend/src/views/Dashboard.vue
T
Hermes CI Fix ad68471b29 feat: 路线图R1决策建议一键落地+R2机会推送+R5预算闭环
R1(P0): AI建议一键应用到KPI/预算/行动方案
- 新表 ai_suggestions + AISuggestion 模型(init_db自动建)
- /api/cma/ai/suggestions CRUD + /{id}/apply(复用kpis/budget/action_plans) + dismiss
- 应用写 OperationLog(action=ai_suggestion_apply, detail含suggestion_id/before/after)
- 规则驱动建议生成 generate_rule_suggestions(低执行率/高执行率/预算超支/pending预警)
- 幂等: 同entity+type+target_id+title+unapplied不重复建; applied后拒绝重复应用
- 前端: Dashboard AI面板建议卡(应用到/忽略) + 建议中心页 /ai-suggestions

R2(P1): 数据找人扩大-机会类推送
- scripts/opportunity_detector.py: KPI向好(执行率>110%)/预算余量(<70%且actual>0)/预测上行
- scripts/daily_push.py: 异常+机会 每日9:15推企微(8800/send, --dry-run调试)
- crontab: 15 9 * * * (alert_generator 9:00之后)

R5(P0): 预算闭环加固
- auto-decompose批量幂等: 只取年度行(period=YYYY-00)+同KPI多版本取一行
- scripts/closed_loop_check.py: 预算执行率异常→检查现金流/行动同步→缺失提示+报告
- scripts/verify_decompose_idempotent.py: 幂等验证脚本

测试: test_ai_suggestions(10例)+test_roadmap_r2r5(14例); 修test_budget幂等契约适配年度行
全量: 673 passed
2026-08-30 12:05:36 +08:00

744 lines
37 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="dashboard-page">
<!-- 顶部导航条 -->
<div class="dash-header">
<div class="header-left">
<h3 class="page-title">{{ roleLabel }}经营看板</h3>
<el-radio-group v-model="periodType" size="small" @change="onPeriodChange">
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="quarter">本季</el-radio-button>
<el-radio-button value="year">本年</el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="periodType === 'custom'"
v-model="customStart"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
size="small"
style="width:140px"
@change="onCustomRangeChange"
/>
<span v-if="periodType === 'custom'" style="color:#999;"></span>
<el-date-picker
v-if="periodType === 'custom'"
v-model="customEnd"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
size="small"
style="width:140px"
@change="onCustomRangeChange"
/>
</div>
<div class="header-right">
<span v-if="dataRange" class="data-range">📅 {{ dataRange }}</span>
<el-button text size="small" @click="showSidebar = !showSidebar; if(showSidebar) refreshAnalysis()">
{{ showSidebar ? '收起AI' : '🤖 AI分析' }}
</el-button>
</div>
</div>
<!-- ====== CEO视图 ====== -->
<template v-if="userRole === 'ceo'">
<!-- 摘要卡片行 -->
<div class="summary-row">
<div class="stat-card blue" @click="$router.push('/kpis')" style="cursor:pointer;"><div class="stat-val"><CountUp :value="summary.kpi_total" /></div><div class="stat-label">KPI总数</div><div class="stat-sub click-hint">点击查看详情</div></div>
<div class="stat-card red" @click="$router.push('/alerts')" style="cursor:pointer;"><div class="stat-val"><CountUp :value="redAlerts" /></div><div class="stat-label">紧急异常</div><div class="stat-sub">待处理 {{ alerts.length }} </div></div>
<div class="stat-card green" @click="$router.push('/kpis')" style="cursor:pointer;"><div class="stat-val"><CountUp :value="greenKpis" /></div><div class="stat-label">正常</div></div>
<div class="stat-card yellow" @click="$router.push('/alerts')" style="cursor:pointer;"><div class="stat-val"><CountUp :value="yellowKpis" /></div><div class="stat-label">预警中</div></div>
<div class="stat-card" :class="syncCardClass" @click="$router.push('/data')" style="cursor:pointer;">
<div class="stat-val" style="font-size:14px;line-height:1.4;">{{ syncStatusText }}</div>
<div class="stat-label">数据同步</div>
<div class="stat-sub">{{ syncTimeText }}</div>
</div>
</div>
<!-- 主内容区双栏 -->
<div class="ceo-main">
<!-- 左栏 -->
<div class="ceo-left">
<!-- 异常高亮区 -->
<div v-if="alerts.length > 0" class="alert-spotlight">
<div class="spotlight-title">🔴 需要关注的异常</div>
<div class="spotlight-list">
<div v-for="a in alerts" :key="a.id" class="spotlight-item"
:class="'level-' + a.alert_level"
@click="$router.push('/alerts')">
<span class="spotlight-badge">{{ a.alert_level === 'red' ? '紧急' : '预警' }}</span>
<span class="spotlight-msg">{{ a.alert_message }}</span>
<el-icon><ArrowRight /></el-icon>
</div>
</div>
</div>
<!-- KPI矩阵 四维度网格 -->
<div class="matrix-grid">
<div v-for="dim in kpiByDim" :key="dim.name" class="dim-card">
<div class="dim-head" :style="{ background: dim.color }">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-name">{{ dim.name }}</span>
<span class="dim-count">{{ dim.kpis.length }}</span>
</div>
<div class="dim-body">
<div v-for="k in dim.kpis" :key="k.id" class="kpi-row" @click="showKPIDetail(k)">
<div class="kpi-row-top">
<span class="kpi-row-name">{{ k.kpi_name }}</span>
<span class="kpi-row-badge" :style="{ color: alertColor(k.alert_level) }">{{ alertIcon(k.alert_level) }}</span>
</div>
<div class="kpi-row-btm">
<span class="kpi-row-val">{{ fmtValue(k.actual_value, k.kpi_name) }}</span>
<span class="kpi-row-target">/ {{ k.target_value ?? '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右栏 -->
<div class="ceo-right">
<!-- 预测卡片 -->
<div v-if="predictions.length > 0" class="right-card">
<div class="right-card-title">🔮 下月预测</div>
<div class="pred-list">
<div v-for="p in predictions" :key="p.kpi_id" class="pred-row" :class="'trend-' + p.trend"
@click="$router.push('/predict')" style="cursor:pointer;">
<div class="pred-row-top">
<span class="pred-name">{{ p.kpi_name }}</span>
<span class="pred-arrow">{{ {up:'↑',down:'↓',stable:'→'}[p.trend] }}</span>
</div>
<div class="pred-row-val">
<span class="pred-cur">{{ fmtValue(p.last_value, p.kpi_code) }}</span>
<span class="pred-sep"></span>
<span class="pred-fut" :style="{ color: p.trend === 'down' ? 'var(--bsc-finance)' : 'var(--bsc-process)' }">
{{ fmtValue(p.predicted_value, p.kpi_code) }}
</span>
</div>
<div class="pred-row-info">
<span class="pred-conf" :class="p.confidence">{{ {high:'高',medium:'中',low:'低'}[p.confidence] }}置信</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 增长质量诊断 -->
<GrowthQuality />
</template>
<!-- ====== 业务视图 ====== -->
<template v-if="userRole === 'business'">
<div class="summary-row">
<div class="stat-card blue"><div class="stat-val">{{ myKpis.length }}</div><div class="stat-label">我的KPI</div></div>
<div class="stat-card red"><div class="stat-val">{{ myKpis.filter((k:any)=>k.alert_level==='red').length }}</div><div class="stat-label">需关注</div></div>
</div>
<div v-if="myKpis.length === 0" class="empty-state">暂无关联到你的KPI</div>
<div v-else class="kpi-card-grid">
<div v-for="k in myKpis" :key="k.id" class="kpi-card-item" @click="showKPIDetail(k)">
<div class="card-item-head">
<span class="item-name">{{ k.kpi_name }}</span>
<el-tag size="small" :type="alertType(k.alert_level)">{{ alertIcon(k.alert_level) }}</el-tag>
</div>
<div class="card-item-val" :style="{ color: alertColor(k.alert_level) }">
{{ fmtValue(k.actual_value, k.kpi_name) }}
<span class="item-unit">{{ k.unit }}</span>
</div>
<div class="card-item-target">目标 {{ k.target_value ?? '-' }}</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>
</div>
</div>
</div>
</template>
<!-- ====== 财务视图 ====== -->
<template v-if="userRole === 'finance'">
<div class="summary-row">
<div class="stat-card blue" @click="$router.push('/deviations')" style="cursor:pointer;"><div class="stat-val">{{ finSummary.total_sales ? fmtValue(finSummary.total_sales, 'SALES_TOTAL') : '-' }}</div><div class="stat-label">本月销售额</div></div>
<div class="stat-card" :class="(finSummary.gross_profit_rate ?? 0) < 0 ? 'red' : 'green'" @click="$router.push('/deviations')" style="cursor:pointer;">
<div class="stat-val">{{ finSummary.gross_profit_rate != null ? finSummary.gross_profit_rate.toFixed(1) + '%' : '-' }}</div>
<div class="stat-label">毛利率</div>
</div>
<div class="stat-card yellow" @click="$router.push('/deviations')" style="cursor:pointer;"><div class="stat-val">{{ finSummary.receivable_turnover != null ? finSummary.receivable_turnover.toFixed(2) : '-' }}</div><div class="stat-label">应收周转率</div></div>
</div>
<div class="kpi-card-grid">
<div v-for="k in finKpis" :key="k.id" class="kpi-card-item wide" @click="showKPIDetail(k)">
<div class="card-item-head">
<span class="item-name">{{ k.kpi_name }}</span>
<el-tag size="small" :type="alertType(k.alert_level)">{{ alertIcon(k.alert_level) }}</el-tag>
</div>
<div class="card-item-val" :style="{ color: alertColor(k.alert_level) }">
{{ k.actual_value != null ? fmtValue(k.actual_value, k.kpi_code) : '-' }}
<span class="item-unit">{{ k.unit }}</span>
</div>
<div class="trend-bars" v-if="k.trend && k.trend.length >= 2">
<div v-for="(t, i) in k.trend" :key="i" class="tbar-wrap" :title="t.period + ': ' + (t.value ?? '-')">
<div class="tbar" :style="{ height: barHeight(t.value, k.trend) + '%' }"></div>
<div class="tbar-label">{{ t.period.slice(-2) }}</div>
</div>
</div>
</div>
</div>
</template>
<!-- ====== IT视图 ====== -->
<template v-if="userRole === 'it'">
<div class="summary-row">
<div class="stat-card blue"><div class="stat-val"><CountUp :value="summary.kpi_total" /></div><div class="stat-label">活跃KPI</div></div>
<div class="stat-card green"><div class="stat-val">{{ enabledChannels }}</div><div class="stat-label">通知渠道</div></div>
<div class="stat-card gray"><div class="stat-val">{{ erpStatus }}</div><div class="stat-label">ERP同步</div></div>
</div>
<el-card shadow="never" class="sys-card">
<template #header><span>⚙️ 系统概览</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="后端服务">{{ backendStatus }}</el-descriptions-item>
<el-descriptions-item label="数据库">{{ dbStatus }}</el-descriptions-item>
<el-descriptions-item label="ERP网关">{{ erpGatewayStatus }}</el-descriptions-item>
<el-descriptions-item label="定时任务">每日1:00</el-descriptions-item>
<el-descriptions-item label="预警推送">企微应用</el-descriptions-item>
<el-descriptions-item label="Redis">{{ redisStatus }}</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<!-- AI分析浮窗R1AI建议一键落地 -->
<div v-if="showSidebar" class="ai-panel">
<div class="ai-head">
<span>🤖 AI 分析</span>
<el-button text size="small" @click="router.push('/ai-suggestions')">建议中心</el-button>
<el-button text size="small" @click="showSidebar = false">✕</el-button>
</div>
<div class="ai-body">
<div class="ai-section-title">📋 决策建议</div>
<div v-if="suggestionsLoading" class="ai-loading"><el-icon class="is-loading" :size="16"><Loading /></el-icon><p>加载建议...</p></div>
<div v-else-if="suggestions.length === 0" class="ai-empty">暂无待应用建议</div>
<div v-else class="sug-list">
<div v-for="s in suggestions" :key="s.id" class="sug-card" :class="'type-' + s.suggestion_type">
<div class="sug-top">
<el-tag size="small" :type="sugTagType(s.suggestion_type)">{{ sugTypeLabel(s.suggestion_type) }}</el-tag>
<el-tag v-if="s.status === 'applied'" size="small" type="success">已应用</el-tag>
<el-tag v-else size="small" type="info">未应用</el-tag>
</div>
<div class="sug-title">{{ s.title }}</div>
<div class="sug-content">{{ s.content }}</div>
<div class="sug-foot">
<el-button v-if="s.status === 'unapplied'" size="small" type="primary" @click="openApplyDialog(s)">应用到</el-button>
<el-button v-if="s.status === 'unapplied'" size="small" @click="dismissSuggestion(s)">忽略</el-button>
<span v-if="s.status === 'applied'" class="sug-applied-by"> {{ s.applied_by }} {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
</div>
</div>
</div>
<el-divider />
<div class="ai-section-title">💡 AI分析文本</div>
<div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div>
<div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div>
<el-empty v-else description="暂无分析" />
</div>
<div class="ai-foot">
<el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新分析</el-button>
</div>
</div>
<!-- 应用建议弹窗 -->
<el-dialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" width="520px">
<el-form label-width="100px">
<template v-if="applySug?.suggestion_type === 'kpi_target'">
<el-form-item label="KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
<el-form-item label="新目标值" required>
<el-input-number v-model="applyForm.target_value" :precision="2" style="width:220px" />
</el-form-item>
<div class="apply-tip">应用后将修改KPI目标值并写入操作日志留痕</div>
</template>
<template v-else-if="applySug?.suggestion_type === 'budget_adjust'">
<el-form-item label="KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
<el-form-item label="期间" required>
<el-input v-model="applyForm.period" placeholder="2026-09" style="width:220px" />
</el-form-item>
<el-form-item label="预算值" required>
<el-input-number v-model="applyForm.budget_value" :precision="2" style="width:220px" />
</el-form-item>
<div class="apply-tip">应用后将新增/更新该KPI对应期间的预算并写入操作日志留痕</div>
</template>
<template v-else>
<el-form-item label="关联KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
<el-form-item label="计划标题" required><el-input v-model="applyForm.title" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="applyForm.assignee" /></el-form-item>
<el-form-item label="优先级">
<el-select v-model="applyForm.priority" style="width:220px">
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="applyForm.due_date" type="date" value-format="YYYY-MM-DD" style="width:220px" />
</el-form-item>
<div class="apply-tip">应用后将创建行动方案并写入操作日志留痕</div>
</template>
</el-form>
<template #footer>
<el-button @click="showApplyDialog = false">取消</el-button>
<el-button type="primary" :loading="applying" @click="submitApply">确认应用</el-button>
</template>
</el-dialog>
<!-- KPI详情弹窗 -->
<el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px">
<div v-if="selectedKPI">
<div class="dlg-summary">
<el-tag :type="alertType(selectedKPI.alert_level)" size="large">
{{ periodType === 'month' ? '本月' : periodType }}: {{ fmtValue(selectedKPI.actual_value, selectedKPI.kpi_name) }}
</el-tag>
<span>目标: {{ selectedKPI.target_value }} | {{ selectedKPI.responsible_dept || '' }}</span>
</div>
<div class="kpi-bar large" v-if="selectedKPI.target_value && selectedKPI.actual_value">
<div class="bar-fill" :style="{ width: Math.min(100, selectedKPI.actual_value/selectedKPI.target_value*100)+'%', background: alertColor(selectedKPI.alert_level) }"></div>
</div>
<el-divider />
<div v-if="selectedKPIDetail" class="analysis-content" v-html="renderMd(selectedKPIDetail)"></div>
<el-button v-else type="primary" plain @click="loadKPIAnalysis" :loading="loadingDetail">AI分析此KPI</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading, ArrowRight } from '@element-plus/icons-vue'
import { dashboardApi, alertApi, aiSuggestionApi } from '../api/index'
import axios from 'axios'
import CountUp from '../components/CountUp.vue'
import GrowthQuality from '../components/GrowthQuality.vue'
const router = useRouter()
const userStr = localStorage.getItem('cma_user') || '{}'
const user = JSON.parse(userStr)
const userRole = ref(user.role || 'ceo')
const roleTagType = computed(() => ({ ceo: 'danger', finance: 'warning', business: 'primary', it: 'info' }[userRole.value] || ''))
const roleLabel = computed(() => ({ ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部' }[userRole.value] || userRole.value))
const periodType = ref('month')
const customStart = ref('')
const customEnd = ref('')
const dataRange = ref('')
const showSidebar = ref(false)
const showDetail = ref(false)
const selectedKPI = ref<any>(null)
const selectedKPIDetail = ref('')
const loadingDetail = ref(false)
const predictions = ref<any[]>([])
const kpis = ref<any[]>([])
const alerts = ref<any[]>([])
const summary = ref({ kpi_total: 0, alert_count: 0, sync_status: { status: 'unknown', last_sync: null, detail: '' } })
// 同步状态卡片样式和文字
const syncCardClass = computed(() => {
const s = summary.value.sync_status?.status
if (s === 'success') return 'green'
if (s === 'failed') return 'red'
return 'blue'
})
const syncStatusText = computed(() => {
const s = summary.value.sync_status?.status
if (s === 'success') return '✅ 同步正常'
if (s === 'failed') return '❌ 同步失败'
return '⏳ 待同步'
})
const syncTimeText = computed(() => {
const raw = summary.value.sync_status?.last_sync || ''
// 从日志行提取时间 2026-05-27 01:00:02
const m = raw.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/)
if (m) return m[1]
return raw.substring(0, 20) || '--'
})
const redAlerts = computed(() => alerts.value.filter((a: any) => a.alert_level === 'red').length)
const greenKpis = computed(() => kpis.value.filter((k: any) => k.alert_level === 'green' || k.alert_level === 'none').length)
const yellowKpis = computed(() => kpis.value.filter((k: any) => k.alert_level === 'yellow' || k.alert_level === 'red').length)
const myKpis = ref<any[]>([])
const finKpis = ref<any[]>([])
const finSummary = ref<any>({})
const enabledChannels = ref(0)
const aiAnalysis = ref('')
const aiLoading = ref(false)
// R1: AI决策建议(建议卡 + 应用到弹窗)
const suggestions = ref<any[]>([])
const suggestionsLoading = ref(false)
const showApplyDialog = ref(false)
const applySug = ref<any>(null)
const applyForm = ref<any>({})
const applying = ref(false)
const applyKpiName = ref('')
const sugTypeLabel = (t: string) => ({ kpi_target: '改KPI目标', budget_adjust: '调预算', action_plan: '建行动方案' }[t] || t)
const sugTagType = (t: string) => ({ kpi_target: 'warning', budget_adjust: 'danger', action_plan: 'primary' }[t] || 'info')
async function loadSuggestions() {
suggestionsLoading.value = true
try {
const r: any = await aiSuggestionApi.list({ status: 'unapplied' })
suggestions.value = (r as any)?.data || []
} catch { suggestions.value = [] }
suggestionsLoading.value = false
}
function openApplyDialog(s: any) {
applySug.value = s
const sd = s.suggestion_data || {}
applyForm.value = {
target_value: sd.target_value ?? null,
period: sd.period || '',
budget_value: sd.budget_value ?? null,
title: sd.title || '',
assignee: sd.assignee || '',
priority: sd.priority || 'medium',
due_date: sd.due_date || '',
}
applyKpiName.value = s.target_name || ''
showApplyDialog.value = true
}
async function submitApply() {
if (!applySug.value) return
const action = applySug.value.suggestion_type
const body: any = { action, kpi_id: applySug.value.target_id || applySug.value.suggestion_data?.kpi_id }
if (action === 'kpi_target') {
if (applyForm.value.target_value == null) { ElMessage.warning('请输入新目标值'); return }
body.target_value = applyForm.value.target_value
} else if (action === 'budget_adjust') {
if (!applyForm.value.period || applyForm.value.budget_value == null) { ElMessage.warning('请填写期间和预算值'); return }
body.period = applyForm.value.period
body.budget_value = applyForm.value.budget_value
} else {
if (!applyForm.value.title) { ElMessage.warning('请输入计划标题'); return }
body.title = applyForm.value.title
body.assignee = applyForm.value.assignee
body.priority = applyForm.value.priority
body.due_date = applyForm.value.due_date
}
applying.value = true
try {
await aiSuggestionApi.apply(applySug.value.id, body)
ElMessage.success('建议已应用并留痕')
showApplyDialog.value = false
await loadSuggestions()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '应用失败')
}
applying.value = false
}
async function dismissSuggestion(s: any) {
try {
await aiSuggestionApi.dismiss(s.id)
ElMessage.success('建议已忽略')
await loadSuggestions()
} catch { ElMessage.error('忽略失败') }
}
const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 })
aiApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('cma_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
const dimConfig: Record<string, { icon: string; color: string; name: string }> = {
finance: { icon: '💰', color: '#409eff', name: '财务维度' },
customer: { icon: '👥', color: '#67c23a', name: '客户维度' },
process: { icon: '⚙️', color: '#e6a23c', name: '内部流程' },
learning: { icon: '📚', color: '#f56c6c', name: '学习成长' },
}
const kpiByDim = computed(() => {
const dims: Record<string, any[]> = {}
kpis.value.forEach((k: any) => {
const d = k.dimension || 'other'
if (!dims[d]) dims[d] = []
dims[d].push(k)
})
return Object.entries(dims).map(([key, kpis]) => ({
name: dimConfig[key]?.name || key,
icon: dimConfig[key]?.icon || '📊',
color: dimConfig[key]?.color || '#909399',
kpis,
}))
})
function fmtValue(v: any, tag?: string): string {
if (v == null) return '-'
const n = Number(v)
if (isNaN(n)) return String(v)
if (tag === 'SALES_TOTAL') return '¥' + (n / 10000).toFixed(0) + '万'
if (tag && (tag.includes('率') || tag.includes('RATE') || tag.includes('ratio') || tag.includes('RATIO'))) return n.toFixed(1) + '%'
if (Math.abs(n) >= 10000) return (n / 10000).toFixed(1) + '万'
if (Number.isInteger(n)) return n.toLocaleString()
return n.toFixed(2)
}
function alertType(level: string): string {
return level === 'red' ? 'danger' : level === 'yellow' ? 'warning' : level === 'green' ? 'success' : 'info'
}
function alertIcon(level: string): string {
return level === 'red' ? '🔴' : level === 'yellow' ? '🟡' : level === 'green' ? '🟢' : '⚪'
}
function alertColor(level: string): string {
return level === 'red' ? '#f56c6c' : level === 'yellow' ? '#e6a23c' : level === 'green' ? '#67c23a' : '#909399'
}
function trendColor(v: any, kpi: any): string {
if (v == null) return '#ddd'
if (kpi.threshold_red) {
const threshold = parseFloat(kpi.threshold_red.replace(/[<>=]/g, ''))
return v < threshold ? '#f56c6c' : '#67c23a'
}
return '#909399'
}
function barHeight(v: any, trend: any[]): number {
if (v == null || trend.length === 0) return 0
const values = trend.filter(t => t.value != null).map(t => Math.abs(t.value))
if (values.length === 0) return 0
return Math.max(...values) > 0 ? (Math.abs(v) / Math.max(...values)) * 80 : 0
}
function renderMd(text: string): string {
if (!text) return ''
return text.replace(/### (.+)/g, '<h4 style="margin:12px 0 6px">$1</h4>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>')
}
function showKPIDetail(k: any) {
selectedKPI.value = k
selectedKPIDetail.value = ''
showDetail.value = true
}
async function loadKPIAnalysis() {
if (!selectedKPI.value?.id) return
loadingDetail.value = true
try {
const r: any = await aiApi.get(`/ai/kpi-analysis/${selectedKPI.value.id}`)
selectedKPIDetail.value = r.data?.analysis || r.analysis || '暂无分析结果'
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
loadingDetail.value = false
}
async function refreshAnalysis() {
aiLoading.value = true
try {
const r: any = await aiApi.get('/ai/dashboard-analysis', { params: { role: userRole.value } })
aiAnalysis.value = r.data?.analysis || r.analysis || '暂无分析'
// 建议列表从服务端拉取(含dashboard分析自动生成的规则建议)
await loadSuggestions()
} catch { aiAnalysis.value = 'AI分析暂时不可用' }
aiLoading.value = false
}
// 构建请求参数
function getPeriodParams(): any {
if (periodType.value === 'custom' && customStart.value && customEnd.value) {
return { period: 'custom', start_date: customStart.value, end_date: customEnd.value }
}
return { period: periodType.value }
}
function onPeriodChange(val: string) {
if (val !== 'custom') loadData()
}
function onCustomRangeChange(val: any) {
if (customStart.value && customEnd.value) loadData()
}
async function loadData() {
try {
const entityId = Number(localStorage.getItem('cma_entity_id') || 1)
const params = { ...getPeriodParams(), entity_id: entityId }
if (userRole.value === 'ceo') {
const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([
dashboardApi.kpis({ role: 'ceo', ...params }),
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 || []
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 })
myKpis.value = (r as any)?.data || []
dataRange.value = (r as any)?.period || ''
} else if (userRole.value === 'finance') {
const r: any = await dashboardApi.financeAnalysis?.({ ...params })
finKpis.value = (r as any)?.kpis || []
finSummary.value = (r as any)?.summary || {}
dataRange.value = (r as any)?.period || ''
} else if (userRole.value === 'it') {
const channelRes = await axios.get('/api/cma/notifications/channels').then(r => r.data)
enabledChannels.value = channelRes.data?.filter((c: any) => c.enabled).length || 0
}
} catch (e) { console.error('加载数据失败:', e) }
}
onMounted(() => { loadData() })
</script>
<style scoped>
/* ========== 布局 ========== */
.dashboard-page { max-width: 1400px; margin: 0 auto; position: relative; }
/* 顶部导航 */
.dash-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; padding:12px 20px; background:#fff; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
.header-left, .header-right { display:flex; align-items:center; gap:12px; }
.data-range { color:#999; font-size:13px; }
/* ========== 摘要卡片行 ========== */
.summary-row { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:14px; margin-bottom:20px; }
.stat-card { background:#fff; border-radius:10px; padding:16px 20px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border-top:3px solid #ccc; transition:transform .15s; cursor:default; }
.stat-card:hover { transform:translateY(-1px); box-shadow:0 3px 10px rgba(0,0,0,0.08); }
.stat-card.blue { border-top-color:var(--bsc-customer); }
.stat-card.red { border-top-color:var(--bsc-finance); }
.stat-card.green { border-top-color:var(--bsc-process); }
.stat-card.yellow { border-top-color:var(--bsc-learning); }
.stat-card.gray { border-top-color:#909399; }
.stat-val { font-size:28px; font-weight:700; color:#1a1a2e; letter-spacing:-1px; }
.stat-label { font-size:13px; color:#888; margin-top:2px; }
.stat-sub { font-size:11px; color:#bbb; margin-top:1px; }
.click-hint { font-size:10px; color:#d0d0d0; }
/* ========== CEO双栏 ========== */
.ceo-main { display:flex; gap:20px; align-items:flex-start; }
.ceo-left { flex:2; min-width:0; }
.ceo-right { flex:1; min-width:260px; max-width:340px; position:sticky; top:80px; }
/* 异常高亮区 */
.alert-spotlight { background:linear-gradient(135deg, #fff5f5 0%, #fff 100%); border:1px solid #fde2e2; border-radius:10px; padding:16px; margin-bottom:20px; }
.spotlight-title { font-size:15px; font-weight:600; margin-bottom:10px; color:#c0392b; }
.spotlight-list { display:flex; flex-direction:column; gap:6px; }
.spotlight-item { display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:8px; cursor:pointer; transition:background .15s; font-size:13px; }
.spotlight-item:hover { background:rgba(245,108,108,0.08); }
.spotlight-item.level-red { border-left:3px solid var(--bsc-finance); }
.spotlight-item.level-yellow { border-left:3px solid var(--bsc-learning); }
.spotlight-badge { font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; white-space:nowrap; }
.spotlight-item.level-red .spotlight-badge { background:#fef0f0; color:var(--bsc-finance); }
.spotlight-item.level-yellow .spotlight-badge { background:#fdf6ec; color:var(--bsc-learning); }
.spotlight-msg { flex:1; color:#333; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.spotlight-item .el-icon { color:#ccc; flex-shrink:0; }
/* KPI矩阵 */
.matrix-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:14px; }
.dim-card { background:#fff; border-radius:10px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
.dim-head { display:flex; align-items:center; gap:6px; padding:10px 14px; color:#fff; font-weight:600; font-size:13px; }
.dim-icon { font-size:16px; }
.dim-count { margin-left:auto; font-size:11px; opacity:.7; }
.dim-body { padding:6px 0; }
.kpi-row { padding:10px 14px; cursor:pointer; transition:background .12s; border-bottom:1px solid #f8f8f8; }
.kpi-row:last-child { border-bottom:none; }
.kpi-row:hover { background:#f8faff; }
.kpi-row-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:4px; }
.kpi-row-name { font-size:13px; color:#333; }
.kpi-row-badge { font-size:12px; }
.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; }
/* 右侧预测 */
.right-card { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border:1px solid #f0f0f0; }
.right-card-title { font-size:15px; font-weight:600; margin-bottom:14px; }
.pred-list { display:flex; flex-direction:column; gap:12px; }
.pred-row { padding:12px; border-radius:8px; border:1px solid #f0f0f0; transition:border-color .15s; }
.pred-row:hover { border-color:#d0d0d0; }
.pred-row.trend-down { border-left:3px solid var(--bsc-finance); }
.pred-row.trend-up { border-left:3px solid var(--bsc-process); }
.pred-row.trend-stable { border-left:3px solid #909399; }
.pred-row-top { display:flex; justify-content:space-between; margin-bottom:6px; }
.pred-name { font-size:13px; font-weight:500; color:#333; }
.pred-arrow { font-size:14px; font-weight:700; color:#999; }
.pred-row.trend-down .pred-arrow { color:var(--bsc-finance); }
.pred-row.trend-up .pred-arrow { color:var(--bsc-process); }
.pred-row-val { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
.pred-cur { font-size:16px; font-weight:600; color:#555; }
.pred-sep { color:#ccc; font-size:12px; }
.pred-fut { font-size:18px; font-weight:700; }
.pred-row-info { font-size:11px; }
.pred-row.trend-up .pred-arrow { color:var(--bsc-process); }
.pred-conf.high { background:#f0f9eb; color:var(--bsc-process); }
.pred-conf.medium { background:#fdf6ec; color:var(--bsc-learning); }
.pred-conf.low { background:#f4f4f5; color:#909399; }
/* ========== 业务/财务KPI卡片 ========== */
.kpi-card-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:14px; }
.kpi-card-item { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); cursor:pointer; transition:all .15s; border:1px solid transparent; }
.kpi-card-item:hover { border-color:#409eff; box-shadow:0 3px 12px rgba(64,158,255,0.12); }
.kpi-card-item.wide { grid-column:span 2; }
.card-item-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
.item-name { font-size:14px; font-weight:500; color:#333; }
.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-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; }
/* 财务趋势柱 */
.trend-bars { display:flex; gap:6px; align-items:flex-end; height:40px; margin-top:8px; }
.tbar-wrap { display:flex; flex-direction:column; align-items:center; flex:1; }
.tbar { width:100%; background:#409eff; border-radius:2px 2px 0 0; min-height:3px; transition:height .3s; }
.tbar-label { font-size:9px; color:#bbb; margin-top:3px; }
/* 系统卡片 */
.sys-card { margin-top:16px; }
/* AI面板 */
.ai-panel { position:fixed; top:80px; right:20px; width:340px; max-height:calc(100vh - 100px); background:#fff; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,0.12); display:flex; flex-direction:column; z-index:200; }
.ai-head { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid #f0f0f0; font-weight:600; }
.ai-body { flex:1; overflow-y:auto; padding:14px 16px; font-size:13px; line-height:1.6; }
.ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; }
.ai-loading { text-align:center; padding:30px 0; color:#999; }
/* R1: 建议卡 */
.ai-section-title { font-size:13px; font-weight:600; color:#333; margin-bottom:10px; }
.ai-empty { text-align:center; color:#bbb; padding:16px 0; font-size:12px; }
.sug-list { display:flex; flex-direction:column; gap:10px; }
.sug-card { border:1px solid #eee; border-radius:8px; padding:10px 12px; background:#fafafa; border-left:3px solid #909399; }
.sug-card.type-kpi_target { border-left-color:#e6a23c; }
.sug-card.type-budget_adjust { border-left-color:#f56c6c; }
.sug-card.type-action_plan { border-left-color:#409eff; }
.sug-top { display:flex; gap:6px; margin-bottom:6px; }
.sug-title { font-size:13px; font-weight:600; color:#333; margin-bottom:4px; }
.sug-content { font-size:12px; color:#666; line-height:1.5; margin-bottom:8px; }
.sug-foot { display:flex; gap:6px; align-items:center; }
.sug-applied-by { font-size:11px; color:#999; }
.apply-tip { font-size:12px; color:#999; padding:0 0 10px 100px; }
/* KPI详情弹窗 */
.dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; }
.bar-fill { height:100%; border-radius:5px; transition:width .5s; }
.analysis-content { font-size:13px; line-height:1.7; }
.analysis-content h4 { margin:12px 0 6px; color:#409eff; }
/* 空状态 */
.empty-state { text-align:center; padding:60px 0; color:#aaa; font-size:14px; }
/* 响应式 */
@media screen and (max-width: 1024px) {
.ceo-main { flex-direction:column; }
.ceo-right { max-width:100%; position:static; }
.matrix-grid { grid-template-columns:1fr; }
}
@media screen and (max-width: 768px) {
.summary-row { grid-template-columns:repeat(2, 1fr); }
.kpi-card-grid { grid-template-columns:1fr; }
.kpi-card-item.wide { grid-column:span 1; }
.dash-header { flex-direction:column; align-items:flex-start; gap:8px; }
.ai-panel { width:calc(100vw - 40px); right:20px; }
}
</style>