包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
576 lines
28 KiB
Vue
576 lines
28 KiB
Vue
<template>
|
||
<div class="dashboard-page">
|
||
<!-- 顶部导航条 -->
|
||
<div class="dash-header">
|
||
<div class="header-left">
|
||
<el-tag size="large" :type="roleTagType">{{ roleLabel }}驾驶舱</el-tag>
|
||
<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">{{ 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">{{ 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">{{ greenKpis }}</div><div class="stat-label">正常</div></div>
|
||
<div class="stat-card yellow" @click="$router.push('/alerts')" style="cursor:pointer;"><div class="stat-val">{{ 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' ? '#f56c6c' : '#67c23a' }">
|
||
{{ 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>
|
||
</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">{{ 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分析浮窗 -->
|
||
<div v-if="showSidebar" class="ai-panel">
|
||
<div class="ai-head"><span>🤖 AI 分析</span><el-button text size="small" @click="showSidebar = false">✕</el-button></div>
|
||
<div class="ai-body">
|
||
<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详情弹窗 -->
|
||
<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 } from '../api/index'
|
||
import axios from 'axios'
|
||
|
||
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)
|
||
|
||
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.post('/ai/kpi-analysis', { kpi_id: selectedKPI.value.id, period: periodType.value })
|
||
selectedKPIDetail.value = r.data || r.analysis || '暂无分析结果'
|
||
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
|
||
loadingDetail.value = false
|
||
}
|
||
|
||
async function refreshAnalysis() {
|
||
aiLoading.value = true
|
||
try {
|
||
const r: any = await aiApi.post('/ai/dashboard-analysis', { role: userRole.value, period: periodType.value })
|
||
aiAnalysis.value = r.data || r.analysis || '暂无分析'
|
||
} 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 params = getPeriodParams()
|
||
if (userRole.value === 'ceo') {
|
||
const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([
|
||
dashboardApi.kpis({ role: 'ceo', ...params }),
|
||
alertApi.list({ limit: 10 }),
|
||
dashboardApi.summary({ role: 'ceo', ...params }),
|
||
dashboardApi.predict({}).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:#409eff; }
|
||
.stat-card.red { border-top-color:#f56c6c; }
|
||
.stat-card.green { border-top-color:#67c23a; }
|
||
.stat-card.yellow { border-top-color:#e6a23c; }
|
||
.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 #f56c6c; }
|
||
.spotlight-item.level-yellow { border-left:3px solid #e6a23c; }
|
||
.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:#f56c6c; }
|
||
.spotlight-item.level-yellow .spotlight-badge { background:#fdf6ec; color:#e6a23c; }
|
||
.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 #f56c6c; }
|
||
.pred-row.trend-up { border-left:3px solid #67c23a; }
|
||
.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:#f56c6c; }
|
||
.pred-row.trend-up .pred-arrow { color:#67c23a; }
|
||
.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-conf { padding:1px 6px; border-radius:3px; }
|
||
.pred-conf.high { background:#f0f9eb; color:#67c23a; }
|
||
.pred-conf.medium { background:#fdf6ec; color:#e6a23c; }
|
||
.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; }
|
||
|
||
/* 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>
|