Files
cma-management/frontend/src/views/Dashboard.vue
T
Hermes CI Fix 7b74736980 refactor(cma-frontend): route all API calls through shared axios instance (cma-frontend-axios-unify-20260915)
api/index.ts: add permissionApi.getModules/getConfig/saveConfig + alignmentApi.config/tree.

views: replace 13 raw fetch calls and 6 local axios.create instances (each hand-building the Authorization header, without the shared 401 redirect / timeout toast) with the shared instance from api/index.ts.

KPIAlignment: fixes a live bug - the header value was the string literal 'Bearer ' + token, so /alignment/tree always returned 401 (reproduced against the production bundle).

Unwrap semantics preserved: the shared response interceptor returns response.data, so call sites that used res.data were adjusted accordingly.

Verified: scan-frontend-tokenless RAW-TOKENLESS=0; vite build ok; headless A/B (new dist vs production bundle, 8 affected pages, real login) -> identical rendered rows and identical pre-existing error profile, /alignment 401->200.

Push not executed: main is ahead 4 with other tickets' gated commits (see cma-main-ahead3-push-gate-notice-20260915).
2026-09-15 22:21:56 +08:00

643 lines
33 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'">
<!-- 本量利·保本点卡片(Phase2,A区只读) -->
<BEPBreakEvenCard />
<!-- 摘要卡片行 -->
<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'">
<!-- 本量利·保本点卡片(Phase2,A区只读) -->
<BEPBreakEvenCard />
<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分析浮窗(R1:AI建议→一键落地) -->
<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">
<span v-if="s.status === 'applied'" class="sug-applied-by">由 {{ s.applied_by }} 于 {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
<span v-else class="sug-pending-hint">待审批 · 请到 AI建议中心 处理</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>
<!-- KPI详情弹窗(只读,A区) -->
<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, notificationApi } from '../api/index'
import api from '../api/index'
import CountUp from '../components/CountUp.vue'
import GrowthQuality from '../components/GrowthQuality.vue'
import BEPBreakEvenCard from '../components/BEPBreakEvenCard.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决策建议(A区只读展示;应用/忽略/审批收敛至 B区 /ai-suggestions,P0-① 三区收口 2026-09-03)
const suggestions = ref<any[]>([])
const suggestionsLoading = ref(false)
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
}
// AI 分析等接口统一走 api/index.ts 的共享实例(此前本地 aiApi 手拼 Bearer 已移除)
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 api.get(`/ai/kpi-analysis/${selectedKPI.value.id}`)
selectedKPIDetail.value = r?.analysis || '暂无分析结果'
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
loadingDetail.value = false
}
async function refreshAnalysis() {
aiLoading.value = true
try {
const r: any = await api.get('/ai/dashboard-analysis', { params: { role: userRole.value } })
aiAnalysis.value = 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') {
// 鉴权收口(cma-api-auth-surface-audit-20260914)+ 实例统一(2026-09-15):走共享实例,Bearer 由拦截器注入
const channelRes: any = await notificationApi.list()
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; }
.sug-pending-hint { font-size:11px; color:#b88230; }
.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>