Files
cma-management/frontend/src/views/Dashboard.vue
T
Hermes CI Fix 3dddd36866 init: 管理会计OS初始代码
包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
2026-05-28 17:32:22 +08:00

576 lines
28 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">
<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>