init: 管理会计OS初始代码

包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
This commit is contained in:
Hermes CI Fix
2026-05-28 17:32:22 +08:00
commit 3dddd36866
142 changed files with 18533 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
<template>
<div class="review-page">
<!-- 顶部 -->
<div class="review-header">
<div class="header-left">
<el-button text @click="$router.push('/maps')"> 返回战略地图</el-button>
<h3>{{ mapTitle }} · 战略回顾</h3>
</div>
<div class="header-right">
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadReview">
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-radio-group v-model="period" size="small">
<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-group>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-wrap">
<el-skeleton :rows="6" animated />
</div>
<template v-if="!loading && summary">
<!-- 总体得分卡片 -->
<div class="summary-cards">
<div class="s-card overall">
<div class="s-card-top">
<span class="score">{{ summary.health_score }}%</span>
<span class="score-label">整体健康度</span>
</div>
<div class="s-card-btm">
<span class="stat-item green">🟢 {{ summary.green }}个达成</span>
<span class="stat-item yellow">🟡 {{ summary.yellow }}个预警</span>
<span class="stat-item red">🔴 {{ summary.red }}个异常</span>
</div>
</div>
</div>
<!-- 四维度矩阵 -->
<div class="dim-matrix">
<div v-for="dim in dimensions" :key="dim.key" class="dim-col" :style="{ borderLeft: '4px solid ' + dim.color }">
<div class="dim-head">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-name">{{ dim.name }}</span>
</div>
<div class="dim-body">
<div v-for="obj in dim.objectives" :key="obj.name"
class="obj-row"
:class="'obj-' + obj.level"
@click="showObjKpis(obj)">
<span class="obj-badge">{{ badgeIcon(obj.level) }}</span>
<div class="obj-info">
<span class="obj-name">{{ obj.name }}</span>
<span v-if="obj.has_data" class="obj-ratio">
<template v-for="k in obj.kpis.slice(0,2)" :key="k.kpi_code">
{{ fmtValue(k.actual_value, k.kpi_code) }}/{{ k.target_value ?? '-' }}
</template>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 🔴 需重点关注 -->
<div v-if="focusItems.length > 0" class="focus-section">
<h4 class="focus-title">🔴 需重点关注</h4>
<div v-for="item in focusItems" :key="item.name" class="focus-card" :class="'focus-' + item.level" @click="showObjKpis(item)">
<div class="focus-head">
<span class="focus-level">{{ item.level === 'red' ? '紧急' : '预警' }}</span>
<span class="focus-name">{{ item.name }}</span>
</div>
<div class="focus-kpis">
<div v-for="k in item.kpis" :key="k.kpi_code" class="fkpi-row">
<span class="fkpi-name">{{ k.kpi_name }}</span>
<span class="fkpi-val" :style="{ color: k.level === 'red' ? '#f56c6c' : '#e6a23c' }">
{{ fmtValue(k.actual_value, k.kpi_code) }} / {{ k.target_value ?? '-' }}
</span>
</div>
</div>
<!-- 关联的改善行动 -->
<div v-if="getPlansByKpi(item.kpis)" class="focus-plans">
<div v-for="p in getPlansByKpi(item.kpis)" :key="p.id" class="fplan-row" :class="'plan-' + planStatus(p)">
<span class="fplan-status">{{ planIcon(p) }}</span>
<span class="fplan-title">{{ p.title }}</span>
<span class="fplan-assignee">{{ p.assignee }}</span>
<span v-if="p.due_date && p.status !== 'completed'" class="fplan-due">{{ dueText(p) }}</span>
</div>
</div>
</div>
</div>
<!-- 📋 改善行动进度 -->
<div v-if="actionPlans.length > 0" class="plans-section">
<h4 class="plans-title">📋 改善行动进度</h4>
<div class="plans-stats">
<el-tag>{{ pendingCount }} 待处理</el-tag>
<el-tag type="warning">{{ inProgressCount }} 进行中</el-tag>
<el-tag type="success">{{ doneCount }} 已完成</el-tag>
<el-tag type="danger">{{ overdueCount }} 逾期</el-tag>
</div>
<el-table :data="actionPlans" size="small" style="width:100%" @row-click="(r:any)=>$router.push('/action-plans?kpi='+r.kpi_id)">
<el-table-column prop="title" label="行动标题" min-width="200" />
<el-table-column prop="assignee" label="负责人" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="planTagType(row)" size="small">{{ planLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress || 0" :status="row.status==='completed'?'success':undefined" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column label="截止" width="120">
<template #default="{ row }">
<span :style="{ color: isOverdue(row) ? '#f56c6c' : '#666' }">{{ row.due_date ? row.due_date.slice(0,10) : '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- KPI详情弹出 -->
<el-dialog v-model="showKpiDialog" :title="selectedObjName" width="600">
<div v-for="k in selectedKpis" :key="k.kpi_code" class="kpi-detail-row" :class="'kpi-' + k.level">
<div class="kpi-detail-head">
<span class="kpi-detail-badge">{{ badgeIcon(k.level) }}</span>
<span class="kpi-detail-name">{{ k.kpi_name }} ({{ k.kpi_code }})</span>
</div>
<div class="kpi-detail-body">
<div class="kpi-detail-item">
<span class="label">实际值</span>
<span class="val">{{ fmtValue(k.actual_value, k.kpi_code) }}</span>
</div>
<div class="kpi-detail-item">
<span class="label">目标值</span>
<span class="val">{{ k.target_value ?? '-' }} {{ k.unit }}</span>
</div>
<div class="kpi-detail-item" v-if="k.actual_value != null && k.target_value">
<span class="label">达成率</span>
<span class="val" :style="{ color: k.level === 'red' ? '#f56c6c' : 'inherit' }">
{{ (k.actual_value / k.target_value * 100).toFixed(1) }}%
</span>
</div>
<el-button size="small" @click="$router.push('/kpis/'+k.kpi_id)" style="margin-top:8px;">查看KPI详情 →</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue"
import { useRoute } from "vue-router"
import { ElMessage } from "element-plus"
import api from "../api/index"
const route = useRoute()
// ── 状态 ──
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const mapTitle = ref("")
const loading = ref(false)
const period = ref("month")
const summary = ref<any>(null)
const dimensions = ref<any[]>([])
const focusItems = ref<any[]>([])
const actionPlans = ref<any[]>([])
const showKpiDialog = ref(false)
const selectedObjName = ref("")
const selectedKpis = ref<any[]>([])
// ── 计算属性 ──
const pendingCount = () => actionPlans.value.filter((p:any) => p.status === 'pending').length
const inProgressCount = () => actionPlans.value.filter((p:any) => p.status === 'in_progress').length
const doneCount = () => actionPlans.value.filter((p:any) => p.status === 'completed').length
const overdueCount = () => actionPlans.value.filter((p:any) => p.status !== 'completed' && isOverdue(p)).length
// ── 加载 ──
async function loadReview() {
if (!selectedMap.value) return
loading.value = true
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
mapTitle.value = r.title
summary.value = r.summary
dimensions.value = r.dimensions || []
focusItems.value = r.focus_items || []
actionPlans.value = r.action_plans || []
} catch (e: any) {
ElMessage.error("加载战略回顾数据失败")
} finally {
loading.value = false
}
}
// ── 工具函数 ──
function badgeIcon(level: string) {
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
}
function fmtValue(val: any, code: string) {
if (val == null) return '-'
if (typeof val === 'number') {
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
return val
}
function showObjKpis(obj: any) {
selectedObjName.value = obj.name
selectedKpis.value = obj.kpis || []
showKpiDialog.value = true
}
function getPlansByKpi(kpis: any[]) {
if (!kpis || !actionPlans.value.length) return null
const kpiIds = kpis.map((k:any) => k.kpi_id)
return actionPlans.value.filter((p:any) => kpiIds.includes(p.kpi_id))
}
function planStatus(p: any) {
if (p.status === 'completed') return 'done'
if (isOverdue(p)) return 'overdue'
if (p.status === 'in_progress') return 'active'
return 'pending'
}
function planIcon(p: any) {
if (p.status === 'completed') return '✅'
if (isOverdue(p)) return '🔴'
if (p.status === 'in_progress') return '🔄'
return '⏳'
}
function planLabel(p: any) {
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[p.status] || p.status
}
function planTagType(p: any) {
if (p.status === 'completed') return 'success'
if (isOverdue(p)) return 'danger'
if (p.status === 'in_progress') return 'warning'
return 'info'
}
function isOverdue(p: any) {
if (p.status === 'completed' || !p.due_date) return false
return new Date(p.due_date) < new Date()
}
function dueText(p: any) {
if (!p.due_date) return ''
const due = new Date(p.due_date)
const now = new Date()
const diff = Math.ceil((due.getTime() - now.getTime()) / 86400000)
if (diff < 0) return `逾期${Math.abs(diff)}`
if (diff === 0) return '今日截止'
return `剩余${diff}`
}
onMounted(async () => {
const r: any = await api.get('/maps')
maps.value = r.data || []
const id = route.params.id ? Number(route.params.id) : (maps.value[0]?.id || null)
if (id) {
selectedMap.value = id
await loadReview()
}
})
</script>
<style scoped>
.review-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-shrink: 0; }
.review-header .header-left { display: flex; align-items: center; gap: 12px; }
.review-header h3 { margin: 0; font-size: 18px; }
.review-header .header-right { display: flex; align-items: center; }
.loading-wrap { padding: 40px; }
.summary-cards { display: flex; gap: 16px; margin-bottom: 20px; }
.s-card { padding: 20px; border-radius: 12px; flex: 1; }
.s-card.overall { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.s-card-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.score { font-size: 36px; font-weight: 700; }
.score-label { font-size: 14px; opacity: 0.8; }
.s-card-btm { display: flex; gap: 16px; font-size: 13px; }
.stat-item { opacity: 0.9; }
.dim-matrix { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
.dim-col { background: #fff; border-radius: 8px; padding: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.dim-head { font-size: 14px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.dim-body { display: flex; flex-direction: column; gap: 6px; }
.obj-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; cursor: pointer; transition: background 0.2s; font-size: 13px; }
.obj-row:hover { background: #f5f7fa; }
.obj-red { background: #fef0f0; }
.obj-yellow { background: #fdf6ec; }
.obj-green { background: #f0f9eb; }
.obj-gray { background: #fafafa; }
.obj-badge { font-size: 12px; }
.obj-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.obj-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.obj-ratio { font-size: 11px; color: #999; }
.focus-section { margin-bottom: 24px; }
.focus-title { font-size: 16px; margin: 0 0 12px 0; }
.focus-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; }
.focus-red { border-left: 4px solid #f56c6c; }
.focus-yellow { border-left: 4px solid #e6a23c; }
.focus-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.focus-level { font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
.focus-red .focus-level { background: #fef0f0; color: #f56c6c; }
.focus-yellow .focus-level { background: #fdf6ec; color: #e6a23c; }
.focus-name { font-weight: 600; font-size: 14px; }
.focus-kpis { padding-left: 20px; margin-bottom: 6px; }
.fkpi-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
.fkpi-name { color: #666; }
.fkpi-val { font-weight: 500; }
.focus-plans { padding-left: 20px; margin-top: 4px; border-top: 1px solid #f0f0f0; padding-top: 6px; }
.fplan-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; }
.fplan-status { flex-shrink: 0; }
.fplan-title { flex: 1; }
.fplan-assignee { color: #999; }
.fplan-due { color: #f56c6c; font-size: 11px; }
.plans-section { margin-bottom: 24px; }
.plans-title { font-size: 16px; margin: 0 0 12px 0; }
.plans-stats { display: flex; gap: 8px; margin-bottom: 12px; }
.kpi-detail-row { border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.kpi-red { border-left: 4px solid #f56c6c; }
.kpi-yellow { border-left: 4px solid #e6a23c; }
.kpi-green { border-left: 4px solid #67c23a; }
.kpi-gray { border-left: 4px solid #ccc; }
.kpi-detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kpi-detail-name { font-weight: 600; font-size: 14px; }
.kpi-detail-body { padding-left: 24px; }
.kpi-detail-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
.kpi-detail-item .label { color: #999; width: 60px; }
.kpi-detail-item .val { font-weight: 500; }
</style>