feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div style="padding:0 16px;">
|
||||
<!-- 顶部标题+操作 -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h3 style="margin:0;">行动方案库</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索标题/KPI名称/编码" clearable style="width:240px;" @clear="loadData" @keyup.enter="loadData" />
|
||||
<el-button type="primary" @click="openCreate">+ 新建行动方案</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态统计卡片 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;">
|
||||
<div v-for="card in statCards" :key="card.key"
|
||||
:class="['stat-card', { active: filterStatus === card.key }]"
|
||||
@click="filterStatus = card.key; page = 1; loadData()"
|
||||
style="cursor:pointer;min-width:100px;padding:14px 20px;border-radius:8px;border:2px solid transparent;transition:all .2s;background:#f5f7fa;text-align:center;">
|
||||
<div style="font-size:12px;color:#909399;">{{ card.label }}</div>
|
||||
<div style="font-size:24px;font-weight:600;margin-top:4px;" :style="{ color: card.color }">{{ stats[card.key] ?? '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:100px;" @change="page=1;loadData()">
|
||||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filterKpi" placeholder="关联KPI" clearable filterable style="width:180px;" @change="page=1;loadData()">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filterAssignee" placeholder="负责人" clearable filterable style="width:120px;" @change="page=1;loadData()">
|
||||
<el-option v-for="u in userOptions" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<el-select v-model="filterPriority" placeholder="优先级" clearable style="width:100px;" @change="page=1;loadData()">
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="低" value="low" />
|
||||
</el-select>
|
||||
<el-button v-if="hasFilter" size="small" @click="clearFilter">清除筛选</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="list" v-loading="loading" border stripe size="small" style="width:100%;" @selection-change="(sel: any[]) => selectedRows = sel">
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column label="行动方案" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<span style="font-weight:500;cursor:pointer;color:#409eff;" @click="viewDetail(row)">{{ row.title }}</span>
|
||||
<div v-if="row.target_value" style="font-size:11px;color:#67c23a;margin-top:2px;display:flex;align-items:center;gap:4px;">
|
||||
<span style="font-size:10px;border:1px solid #67c23a;border-radius:2px;padding:0 3px;line-height:14px;">S</span>
|
||||
{{ row.target_value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联KPI" width="180">
|
||||
<template #default="{ row }">
|
||||
<div style="font-size:12px;line-height:1.4;">
|
||||
<span style="color:#666;">{{ row.kpi_code }}</span><br>
|
||||
<span>{{ row.kpi_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="维度" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="dimTagType(row.kpi_dimension)">{{ dimLabel(row.kpi_dimension) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="90" prop="assignee" />
|
||||
<el-table-column label="优先级" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.priority === 'high' ? 'danger' : row.priority === 'low' ? 'info' : 'warning'">
|
||||
{{ row.priority === 'high' ? '高' : row.priority === 'low' ? '低' : '中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="120">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<el-progress :percentage="row.progress || 0" :stroke-width="8" style="flex:1;" />
|
||||
<span style="font-size:11px;color:#999;min-width:28px;">{{ row.progress || 0 }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截止日" width="100" prop="due_date">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!row.due_date" style="color:#ccc;">—</span>
|
||||
<span v-else :style="{ color: isOverdue(row) ? '#f56c6c' : '#666', fontWeight: isOverdue(row) ? 600 : 400 }">
|
||||
{{ row.due_date?.slice(0, 10) }}
|
||||
<el-tooltip v-if="isOverdue(row)" content="已逾期" placement="top"><span style="margin-left:2px;">⚠️</span></el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="editPlan(row)">编辑</el-button>
|
||||
<el-button size="small" link type="danger" @click="deletePlan(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<div v-if="selectedRows.length > 0" style="margin-top:8px;display:flex;gap:8px;align-items:center;padding:8px 12px;background:#f0f9ff;border-radius:6px;">
|
||||
<span style="font-size:13px;color:#409eff;">已选 {{ selectedRows.length }} 项</span>
|
||||
<el-button size="small" @click="batchSetStatus('in_progress')">置为进行中</el-button>
|
||||
<el-button size="small" type="success" @click="batchSetStatus('completed')">置为已完成</el-button>
|
||||
<el-button size="small" @click="clearSelected">取消选择</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div style="display:flex;justify-content:center;margin-top:16px;">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next, total" @current-change="loadData" background small />
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<MyDialog v-model="showForm" :title="formTitle" :width="550">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<el-tag size="small" type="success">S 具体的</el-tag>
|
||||
<el-tag size="small" type="warning">M 可衡量</el-tag>
|
||||
<el-tag size="small">A 可实现</el-tag>
|
||||
<el-tag size="small" type="primary">R 相关(已关联KPI)</el-tag>
|
||||
<el-tag size="small" type="danger">T 有时限(截止日)</el-tag>
|
||||
</div>
|
||||
<el-form :model="form" label-width="80px" size="small">
|
||||
<el-form-item label="行动标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入行动方案标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值" help="SMART:具体、可衡量的成功标准">
|
||||
<div style="display:flex;gap:4px;flex-direction:column;width:100%;">
|
||||
<el-input v-model="form.target_value" placeholder="例:满意度从85%提升至92%、不良率降低50%以下" />
|
||||
<span style="font-size:11px;color:#999;line-height:1.4;">设置可量化的成功标准,完成后对照核验</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联KPI" required>
|
||||
<el-select v-model="form.kpi_id" placeholder="选择关联KPI" filterable style="width:100%;" :teleported="false">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="详细描述(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-select v-model="form.assignee" placeholder="选择负责人" filterable allow-create style="width:100%;" :teleported="false">
|
||||
<el-option v-for="u in userOptions" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-radio-group v-model="form.priority">
|
||||
<el-radio value="high">高</el-radio>
|
||||
<el-radio value="medium">中</el-radio>
|
||||
<el-radio value="low">低</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="截止日期">
|
||||
<el-date-picker v-model="form.due_date" type="date" placeholder="选择截止日期" style="width:100%;" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId" label="状态">
|
||||
<el-select v-model="form.status" style="width:100%;" :teleported="false">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="进行中" value="in_progress" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId" label="进度 %">
|
||||
<el-slider v-model="form.progress" :min="0" :max="100" :step="5" show-input style="width:100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId && form.status === 'completed'" label="改善结果">
|
||||
<el-input v-model="form.result" type="textarea" :rows="3" placeholder="描述改善结果,对照目标值核验" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePlan" :loading="saving">{{ editingId ? '保存修改' : '创建' }}</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<MyDialog v-model="showDetail" :title="detailTitle" :width="600">
|
||||
<div v-if="detailPlan">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">关联KPI</div>
|
||||
<div style="margin-top:2px;">{{ detailPlan.kpi_code }} {{ detailPlan.kpi_name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">维度</div>
|
||||
<div style="margin-top:2px;">
|
||||
<el-tag size="small" :type="dimTagType(detailPlan.kpi_dimension)">{{ dimLabel(detailPlan.kpi_dimension) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">负责人</div>
|
||||
<div style="margin-top:2px;">{{ detailPlan.assignee || '未指定' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">优先级</div>
|
||||
<div style="margin-top:2px;">
|
||||
<el-tag size="small" :type="detailPlan.priority === 'high' ? 'danger' : detailPlan.priority === 'low' ? 'info' : 'warning'">
|
||||
{{ detailPlan.priority === 'high' ? '高' : detailPlan.priority === 'low' ? '低' : '中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">截止日期</div>
|
||||
<div style="margin-top:2px;" :class="{ 'overdue-text': isOverdue(detailPlan) }">
|
||||
{{ detailPlan.due_date?.slice(0, 10) || '未设置' }}
|
||||
<span v-if="isOverdue(detailPlan)" style="color:#f56c6c;font-size:12px;margin-left:4px;">已逾期</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">状态</div>
|
||||
<div style="margin-top:2px;">
|
||||
<el-tag size="small" :type="statusTagType(detailPlan.status)">{{ statusLabel(detailPlan.status) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<div style="font-size:12px;color:#999;">进度</div>
|
||||
<el-progress :percentage="detailPlan.progress || 0" :stroke-width="12" style="margin-top:4px;" />
|
||||
</div>
|
||||
<!-- 目标值 -->
|
||||
<div v-if="detailPlan.target_value" style="margin-top:12px;padding:10px 14px;background:#f0f9eb;border-radius:6px;border-left:3px solid #67c23a;">
|
||||
<div style="font-size:12px;color:#67c23a;font-weight:500;display:flex;align-items:center;gap:4px;">
|
||||
<span style="font-size:10px;border:1px solid #67c23a;border-radius:2px;padding:0 3px;line-height:14px;">SMART</span>
|
||||
目标值 / 成功标准
|
||||
</div>
|
||||
<div style="margin-top:4px;line-height:1.6;font-size:14px;">{{ detailPlan.target_value }}</div>
|
||||
</div>
|
||||
<div v-if="detailPlan.description" style="margin-top:12px;">
|
||||
<div style="font-size:12px;color:#999;">详细描述</div>
|
||||
<div style="margin-top:4px;line-height:1.6;background:#f9f9f9;padding:8px 12px;border-radius:4px;">{{ detailPlan.description }}</div>
|
||||
</div>
|
||||
<div v-if="detailPlan.result" style="margin-top:12px;">
|
||||
<div style="font-size:12px;color:#999;">改善结果</div>
|
||||
<div style="margin-top:4px;line-height:1.6;background:#f0f9eb;padding:8px 12px;border-radius:4px;">{{ detailPlan.result }}</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;font-size:12px;color:#ccc;display:flex;gap:16px;">
|
||||
<span>创建人: {{ detailPlan.created_by }}</span>
|
||||
<span>创建时间: {{ detailPlan.created_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showDetail = false">关闭</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { actionPlanApi, kpiApi, userApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// ── 数据 ──
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const stats = ref<Record<string, number>>({})
|
||||
const selectedRows = ref<any[]>([])
|
||||
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const userOptions = ref<string[]>([])
|
||||
|
||||
// ── 筛选 ──
|
||||
const filterStatus = ref('')
|
||||
const filterDimension = ref('')
|
||||
const filterKpi = ref<number | null>(null)
|
||||
const filterAssignee = ref('')
|
||||
const filterPriority = ref('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const dimensions = [
|
||||
{ value: 'finance', label: '财务' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'process', label: '内部流程' },
|
||||
{ value: 'learning', label: '学习成长' },
|
||||
]
|
||||
|
||||
const hasFilter = computed(() =>
|
||||
filterStatus.value || filterDimension.value || filterKpi.value ||
|
||||
filterAssignee.value || filterPriority.value || searchKeyword.value
|
||||
)
|
||||
|
||||
function clearFilter() {
|
||||
filterStatus.value = ''
|
||||
filterDimension.value = ''
|
||||
filterKpi.value = null
|
||||
filterAssignee.value = ''
|
||||
filterPriority.value = ''
|
||||
searchKeyword.value = ''
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
selectedRows.value = []
|
||||
}
|
||||
|
||||
// ── 统计卡片 ──
|
||||
const statCards = [
|
||||
{ key: '', label: '全部', color: '#409eff' },
|
||||
{ key: 'pending', label: '待处理', color: '#909399' },
|
||||
{ key: 'in_progress', label: '进行中', color: '#e6a23c' },
|
||||
{ key: 'completed', label: '已完成', color: '#67c23a' },
|
||||
{ key: 'overdue', label: '已逾期', color: '#f56c6c' },
|
||||
]
|
||||
|
||||
// ── 加载数据 ──
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterDimension.value) params.dimension = filterDimension.value
|
||||
if (filterKpi.value) params.kpi_id = filterKpi.value
|
||||
if (filterAssignee.value) params.assignee = filterAssignee.value
|
||||
if (filterPriority.value) params.priority = filterPriority.value
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||
|
||||
const r: any = await actionPlanApi.list(params)
|
||||
const d = r.data || []
|
||||
list.value = Array.isArray(d) ? d : []
|
||||
total.value = r.total || list.value.length
|
||||
} catch { ElMessage.error('加载行动方案失败') }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r: any = await actionPlanApi.stats()
|
||||
stats.value = r
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadKpis() {
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 500 })
|
||||
const d = r.data || r || []
|
||||
kpiOptions.value = Array.isArray(d) ? d : []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const r: any = await userApi.list()
|
||||
const d = r.data || []
|
||||
if (Array.isArray(d)) {
|
||||
userOptions.value = d.map((u: any) => u.name || u.username).filter(Boolean)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── 新建/编辑 ──
|
||||
const showForm = ref(false)
|
||||
const formTitle = ref('')
|
||||
const editingId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
const form = ref({
|
||||
title: '',
|
||||
target_value: '',
|
||||
kpi_id: null as number | null,
|
||||
description: '',
|
||||
assignee: '',
|
||||
priority: 'medium',
|
||||
due_date: null as string | null,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
result: '',
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
formTitle.value = '新建行动方案'
|
||||
form.value = { title: '', target_value: '', kpi_id: null, description: '', assignee: '', priority: 'medium', due_date: null, status: 'pending', progress: 0, result: '' }
|
||||
// 如果路由携带 kpi_id 参数,自动填入
|
||||
if (route.query.kpi_id) {
|
||||
form.value.kpi_id = Number(route.query.kpi_id)
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function editPlan(row: any) {
|
||||
editingId.value = row.id
|
||||
formTitle.value = '编辑行动方案'
|
||||
form.value = {
|
||||
title: row.title,
|
||||
target_value: row.target_value || '',
|
||||
kpi_id: row.kpi_id,
|
||||
description: row.description || '',
|
||||
assignee: row.assignee || '',
|
||||
priority: row.priority || 'medium',
|
||||
due_date: row.due_date?.slice(0, 10) || null,
|
||||
status: row.status || 'pending',
|
||||
progress: row.progress || 0,
|
||||
result: row.result || '',
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function savePlan() {
|
||||
if (!form.value.title || !form.value.kpi_id) {
|
||||
ElMessage.warning('请填写标题并选择关联KPI')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { ...form.value }
|
||||
if (editingId.value) {
|
||||
await actionPlanApi.update(editingId.value, data)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await actionPlanApi.create(data)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
showForm.value = false
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
// ── 删除 ──
|
||||
async function deletePlan(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此行动方案?')
|
||||
await actionPlanApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ── 批量操作 ──
|
||||
async function batchSetStatus(status: string) {
|
||||
const ids = selectedRows.value.map(r => r.id)
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将选中的 ${ids.length} 项置为「${status === 'completed' ? '已完成' : '进行中'}」?`)
|
||||
for (const id of ids) {
|
||||
await actionPlanApi.update(id, { status })
|
||||
}
|
||||
ElMessage.success(`已更新 ${ids.length} 项`)
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error('批量操作失败') }
|
||||
}
|
||||
|
||||
// ── 详情 ──
|
||||
const showDetail = ref(false)
|
||||
const detailTitle = ref('')
|
||||
const detailPlan = ref<any>(null)
|
||||
|
||||
function viewDetail(row: any) {
|
||||
detailPlan.value = row
|
||||
detailTitle.value = row.title
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
// ── 辅助函数 ──
|
||||
function isOverdue(row: any) {
|
||||
if (!row.due_date) return false
|
||||
if (row.status === 'completed' || row.status === 'cancelled') return false
|
||||
return new Date(row.due_date.slice(0, 10)) < new Date(new Date().toLocaleDateString())
|
||||
}
|
||||
|
||||
function dimTagType(dim: string) {
|
||||
const map: Record<string, string> = { finance: 'primary', customer: 'success', process: 'warning', learning: 'danger' }
|
||||
return map[dim] || 'info'
|
||||
}
|
||||
|
||||
function dimLabel(dim: string) {
|
||||
const map: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
return map[dim] || dim || '-'
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
const map: Record<string, string> = { pending: 'info', in_progress: 'warning', completed: 'success', cancelled: 'info' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// ── 初始化 ──
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadData()
|
||||
loadKpis()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card:hover {
|
||||
border-color: #dcdfe6;
|
||||
background: #fff;
|
||||
}
|
||||
.stat-card.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.overdue-text {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预算管理</h3>
|
||||
<div v-if="noMap" style="padding:60px 0;text-align:center;color:#999;">
|
||||
<p style="font-size:16px;margin-bottom:12px;">暂无已发布的战略地图</p>
|
||||
<p style="font-size:13px;">预算编制需要基于已发布的战略地图,请先在【战略地图】中创建并发布</p>
|
||||
<el-button type="primary" style="margin-top:16px;" @click="$router.push('/maps')">去创建战略地图</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<el-tab-pane label="预算录入" name="input">
|
||||
<div style="margin-bottom:8px;font-size:12px;color:#909399;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
选择战略地图
|
||||
<el-select v-model="selectedMapId" placeholder="选择已发布的战略地图" style="width:260px;" @change="onMapSelect">
|
||||
<el-option v-for="m in publishedMaps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<el-tag v-if="selectedMapId" size="small" type="success">已选择</el-tag>
|
||||
<span style="color:#999;">共 {{ total }} 条KPI</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
|
||||
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
|
||||
<el-button type="primary" @click="loadBudget">查询</el-button>
|
||||
<el-button type="success" @click="showAddBudget = true">+ 新增预算</el-button>
|
||||
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-radio-group v-model="budgetViewMode" size="small" @change="onViewModeChange"><el-radio-button value="list">KPI列表</el-radio-button><el-radio-button value="summary">维度汇总</el-radio-button></el-radio-group>
|
||||
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
|
||||
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
|
||||
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
|
||||
</div>
|
||||
<template v-if="budgetViewMode === 'list'">
|
||||
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
|
||||
<el-table-column type="index" label="#" width="40" />
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
|
||||
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="budget_value" label="预算值" width="170">
|
||||
<template #default="{ row }"><el-input-number v-if="row._editing" v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:155px;" @change="onEditChange(row)" /><span v-else>{{ formatNumber(row.budget_value, row.unit) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
<!-- 行动方案列(非财务维度显示) -->
|
||||
<el-table-column label="行动方案" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.dimension === 'finance'" style="color:#ccc;font-size:12px;">—</span>
|
||||
<el-button v-else size="small" link type="primary" @click="showActionPlans(row)">
|
||||
{{ row._actionCount != null ? `${row._actionCount}项` : '关联' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!batchEditMode && !row._editing" size="small" @click="startEdit(row)">编辑</el-button>
|
||||
<template v-else-if="!batchEditMode"><el-button size="small" type="primary" @click="saveEdit(row)" :loading="row._saving">保存</el-button><el-button size="small" @click="cancelEdit(row)">取消</el-button></template>
|
||||
<el-button v-if="!batchEditMode && row.id" size="small" :type="row.dimension === 'finance' ? 'info' : 'danger'" :disabled="row.dimension === 'finance'" @click="row.dimension !== 'finance' && doDelete(row)">{{ row.dimension === 'finance' ? '财务指标' : '删除' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-if="budgetViewMode === 'summary'">
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
|
||||
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + card.color }">
|
||||
<div style="text-align:center;"><div style="font-size:14px;margin-bottom:4px;">{{ card.icon }} {{ card.name }}</div><div style="font-size:24px;font-weight:600;color:#333;">{{ formatNumber(card.totalBudget) }}</div><div style="font-size:11px;color:#999;margin-top:4px;">{{ card.kpiCount }}个KPI · 占比{{ card.ratio }}%</div></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-table :data="dimSummaryRows" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="dimension" label="维度" width="100"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="kpiCount" label="KPI数" width="80" />
|
||||
<el-table-column prop="totalBudget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.totalBudget) }}</template></el-table-column>
|
||||
<el-table-column prop="ratio" label="占比" width="100"><template #default="{ row }"><el-progress :percentage="row.ratio" :stroke-width="12" text-inside /></template></el-table-column>
|
||||
<el-table-column prop="avgPerKpi" label="KPI均值" width="140"><template #default="{ row }">{{ formatNumber(row.avgPerKpi) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="年度分解" name="decompose">
|
||||
<el-card>
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center;"><span>年度预算 → 月度自动分解</span><el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button></div></template>
|
||||
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
|
||||
<el-form-item label="年份"><el-select v-model="decomposeForm.year" style="width:150px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
|
||||
<el-form-item label="分解方式"><el-radio-group v-model="decomposeForm.method"><el-radio value="equal">均分(1/12)</el-radio><el-radio value="weighted">按历史权重</el-radio></el-radio-group></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
|
||||
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="120" /><el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
|
||||
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
|
||||
<el-table-column prop="method" label="方式" width="80" /><el-table-column prop="monthly_count" label="月数" width="60" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="战略预算编制" name="strategy">
|
||||
<div v-if="!selectedMap" style="padding:40px 0;text-align:center;color:#999;"><p>请先在预算录入中选择战略地图</p></div>
|
||||
<template v-else>
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<span style="font-weight:500;">{{ selectedMap.title }}</span><el-tag size="small" type="success">已发布</el-tag>
|
||||
<el-select v-model="strategyFilterYear" placeholder="年份" style="width:100px;" @change="loadStrategyBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="strategyFilterMonth" placeholder="月份" style="width:90px;" @change="loadStrategyBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
|
||||
<el-button type="primary" size="small" @click="loadStrategyBudget">刷新</el-button>
|
||||
<el-divider direction="vertical" /><span style="font-size:13px;color:#666;">已选 {{ strategySelectedKpis.length }} 个KPI</span>
|
||||
<el-button size="small" @click="strategyBatchSetSame">统一设值</el-button>
|
||||
<el-input-number v-model="strategyBatchValue" :min="0" controls-position="right" style="width:120px;" size="small" />
|
||||
<el-button v-if="strategySelectedKpis.length > 0" size="small" type="primary" :loading="strategyBatchSaving" @click="strategyBatchSave">批量保存</el-button>
|
||||
</div>
|
||||
<div v-for="dim in strategyTree" :key="dim.key" class="strategy-dim-block">
|
||||
<div class="strategy-dim-header" :style="{ borderLeftColor: dim.color }">
|
||||
<span class="strategy-dim-icon">{{ dim.icon }}</span><span class="strategy-dim-name">{{ dim.name }}</span>
|
||||
<span class="strategy-dim-summary">({{ dim.kpiCount }}个KPI · 预算合计:{{ formatNumber(dim.totalBudget) }})</span>
|
||||
<el-button size="small" link style="margin-left:auto;" @click="strategySelectDim(dim.key)">全选</el-button>
|
||||
</div>
|
||||
<div v-for="obj in dim.objectives" :key="obj.name" class="strategy-obj-block">
|
||||
<div class="strategy-obj-header"><span class="strategy-obj-name">{{ obj.name }}</span></div>
|
||||
<el-table :data="obj.kpis" border stripe size="small" style="width:100%;" :show-header="false" @selection-change="(sel: any[]) => onStrategyKpiSelect(sel, dim.key)">
|
||||
<el-table-column type="selection" width="36" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
|
||||
<el-table-column label="预算值" width="190">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:150px;" @change="row._changed = (row._editValue !== (row.budget_value ?? 0))" />
|
||||
<span style="font-size:12px;color:#999;margin-left:2px;">{{ row.unit }}</span>
|
||||
<el-button v-if="row._changed" size="small" type="primary" link @click="saveStrategyKpi(row)" :loading="row._saving" style="margin-left:4px;">保存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="period" label="期间" width="90" /><el-table-column prop="version" label="版本" width="60" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="版本管理" name="versions">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<el-select v-model="versionYear" placeholder="年份" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-button type="primary" @click="loadVersions">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="versionsList" v-loading="versionsLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="version" label="版本" width="90"><template #default="{ row }"><el-tag :type="row.version === currentVersion ? 'success' : 'info'" size="small">{{ row.version }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="approval_status" label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag v-if="row.approval_status === 'approved'" type="success" size="small">已批准</el-tag><el-tag v-else-if="row.approval_status === 'submitted'" type="warning" size="small">待审批</el-tag><el-tag v-else-if="row.approval_status === 'rejected'" type="danger" size="small">已驳回</el-tag><el-tag v-else type="info" size="small">草稿</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plan_count" label="预算条目数" width="100" />
|
||||
<el-table-column prop="total_budget" label="预算总额" width="130"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
|
||||
<el-table-column prop="last_updated" label="最后更新" min-width="150" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.approval_status === 'draft'" size="small" @click="submitVersion(row)">提交审批</el-button>
|
||||
<template v-if="row.approval_status === 'submitted'"><el-button size="small" type="success" @click="approveVersion(row, 'approved')">批准</el-button><el-button size="small" type="danger" @click="approveVersion(row, 'rejected')">驳回</el-button></template>
|
||||
<el-button v-if="row.approval_status === 'rejected'" size="small" @click="resubmitVersion(row)">重新提交</el-button>
|
||||
<el-checkbox v-model="row._selected" @change="onVersionSelect(row)" :disabled="selectedVersions.length >= 2 && !row._selected" style="margin-left:8px;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="selectedVersions.length === 2" style="margin-top:16px;"><el-button type="primary" @click="loadVersionDiff" :loading="diffLoading">对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}</el-button></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="预算执行" name="execution">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="execFilterYear" placeholder="年份" style="width:100px;" @change="loadExecutionReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="execFilterMonth" placeholder="截止月份" style="width:110px;" @change="loadExecutionReport"><el-option v-for="m in 12" :key="m" :label="`至${m}月`" :value="m" /></el-select>
|
||||
<el-select v-model="execFilterDim" placeholder="维度" clearable style="width:110px;" @change="loadExecutionReport"><el-option label="财务" value="finance" /><el-option label="客户" value="customer" /><el-option label="内部流程" value="process" /><el-option label="学习成长" value="learning" /></el-select>
|
||||
<el-button type="primary" @click="loadExecutionReport">刷新</el-button>
|
||||
</div>
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6" v-for="card in execSummaryCards" :key="card.label"><el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ card.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: card.color}">{{ card.value }}</div></div></el-card></el-col>
|
||||
</el-row>
|
||||
<el-table :data="execReport" v-loading="execLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
|
||||
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
|
||||
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
|
||||
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
|
||||
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
|
||||
<el-form :model="addForm" label-width="80px">
|
||||
<el-form-item label="KPI"><el-select v-model="addForm.kpi_id" placeholder="搜索并选择KPI" filterable remote :remote-method="searchKpiForAdd" :loading="addKpiLoading" style="width:100%;" :teleported="false"><el-option v-for="k in addKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" /></el-select></el-form-item>
|
||||
<el-form-item label="年份"><el-select v-model="addForm.year" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
|
||||
<el-form-item label="月份"><el-select v-model="addForm.month" style="width:120px;"><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select></el-form-item>
|
||||
<el-form-item label="预算值"><el-input-number v-model="addForm.value" :min="0" :precision="0" style="width:200px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="showAddBudget = false">取消</el-button><el-button type="primary" @click="doAddBudget" :loading="addSaving">创建</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<MyDialog v-model="showVersionDiff" title="版本差异对比" :width="900">
|
||||
<template v-if="diffData">
|
||||
<el-alert show-icon :closable="false" style="margin-bottom:12px;"><template #title><span>对比 {{ diffData.summary.version_a }} → {{ diffData.summary.version_b }}</span></template></el-alert>
|
||||
<el-table :data="diffData.diffs" border stripe size="small" max-height="500" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="名称" min-width="140" /><el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="old_value" label="旧值" width="110"><template #default="{ row }">{{ formatNumber(row.old_value) }}</template></el-table-column>
|
||||
<el-table-column prop="new_value" label="新值" width="110"><template #default="{ row }">{{ formatNumber(row.new_value) }}</template></el-table-column>
|
||||
<el-table-column prop="diff_value" label="差值" width="110"><template #default="{ row }"><span :style="{color: row.diff_value > 0 ? '#f56c6c' : row.diff_value < 0 ? '#67c23a' : '#999'}">{{ row.diff_value > 0 ? '+' : '' }}{{ formatNumber(row.diff_value) }}</span></template></el-table-column>
|
||||
<el-table-column prop="diff_rate" label="变动率" width="90"><template #default="{ row }"><el-tag v-if="row.diff_rate === 0" size="small" type="info">持平</el-tag><el-tag v-else :type="row.diff_rate > 0 ? 'danger' : 'success'" size="small">{{ row.diff_rate > 0 ? '+' : '' }}{{ row.diff_rate }}%</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template #footer><el-button @click="showVersionDiff = false">关闭</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 行动方案关联弹窗 -->
|
||||
<MyDialog v-model="showActionDialog" :title="actionDialogTitle" :width="600">
|
||||
<template v-if="actionPlansForKpi.length > 0">
|
||||
<div style="margin-bottom:12px;font-size:13px;color:#666;">已关联 {{ actionPlansForKpi.length }} 项行动方案</div>
|
||||
<div v-for="plan in actionPlansForKpi" :key="plan.id" style="border:1px solid #e8e8e8;border-radius:8px;padding:12px;margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||
<div>
|
||||
<div style="font-weight:500;">{{ plan.title }}</div>
|
||||
<div v-if="plan.description" style="font-size:12px;color:#888;margin-top:4px;">{{ plan.description }}</div>
|
||||
</div>
|
||||
<el-tag v-if="plan.status === 'completed'" type="success" size="small">已完成</el-tag>
|
||||
<el-tag v-else-if="plan.status === 'in_progress'" type="warning" size="small">进行中</el-tag>
|
||||
<el-tag v-else type="info" size="small">{{ plan.status }}</el-tag>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;margin-top:8px;font-size:12px;color:#999;">
|
||||
<span>负责人: {{ plan.assignee || '未指定' }}</span>
|
||||
<span v-if="plan.due_date">截止: {{ plan.due_date?.slice(0,10) }}</span>
|
||||
<span>优先级: {{ plan.priority }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else style="padding:40px 0;text-align:center;color:#999;">
|
||||
<p>暂无关联行动方案</p>
|
||||
<p style="font-size:12px;margin-top:4px;">非财务维度KPI需要配套行动方案才能将战略意图转化为实际行动</p>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<el-button type="primary" @click="goCreateActionPlan">+ 新建行动方案</el-button>
|
||||
<el-button @click="goActionPlansPage">去行动方案库查看</el-button>
|
||||
</div>
|
||||
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const activeTab = ref('input')
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
|
||||
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref('')
|
||||
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
|
||||
const batchEditMode = ref(false); const batchSavingAll = ref(false)
|
||||
|
||||
// ── 战略地图选择 ──
|
||||
const publishedMaps = ref<any[]>([])
|
||||
const selectedMapId = ref<number | null>(null)
|
||||
const selectedMap = ref<any>(null)
|
||||
|
||||
async function loadPublishedMaps() {
|
||||
try {
|
||||
const r: any = await mapApi.list(); const maps = Array.isArray(r) ? r : (r.data || r.items || [])
|
||||
const published = maps.filter((m: any) => m.status === 'published')
|
||||
.sort((a: any, b: any) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime())
|
||||
publishedMaps.value = published
|
||||
noMap.value = published.length === 0
|
||||
// 如果有默认选中的地图ID没变,保持
|
||||
if (selectedMapId.value && published.find(m => m.id === selectedMapId.value)) return
|
||||
// 默认选中第一个
|
||||
if (published.length > 0) {
|
||||
selectedMapId.value = published[0].id
|
||||
selectedMap.value = published[0]
|
||||
}
|
||||
} catch { publishedMaps.value = []; noMap.value = true }
|
||||
}
|
||||
|
||||
function onMapSelect(id: number) {
|
||||
selectedMap.value = publishedMaps.value.find(m => m.id === id) || null
|
||||
loadBudget()
|
||||
}
|
||||
|
||||
function getMapKpiCodes(): Set<string> {
|
||||
const codes = new Set<string>(); if (!selectedMap.value) return codes
|
||||
let dims = selectedMap.value.dimensions
|
||||
if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { return codes } }
|
||||
if (!Array.isArray(dims)) return codes
|
||||
for (const dim of dims) for (const obj of (dim.objectives || [])) for (const code of (obj.kpis || [])) if (code) codes.add(code)
|
||||
return codes
|
||||
}
|
||||
|
||||
const budgetViewMode = ref('list'); const summaryCards = ref<any[]>([]); const dimSummaryRows = ref<any[]>([])
|
||||
function onViewModeChange(mode: string) { if (mode === 'summary') loadSummary() }
|
||||
function loadSummary() {
|
||||
const cfg: Record<string, any> = { finance: { n: '财务维度', i: '💰', c: '#409eff' }, customer: { n: '客户维度', i: '🤝', c: '#67c23a' }, process: { n: '内部流程', i: '⚙️', c: '#e6a23c' }, learning: { n: '学习成长', i: '📚', c: '#f56c6c' } }
|
||||
const dd: Record<string, { t: number; n: number }> = {}
|
||||
for (const item of budgetList.value) { const d = item.dimension || 'other'; if (!dd[d]) dd[d] = { t: 0, n: 0 }; dd[d].t += (item.budget_value || 0); dd[d].n++ }
|
||||
const tb = Object.values(dd).reduce((s, v) => s + v.t, 0); const cards: any[] = []; const rows: any[] = []
|
||||
for (const [k, v] of Object.entries(dd)) { const c = cfg[k] || { n: k, i: '📊', c: '#909399' }; const r = tb > 0 ? Math.round(v.t / tb * 100) : 0; cards.push({ key: k, name: c.n, icon: c.i, color: c.c, totalBudget: v.t, kpiCount: v.n, ratio: r }); rows.push({ dimension: k, kpiCount: v.n, totalBudget: v.t, ratio: r, avgPerKpi: v.n > 0 ? Math.round(v.t / v.n) : 0 }) }
|
||||
summaryCards.value = cards; dimSummaryRows.value = rows
|
||||
}
|
||||
|
||||
const showAddBudget = ref(false)
|
||||
const addForm = ref({ kpi_id: null as any, year: currentYear, month: new Date().getMonth() + 1, value: 0 })
|
||||
const addKpiOptions = ref<any[]>([]); const addKpiLoading = ref(false); const addSaving = ref(false)
|
||||
async function searchKpiForAdd(query: string) {
|
||||
addKpiLoading.value = true
|
||||
try { const r: any = await kpiApi.list({ keyword: query, page_size: 20 }); const d = r.data || r || []; addKpiOptions.value = Array.isArray(d) ? d : (d.items || []) } catch { }
|
||||
addKpiLoading.value = false
|
||||
}
|
||||
async function doAddBudget() {
|
||||
if (!addForm.value.kpi_id) { ElMessage.warning('请选择KPI'); return }; addSaving.value = true
|
||||
try { await budgetApi.create({ kpi_id: addForm.value.kpi_id, period: `${addForm.value.year}-${String(addForm.value.month).padStart(2, '0')}`, budget_value: addForm.value.value, budget_year: addForm.value.year, budget_month: addForm.value.month }); ElMessage.success('预算已创建'); showAddBudget.value = false; loadBudget(); addForm.value = { kpi_id: null, year: currentYear, month: new Date().getMonth() + 1, value: 0 } } catch (e: any) { ElMessage.error(e?.response?.data?.detail || e?.message || '创建失败') }
|
||||
addSaving.value = false
|
||||
}
|
||||
|
||||
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
|
||||
function dimLabel(d: string) { return dimMap[d] || d }; function dimTag(d: string) { return dimTagMap[d] || 'info' }
|
||||
function formatNumber(v: any, unit?: string) { if (v === null || v === undefined) return '--'; const n = Number(v); const p = unit === '%' ? 2 : 0; return n.toLocaleString('zh-CN', { minimumFractionDigits: p, maximumFractionDigits: p }) + (unit ? ` ${unit}` : '') }
|
||||
function rowClass({ row }: any) { return row._changed ? 'row-changed' : '' }
|
||||
|
||||
async function loadBudget() {
|
||||
if (batchEditMode.value && changedRows.value.length > 0) { try { await ElMessageBox.confirm(`有 ${changedRows.value.length} 条修改未保存,是否放弃?`, '提示', { confirmButtonText: '放弃修改', cancelButtonText: '取消' }) } catch { return } }
|
||||
if (!selectedMap.value) return
|
||||
const mapCodes = getMapKpiCodes()
|
||||
if (mapCodes.size === 0) { budgetList.value = []; total.value = 0; return }
|
||||
noMap.value = false; loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }; if (filterYear.value) params.year = filterYear.value; if (filterMonth.value > 0) params.budget_month = filterMonth.value; if (searchKpi.value) params.keyword = searchKpi.value
|
||||
const r: any = await budgetApi.list(params); const d = r.data || r || []; const allPlans = (Array.isArray(d) ? d : (d.items || [])) as any[]
|
||||
// 只保留选中地图关联KPI的预算记录
|
||||
const plans = allPlans.filter((p: any) => mapCodes.has(p.kpi_code))
|
||||
const kr: any = await kpiApi.list({ page_size: 100, keyword: searchKpi.value || undefined }); const kd = kr.data || kr || []; const ak = Array.isArray(kd) ? kd : (kd.items || [])
|
||||
const mk = ak.filter((k: any) => mapCodes.has(k.kpi_code)); const pk = new Set(plans.map((p: any) => p.kpi_code))
|
||||
const ek = mk.filter((k: any) => !pk.has(k.kpi_code)).map((k: any) => ({ kpi_id: k.id, kpi_code: k.kpi_code, kpi_name: k.kpi_name, dimension: k.dimension, unit: k.unit || '', period: `${filterYear.value || currentYear}-${String(filterMonth.value || 1).padStart(2, '0')}`, budget_value: null, version: 'v1.0', status: '', precision: 0, step: 1 }))
|
||||
budgetList.value = [...plans.map((item: any) => initRow({ ...item, _empty: false })), ...ek.map(item => initRow({ ...item, _empty: true }))]
|
||||
total.value = budgetList.value.length; if (budgetViewMode.value === 'summary') loadSummary()
|
||||
// 加载非财务维度KPI的行动方案计数
|
||||
loadActionPlanCounts()
|
||||
} catch (e) { ElMessage.error('加载预算数据失败') }; loading.value = false
|
||||
}
|
||||
function initRow(item: any) { return { ...item, _editing: false, _editValue: item.budget_value ?? 0, _originalValue: item.budget_value, _saving: false, _changed: false } }
|
||||
|
||||
// ── 行动方案关联 ──
|
||||
const showActionDialog = ref(false)
|
||||
const actionDialogTitle = ref('')
|
||||
const actionPlansForKpi = ref<any[]>([])
|
||||
const actionDialogCurrentRow = ref<any>(null)
|
||||
|
||||
async function loadActionPlanCounts() {
|
||||
// 筛选出非财务维度且有关联kpi_id的行
|
||||
const rows = budgetList.value.filter(r => r.dimension !== 'finance' && r.kpi_id)
|
||||
for (const row of rows) {
|
||||
row._actionCount = null // 重置
|
||||
try {
|
||||
const r: any = await actionPlanApi.list({ kpi_id: row.kpi_id, page_size: 1 })
|
||||
const d = r.data || []
|
||||
row._actionCount = Array.isArray(d) ? d.length : 0
|
||||
} catch { row._actionCount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function showActionPlans(row: any) {
|
||||
actionDialogCurrentRow.value = row
|
||||
actionDialogTitle.value = `行动方案 - ${row.kpi_name || row.kpi_code}`
|
||||
actionPlansForKpi.value = []
|
||||
if (!row.kpi_id) { showActionDialog.value = true; return }
|
||||
actionPlanApi.list({ kpi_id: row.kpi_id }).then((r: any) => {
|
||||
const d = r.data || []
|
||||
actionPlansForKpi.value = Array.isArray(d) ? d : []
|
||||
}).catch(() => { actionPlansForKpi.value = [] })
|
||||
showActionDialog.value = true
|
||||
}
|
||||
|
||||
function goCreateActionPlan() {
|
||||
const row = actionDialogCurrentRow.value
|
||||
if (!row) return
|
||||
// 跳转到差异分析页面并预填KPI
|
||||
window.open(`/action-plans?kpi_id=${row.kpi_id}&kpi_name=${encodeURIComponent(row.kpi_name || '')}`, '_blank')
|
||||
}
|
||||
|
||||
function goActionPlansPage() {
|
||||
window.open('/action-plans', '_blank')
|
||||
}
|
||||
|
||||
function onBatchEditToggle(val: boolean) { budgetList.value.forEach(r => { r._editing = val; r._editValue = r.budget_value ?? 0; r._originalValue = r.budget_value; r._changed = false }) }
|
||||
function onEditChange(row: any) { row._changed = row._editValue !== row._originalValue }
|
||||
const changedRows = computed(() => budgetList.value.filter(r => r._changed))
|
||||
async function batchSaveAll() {
|
||||
const ts = changedRows.value; if (ts.length === 0) return; batchSavingAll.value = true; let ok = 0; let no = 0
|
||||
for (const r of ts) { try { if (r._empty || !r.id) { await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }) } else { await budgetApi.update(r.id, { budget_value: r._editValue }) }; r.budget_value = r._editValue; r._originalValue = r._editValue; r._changed = false; ok++ } catch { no++ } }
|
||||
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); batchSavingAll.value = false
|
||||
}
|
||||
function batchCancelAll() { budgetList.value.forEach(r => { r._editValue = r._originalValue ?? 0; r._changed = false }) }
|
||||
function startEdit(row: any) { row._editing = true; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value }
|
||||
function cancelEdit(row: any) { row._editing = false; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value; row._changed = false }
|
||||
async function saveEdit(row: any) {
|
||||
row._saving = true
|
||||
try { if (row._empty || !row.id) { await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }); ElMessage.success('预算已创建') } else { await budgetApi.update(row.id, { budget_value: row._editValue }); ElMessage.success('已更新') }; row.budget_value = row._editValue; row._originalValue = row._editValue; row._editing = false; row._changed = false; row._empty = false } catch (e) { ElMessage.error('保存失败') }
|
||||
row._saving = false
|
||||
}
|
||||
async function doDelete(row: any) { try { await ElMessageBox.confirm(`确认删除「${row.kpi_name || row.kpi_code}」的预算?`, '确认'); await budgetApi.delete(row.id); ElMessage.success('已删除'); loadBudget() } catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') } }
|
||||
|
||||
const showDecompose = ref(false); const decomposeForm = ref({ year: currentYear, method: 'equal' }); const decomposing = ref(false); const decomposeResult = ref(''); const decomposeDetails = ref<any[]>([])
|
||||
const strategyTree = ref<any[]>([]); const strategyFilterYear = ref(currentYear); const strategyFilterMonth = ref(0); const strategyAllBudgetPlans = ref<any[]>([])
|
||||
|
||||
async function loadStrategyBudget() {
|
||||
if (!selectedMap.value) return
|
||||
try { const p: any = { budget_year: strategyFilterYear.value }; if (strategyFilterMonth.value > 0) p.budget_month = strategyFilterMonth.value; const r: any = await budgetApi.list(p); const d = r.data || r || []; const plans = (Array.isArray(d) ? d : (d.items || [])) as any[]; strategyAllBudgetPlans.value = plans
|
||||
let dims = selectedMap.value.dimensions; if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { dims = [] } }; if (!Array.isArray(dims) || dims.length === 0) { strategyTree.value = []; return }
|
||||
const kr: any = await kpiApi.list({ page_size: 100 }); const kd = kr.data || kr || []; const km = new Map<string, any>(); (Array.isArray(kd) ? kd : (kd.items || [])).forEach((k: any) => km.set(k.kpi_code, k))
|
||||
const tree: any[] = []
|
||||
for (const dim of dims) { const dt = { budget: 0, kpiCount: 0 }; const objs = (dim.objectives || []).map((obj: any) => { const kpis = (obj.kpis || []).map((code: string) => { const kpi = km.get(code); const plan = plans.find((p: any) => p.kpi_code === code); const bv = plan ? plan.budget_value : null; dt.kpiCount++; if (bv) dt.budget += bv; return { kpi_code: code, kpi_id: kpi?.id || null, kpi_name: kpi?.kpi_name || code, unit: kpi?.unit || '', precision: kpi?.precision || 0, step: kpi?.step || 1, dimension: dim.key, period: plan?.period || `${strategyFilterYear.value}-${String(strategyFilterMonth.value || 1).padStart(2, '0')}`, version: plan?.version || 'v1.0', budget_value: bv, plan_id: plan?.id || null, _editValue: bv ?? 0, _changed: false, _saving: false, _dimKey: dim.key } }).filter(Boolean); return { name: obj.name, kpis } }); tree.push({ key: dim.key, name: dim.name, icon: dim.icon, color: dim.color, kpiCount: dt.kpiCount, totalBudget: dt.budget, objectives: objs }) }
|
||||
strategyTree.value = tree
|
||||
} catch (e: any) { ElMessage.error('加载战略预算数据失败: ' + (e?.response?.data?.detail || e?.message || '未知错误')) }
|
||||
}
|
||||
|
||||
const strategySelectedKpis = ref<any[]>([]); const strategyBatchValue = ref(0); const strategyBatchSaving = ref(false)
|
||||
function onStrategyKpiSelect(sel: any[], dimKey: string) { sel.forEach((i: any) => { i._dimKey = dimKey }); strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...sel] }
|
||||
function strategySelectDim(dimKey: string) { const dim = strategyTree.value.find((d: any) => d.key === dimKey); if (!dim) return; const all: any[] = []; for (const o of dim.objectives) for (const k of o.kpis) { k._dimKey = dimKey; all.push(k) }; strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...all] }
|
||||
function strategyBatchSetSame() { strategySelectedKpis.value.forEach((k: any) => { k._editValue = strategyBatchValue.value; k._changed = (k._editValue !== (k.budget_value ?? 0)) }) }
|
||||
async function strategyBatchSave() {
|
||||
const ts = strategySelectedKpis.value.filter((k: any) => k._changed); if (ts.length === 0) { ElMessage.warning('没有需要保存的变更'); return }; strategyBatchSaving.value = true; let ok = 0; let no = 0
|
||||
for (const r of ts) { try { if (r.plan_id) { await budgetApi.update(r.plan_id, { budget_value: r._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); r.plan_id = rr.id }; r.budget_value = r._editValue; r._changed = false; ok++ } catch { no++ } }
|
||||
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); strategyBatchSaving.value = false; loadStrategyBudget()
|
||||
}
|
||||
async function saveStrategyKpi(row: any) {
|
||||
row._saving = true
|
||||
try { if (row.plan_id) { await budgetApi.update(row.plan_id, { budget_value: row._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); row.plan_id = rr.id }; row.budget_value = row._editValue; row._changed = false; ElMessage.success('已保存') } catch (e) { ElMessage.error('保存失败') }
|
||||
row._saving = false
|
||||
}
|
||||
|
||||
async function submitVersion(row: any) { try { await ElMessageBox.confirm(`确认将版本「${row.version}」提交审批?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已提交审批'); loadVersions() } catch { } }
|
||||
async function approveVersion(row: any, action: string) { const l = action === 'approved' ? '批准' : '驳回'; try { await ElMessageBox.confirm(`确认${l}版本「${row.version}」?`, '确认'); await budgetApi.versionApprove({ version: row.version, action }); ElMessage.success(`已${l}`); loadVersions() } catch { } }
|
||||
async function resubmitVersion(row: any) { try { await ElMessageBox.confirm(`确认重新提交版本「${row.version}」?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已重新提交'); loadVersions() } catch { } }
|
||||
|
||||
const versionYear = ref(currentYear); const versionsList = ref<any[]>([]); const versionsLoading = ref(false); const selectedVersions = ref<any[]>([]); const showVersionDiff = ref(false); const diffLoading = ref(false); const diffData = ref<any>(null); const currentVersion = ref('')
|
||||
async function loadVersions() { versionsLoading.value = true; try { const r: any = await budgetApi.versions({ year: versionYear.value }); const d = r.data || []; versionsList.value = d.map((v: any) => ({ ...v, _selected: false })); currentVersion.value = d[0]?.version || '' } catch (e) { ElMessage.error('加载版本列表失败') }; versionsLoading.value = false }
|
||||
function onVersionSelect(row: any) { selectedVersions.value = versionsList.value.filter((v: any) => v._selected); if (selectedVersions.value.length > 2) { row._selected = false; selectedVersions.value = versionsList.value.filter((v: any) => v._selected) } }
|
||||
async function loadVersionDiff() { if (selectedVersions.value.length !== 2) return; diffLoading.value = true; try { const [a, b] = selectedVersions.value; const r: any = await budgetApi.versionDiff({ version_a: a.version, version_b: b.version, year: versionYear.value }); diffData.value = r; showVersionDiff.value = true } catch (e) { ElMessage.error('加载版本对比失败') }; diffLoading.value = false }
|
||||
|
||||
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
|
||||
async function loadExecutionReport() {
|
||||
execLoading.value = true
|
||||
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
|
||||
execLoading.value = false
|
||||
}
|
||||
|
||||
async function doDecompose() {
|
||||
decomposing.value = true; decomposeResult.value = ''; decomposeDetails.value = []
|
||||
try { const r: any = await budgetApi.autoDecompose({ year: decomposeForm.value.year, method: decomposeForm.value.method }); decomposeResult.value = r.message || '分解成功'; if (r.results) decomposeDetails.value = r.results.map((res: any) => ({ kpi_code: res.kpi_code, kpi_name: res.kpi_name, annual_budget: res.annual_budget, method: res.method === 'equal' ? '均分' : '加权', monthly_count: res.monthly.length })); ElMessage.success('年度预算分解完成'); loadBudget() } catch (e: any) { ElMessage.error(e.detail || e.message || '分解失败') }
|
||||
decomposing.value = false
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'decompose') loadBudget()
|
||||
else if (tab === 'strategy') loadStrategyBudget()
|
||||
else if (tab === 'versions') loadVersions()
|
||||
else if (tab === 'execution') loadExecutionReport()
|
||||
})
|
||||
onMounted(async () => {
|
||||
await loadPublishedMaps()
|
||||
loadBudget()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.row-changed) { --el-table-tr-bg-color: #fff7e6 !important; }
|
||||
.strategy-dim-block { margin-bottom: 20px; border: 1px solid #ebeef5; border-radius: 8px; overflow: hidden; }
|
||||
.strategy-dim-header { padding: 10px 16px; background: #f5f7fa; border-left: 4px solid #409eff; display: flex; align-items: center; gap: 8px; }
|
||||
.strategy-dim-icon { font-size: 20px; }
|
||||
.strategy-dim-name { font-weight: 600; font-size: 15px; }
|
||||
.strategy-dim-summary { font-size: 12px; color: #999; }
|
||||
.strategy-obj-block { padding: 8px 16px; }
|
||||
.strategy-obj-block:not(:last-child) { border-bottom: 1px solid #f0f0f0; }
|
||||
.strategy-obj-header { padding: 6px 0 8px; font-size: 13px; color: #555; font-weight: 500; }
|
||||
.strategy-obj-name::before { content: '◆ '; color: #999; font-size: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="kb-page">
|
||||
<div class="kb-header">
|
||||
<h3>📖 CMA知识库</h3>
|
||||
<el-input v-model="search" placeholder="搜索术语、计算公式、最佳实践..." prefix-icon="Search" clearable style="width:360px;" />
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="search" class="search-results">
|
||||
<h4>搜索结果({{ searchResults.length }}条)</h4>
|
||||
<div v-for="item in searchResults" :key="item.title" class="search-item" @click="openItem(item)">
|
||||
<span class="search-cat-tag">{{ catLabel(item.category) }}</span>
|
||||
<span class="search-title">{{ item.title }}</span>
|
||||
<span class="search-summary">{{ item.summary }}</span>
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0" class="empty-search">未找到相关内容</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类展示 -->
|
||||
<div v-else class="kb-grid">
|
||||
<div v-for="(cat, ci) in categories" :key="ci" class="kb-category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-icon">{{ cat.icon }}</span>
|
||||
<span class="cat-title">{{ cat.name }}</span>
|
||||
<span class="cat-count">{{ cat.items.length }}篇</span>
|
||||
</div>
|
||||
<div class="cat-body">
|
||||
<div v-for="(item, ii) in cat.items" :key="ii" class="kb-item" @click="openItem(item)">
|
||||
<span class="kb-item-title">{{ item.title }}</span>
|
||||
<span class="kb-item-summary">{{ item.summary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="showDetail" :title="detailItem?.title" width="700" top="5vh">
|
||||
<div v-if="detailItem" class="detail-content" v-html="detailItem.content"></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue"
|
||||
|
||||
const search = ref("")
|
||||
const showDetail = ref(false)
|
||||
const detailItem = ref<any>(null)
|
||||
|
||||
// ── 分类配置 ──
|
||||
const categories = ref([
|
||||
{
|
||||
name: "术语解释", icon: "📖", key: "term",
|
||||
items: [
|
||||
{
|
||||
title: "KPI(关键绩效指标)",
|
||||
summary: "衡量战略目标达成情况的量化指标",
|
||||
content: `<h4>KPI(Key Performance Indicator)</h4>
|
||||
<p>关键绩效指标,是衡量企业战略目标达成情况的量化指标。在管理会计OS中,KPI贯穿战略地图、驾驶舱、预警中心等所有模块。</p>
|
||||
<h5>特征</h5>
|
||||
<ul>
|
||||
<li><b>可量化</b>:每个KPI有明确的计算公式和数值</li>
|
||||
<li><b>有时效</b>:按日/周/月/季/年采集和考核</li>
|
||||
<li><b>有标准</b>:每个KPI设定了目标值和红黄绿灯阈值</li>
|
||||
</ul>
|
||||
<h5>编码规则</h5>
|
||||
<p>KPI编码按 BSC维度_类别_序号 命名,如 <code>F_REVENUE_001</code>:</p>
|
||||
<ul>
|
||||
<li>F = 财务维度 (Finance)</li>
|
||||
<li>C = 客户维度 (Customer)</li>
|
||||
<li>P = 内部流程 (Process)</li>
|
||||
<li>L = 学习成长 (Learning)</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "BSC(平衡计分卡)",
|
||||
summary: "从财务、客户、内部流程、学习成长四个维度衡量企业绩效",
|
||||
content: `<h4>BSC(Balanced Scorecard)</h4>
|
||||
<p>平衡计分卡,由Kaplan和Norton提出,从四个维度衡量企业绩效:</p>
|
||||
<ul>
|
||||
<li><b>财务维度</b>:最终结果指标,如收入、利润、成本</li>
|
||||
<li><b>客户维度</b>:市场反馈指标,如客户数量、满意度</li>
|
||||
<li><b>内部流程</b>:运营效率指标,如交付及时率、库存周转</li>
|
||||
<li><b>学习成长</b>:驱动因素指标,如培训完成率、人才梯队</li>
|
||||
</ul>
|
||||
<p>四个维度形成因果链:<b>学习成长 → 内部流程 → 客户 → 财务</b></p>`,
|
||||
},
|
||||
{
|
||||
title: "红黄绿灯预警",
|
||||
summary: "KPI达成情况的颜色标识:绿=正常 黄=预警 红=异常",
|
||||
content: `<h4>红黄绿灯预警机制</h4>
|
||||
<p>系统自动根据KPI实际值与目标值的比率,生成三种预警等级:</p>
|
||||
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
|
||||
<tr style="background:#f5f5f5;"><th>颜色</th><th>等级</th><th>判定标准</th><th>建议动作</th></tr>
|
||||
<tr><td style="color:#67c23a;font-weight:bold;">🟢 绿色</td><td>正常</td><td>达成率 ≥ 90%</td><td>保持现状</td></tr>
|
||||
<tr><td style="color:#e6a23c;font-weight:bold;">🟡 黄色</td><td>预警</td><td>70% ≤ 达成率 < 90%</td><td>关注趋势,制定改善计划</td></tr>
|
||||
<tr><td style="color:#f56c6c;font-weight:bold;">🔴 红色</td><td>异常</td><td>达成率 < 70%</td><td>立即分析原因,启动改善行动</td></tr>
|
||||
</table>`,
|
||||
},
|
||||
{
|
||||
title: "战略地图",
|
||||
summary: "用BSC四维框架可视化展示战略目标与因果关系的工具",
|
||||
content: `<h4>战略地图</h4>
|
||||
<p>战略地图是平衡计分卡的图形化表达,将战略目标按BSC四个维度排列,并用箭头展示因果驱动关系。</p>
|
||||
<h5>使用方法</h5>
|
||||
<ol>
|
||||
<li>在「战略地图」页面创建一张新地图</li>
|
||||
<li>在每个维度下添加战略目标</li>
|
||||
<li>给目标关联KPI,用于量化衡量</li>
|
||||
<li>从低维度向高维度画因果连线(如 人才培养→流程优化→客户满意→收入增长)</li>
|
||||
<li>发布后可在「战略回顾会」中查看执行情况</li>
|
||||
</ol>`,
|
||||
},
|
||||
{
|
||||
title: "改善行动计划(CAP)",
|
||||
summary: "针对预警问题制定的改进措施,跟踪执行和效果",
|
||||
content: `<h4>改善行动计划</h4>
|
||||
<p>当KPI出现黄色或红色预警时,需要制定改善行动计划来解决问题。</p>
|
||||
<h5>流程</h5>
|
||||
<ol>
|
||||
<li>在预警中心识别异常KPI</li>
|
||||
<li>创建改善行动计划:指定标题、负责人、截止日期、优先级</li>
|
||||
<li>执行过程中更新进度(0-100%)</li>
|
||||
<li>完成后填写改善结果</li>
|
||||
<li>系统会持续跟踪逾期和进度</li>
|
||||
</ol>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "计算公式", icon: "📐", key: "formula",
|
||||
items: [
|
||||
{
|
||||
title: "达成率",
|
||||
summary: "实际值 ÷ 目标值 × 100%",
|
||||
content: `<h4>达成率</h4>
|
||||
<p><b>公式</b>:达成率 = 实际值 / 目标值 × 100%</p>
|
||||
<h5>示例</h5>
|
||||
<p>销售总额目标2000万,实际1640万:1640/2000 = <b>82%</b></p>
|
||||
<h5>说明</h5>
|
||||
<ul>
|
||||
<li>达成率 ≥ 90% 为绿灯</li>
|
||||
<li>70% ≤ 达成率 < 90% 为黄灯</li>
|
||||
<li>达成率 < 70% 为红灯</li>
|
||||
<li>部分反向指标(如成本控制率)需取相反判断</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "偏差率",
|
||||
summary: "(实际值 - 目标值)÷ 目标值 × 100%",
|
||||
content: `<h4>偏差率</h4>
|
||||
<p><b>公式</b>:偏差率 = (实际值 - 目标值) / 目标值 × 100%</p>
|
||||
<h5>示例</h5>
|
||||
<p>预算100万,实际支出120万:(120-100)/100 = <b>+20%</b>(超支)</p>
|
||||
<h5>说明</h5>
|
||||
<ul>
|
||||
<li>正偏差 = 超过目标(可能好也可能坏,取决于指标性质)</li>
|
||||
<li>负偏差 = 低于目标</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "销售毛利率",
|
||||
summary: "(销售收入 - 销售成本)÷ 销售收入 × 100%",
|
||||
content: `<h4>销售毛利率</h4>
|
||||
<p><b>公式</b>:毛利率 = (销售收入 - 销售成本) / 销售收入 × 100%</p>
|
||||
<h5>示例</h5>
|
||||
<p>收入1000万,成本700万:(1000-700)/1000 = <b>30%</b></p>
|
||||
<h5>说明</h5>
|
||||
<p>毛利率是衡量企业盈利能力的核心指标,越高代表产品附加值越大。</p>`,
|
||||
},
|
||||
{
|
||||
title: "库存周转率",
|
||||
summary: "销售成本 ÷ 平均库存金额",
|
||||
content: `<h4>库存周转率</h4>
|
||||
<p><b>公式</b>:库存周转率 = 销售成本 / 平均库存金额</p>
|
||||
<h5>示例</h5>
|
||||
<p>年销售成本1.2亿,平均库存1500万:1.2亿/1500万 = <b>8次/年</b></p>
|
||||
<h5>说明</h5>
|
||||
<p>库存周转率反映企业存货管理效率,越高说明资金占用越少。</p>`,
|
||||
},
|
||||
{
|
||||
title: "达成率 vs 偏差率 对照",
|
||||
summary: "两个指标的区别与使用场景",
|
||||
content: `<h4>达成率 vs 偏差率</h4>
|
||||
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
|
||||
<tr style="background:#f5f5f5;"><th></th><th>达成率</th><th>偏差率</th></tr>
|
||||
<tr><td><b>公式</b></td><td>实际/目标 × 100%</td><td>(实际-目标)/目标 × 100%</td></tr>
|
||||
<tr><td><b>理想值</b></td><td>≥ 100%</td><td>接近0%</td></tr>
|
||||
<tr><td><b>适用场景</b></td><td>正向指标(收入、满意度)</td><td>预算控制、成本差异</td></tr>
|
||||
<tr><td><b>红黄绿灯</b></td><td>≥90%绿,≥70%黄,<70%红</td><td>按偏差幅度自定义</td></tr>
|
||||
</table>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "最佳实践", icon: "⭐", key: "practice",
|
||||
items: [
|
||||
{
|
||||
title: "如何绘制一张好的战略地图",
|
||||
summary: "从设定目标到画因果链的实操指南",
|
||||
content: `<h4>绘制战略地图的5个步骤</h4>
|
||||
<ol>
|
||||
<li><b>明确战略主题</b>:确定本年度最重要的3-5个战略方向</li>
|
||||
<li><b>从财务开始</b>:先在财务维度设定最终目标(如"提升销售总额30%")</li>
|
||||
<li><b>追溯驱动因素</b>:思考\"要达成财务目标,客户需要什么?流程需要怎么优化?员工需要什么能力?\"</li>
|
||||
<li><b>关联KPI</b>:每个目标绑定1-2个可量化的KPI</li>
|
||||
<li><b>画因果连线</b>:从下到上连接各维度目标,形成因果链</li>
|
||||
</ol>
|
||||
<h5>常见错误</h5>
|
||||
<ul>
|
||||
<li>❌ 每个维度设太多目标(建议不超过4个)</li>
|
||||
<li>❌ 目标不关联KPI(无法量化跟踪)</li>
|
||||
<li>❌ 因果链混乱(学习→流程→客户→财务,不要跳层)</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "预算编制的10个原则",
|
||||
summary: "预算管理模块使用的最佳实践",
|
||||
content: `<h4>预算编制最佳实践</h4>
|
||||
<ol>
|
||||
<li><b>自上而下 vs 自下而上</b>:先定总盘子,再分解到各部门</li>
|
||||
<li><b>零基预算</b>:每年从零开始编制,不以上年基数为准</li>
|
||||
<li><b>弹性预算</b>:按业务量设定不同层级的预算标准</li>
|
||||
<li><b>滚动预算</b>:每季度更新一次全年预算</li>
|
||||
<li><b>差异分析</b>:每月对比实际与预算,找出偏差原因</li>
|
||||
</ol>`,
|
||||
},
|
||||
{
|
||||
title: "预警阈值设置的技巧",
|
||||
summary: "如何设置合理的红黄绿灯阈值",
|
||||
content: `<h4>阈值设置建议</h4>
|
||||
<p>合理的红黄绿灯阈值应该:</p>
|
||||
<ul>
|
||||
<li><b>不要过严</b>:绿灯设90%而不是95%,否则大部分KPI都是黄色</li>
|
||||
<li><b>区分指标性质</b>:正向指标(收入)和反向指标(成本)的绿/红判断逻辑不同</li>
|
||||
<li><b>结合历史数据</b>:参考过去6个月的实际值来设定合理目标</li>
|
||||
<li><b>动态调整</b>:每季度review一次阈值,随业务发展调整</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "每日工作台的使用方法",
|
||||
summary: "如何高效使用我的工作台进行日常管理",
|
||||
content: `<h4>工作台使用三部曲</h4>
|
||||
<ol>
|
||||
<li><b>早上看KPI</b>:登录先看一眼我的KPI红黄绿灯,了解整体状态</li>
|
||||
<li><b>处理待办</b>:查看逾期改善行动和待审批事项</li>
|
||||
<li><b>跟踪异常</b>:红色预警的KPI立即查看详情,安排改善行动</li>
|
||||
</ol>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "常见问题", icon: "❓", key: "faq",
|
||||
items: [
|
||||
{
|
||||
title: "数据不同步怎么办?",
|
||||
summary: "ERP数据同步失败的排查步骤",
|
||||
content: `<h4>数据同步问题排查</h4>
|
||||
<ol>
|
||||
<li>检查ERP API网关是否正常运行(端口8300)</li>
|
||||
<li>在「数据管理」页面查看最近一次同步时间</li>
|
||||
<li>手动触发同步:数据管理 → 点击「手动同步」</li>
|
||||
<li>如仍失败,联系IT管理员查看后端日志</li>
|
||||
</ol>`,
|
||||
},
|
||||
{
|
||||
title: "为什么看不到某些KPI?",
|
||||
summary: "角色权限和KPI可见性的说明",
|
||||
content: `<h4>KPI可见性规则</h4>
|
||||
<ul>
|
||||
<li><b>CEO</b>:全量KPI可见</li>
|
||||
<li><b>财务</b>:财务维度KPI + 通用KPI</li>
|
||||
<li><b>业务</b>:客户、流程、学习维度KPI</li>
|
||||
<li><b>IT</b>:全量KPI可见(运维用途)</li>
|
||||
</ul>
|
||||
<p>如需调整可见范围,请联系管理员修改权限配置。</p>`,
|
||||
},
|
||||
{
|
||||
title: "如何删除一个战略目标?",
|
||||
summary: "画布上目标的编辑和删除操作",
|
||||
content: `<h4>删除战略目标</h4>
|
||||
<ol>
|
||||
<li>进入战略地图画布页面</li>
|
||||
<li>点击要删除的目标卡片(会弹出编辑弹窗)</li>
|
||||
<li>点击弹窗底部的「删除」按钮</li>
|
||||
<li>确认删除</li>
|
||||
<li>点击「保存」使改动生效</li>
|
||||
</ol>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// ── 计算属性 ──
|
||||
const searchResults = computed(() => {
|
||||
if (!search.value.trim()) return []
|
||||
const q = search.value.toLowerCase()
|
||||
const results: any[] = []
|
||||
for (const cat of categories.value) {
|
||||
for (const item of cat.items) {
|
||||
if (
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
item.summary.toLowerCase().includes(q) ||
|
||||
item.content.toLowerCase().includes(q)
|
||||
) {
|
||||
results.push({ ...item, category: cat.key })
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
// ── 方法 ──
|
||||
function catLabel(key: string) {
|
||||
const map: Record<string, string> = { term: "术语", formula: "公式", practice: "实践", faq: "FAQ" }
|
||||
return map[key] || key
|
||||
}
|
||||
|
||||
function openItem(item: any) {
|
||||
detailItem.value = item
|
||||
showDetail.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
|
||||
.kb-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.kb-header h3 { margin: 0; font-size: 20px; }
|
||||
.kb-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.kb-category { background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.cat-head { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #f0f0f0; }
|
||||
.cat-icon { font-size: 20px; }
|
||||
.cat-title { font-size: 16px; font-weight: 600; flex: 1; }
|
||||
.cat-count { font-size: 12px; color: #999; background: #f5f5f5; padding: 2px 8px; border-radius: 10px; }
|
||||
.cat-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.kb-item { padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.2s; }
|
||||
.kb-item:hover { background: #f5f7fa; }
|
||||
.kb-item-title { display: block; font-size: 14px; font-weight: 500; margin-bottom: 2px; }
|
||||
.kb-item-summary { display: block; font-size: 12px; color: #999; }
|
||||
.search-results { margin-bottom: 20px; }
|
||||
.search-results h4 { margin: 0 0 12px 0; font-size: 15px; color: #666; }
|
||||
.search-item { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 8px; cursor: pointer; background: #fff; margin-bottom: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
||||
.search-item:hover { background: #f5f7fa; }
|
||||
.search-cat-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: #ecf5ff; color: #409eff; flex-shrink: 0; }
|
||||
.search-title { font-weight: 500; font-size: 14px; flex-shrink: 0; }
|
||||
.search-summary { font-size: 12px; color: #999; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty-search { text-align: center; color: #999; padding: 40px; }
|
||||
.detail-content { line-height: 1.8; font-size: 14px; }
|
||||
.detail-content h4 { margin: 16px 0 8px; }
|
||||
.detail-content h5 { margin: 12px 0 6px; color: #666; }
|
||||
.detail-content ul, .detail-content ol { padding-left: 20px; }
|
||||
.detail-content li { margin-bottom: 4px; }
|
||||
.detail-content code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
.detail-content table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.detail-content th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
|
||||
.detail-content th { background: #f5f5f5; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div style="padding: 30px; max-width: 1000px; margin: 0 auto;">
|
||||
<h3 style="margin-bottom: 20px;">📊 图表组件测试</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px;">
|
||||
<!-- 子弹图测试 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span>🔥 实际 vs 目标(子弹图)</span></template>
|
||||
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||
<BulletChart actual="1280000" target="1500000" label="销售额" unit="元" />
|
||||
<BulletChart actual="85" target="100" label="预算执行率" unit="%" />
|
||||
<BulletChart actual="309393" target="500000" label="净利润" unit="元" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 仪表盘测试 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span>🎯 达标率(仪表盘)</span></template>
|
||||
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||
<GaugeChart actual="95" target="100" label="销售额达标率" unit="%" />
|
||||
<GaugeChart actual="62" target="100" label="毛利率达标率" unit="%" />
|
||||
<GaugeChart actual="38" target="100" label="净利润达标率" unit="%" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 瀑布图测试 -->
|
||||
<el-card shadow="never" style="margin-top: 24px;">
|
||||
<template #header><span>🌊 瀑布图 — 利润拆解</span></template>
|
||||
<div style="height: 300px;">
|
||||
<WaterfallChart :data="profitData" totalUnit="元" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BulletChart from '../components/charts/BulletChart.vue'
|
||||
import GaugeChart from '../components/charts/GaugeChart.vue'
|
||||
import WaterfallChart from '../components/charts/WaterfallChart.vue'
|
||||
|
||||
const profitData = [
|
||||
{ name: '营收', value: 1280000 },
|
||||
{ name: '成本', value: -850000 },
|
||||
{ name: '费用', value: -320000 },
|
||||
{ name: '其他收入', value: 50000 },
|
||||
{ name: '净利润', value: 160000 },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="customer-dashboard">
|
||||
<KPIListView
|
||||
ref="kpiListRef"
|
||||
:dimension="'customer'"
|
||||
:title="'客户维度KPI看板'"
|
||||
:icon="'👥'"
|
||||
:stat-labels="{ total: '客户指标总数', green: '达标', yellow: '预警', red: '未达标', noData: '无数据' }"
|
||||
:templates="CUSTOMER_TEMPLATES"
|
||||
:has-categories="false"
|
||||
:show-frequency="true"
|
||||
:name-placeholder="'客户满意度、渠道覆盖率'"
|
||||
:unit-options="['%', '元', '家', '分', '次']"
|
||||
form-title-create="新建客户KPI"
|
||||
form-title-edit="编辑客户KPI"
|
||||
/>
|
||||
|
||||
<!-- 趋势分析(客户维度特有) -->
|
||||
<el-card shadow="never" class="section-card" v-if="trendData.length > 0">
|
||||
<template #header>
|
||||
<div class="card-header-flex">
|
||||
<span>📈 关键指标趋势</span>
|
||||
<el-radio-group v-model="trendType" size="small" @change="loadTrend">
|
||||
<el-radio-button value="monthly">月度</el-radio-button>
|
||||
<el-radio-button value="quarterly">季度</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="trendLoading" style="padding:40px;text-align:center;">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
<div v-else ref="trendChartRef" style="width:100%;height:320px;"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import KPIListView from '../components/KPIListView.vue'
|
||||
import api from '../api/index'
|
||||
|
||||
// ── 客户维度预设模板 ──
|
||||
const CUSTOMER_TEMPLATES = [
|
||||
{ name: '客户满意度', unit: '%', targetValue: 90 },
|
||||
{ name: '客户留存率', unit: '%', targetValue: 85 },
|
||||
{ name: '新客户增长率', unit: '%', targetValue: 20 },
|
||||
{ name: '客户渠补率', unit: '%', targetValue: 95 },
|
||||
{ name: '客户投诉率', unit: '%', targetValue: 5 },
|
||||
{ name: '客户平均贡献值', unit: '元', targetValue: 10000 },
|
||||
{ name: '客户转化率', unit: '%', targetValue: 30 },
|
||||
{ name: '客户活跃度', unit: '%', targetValue: 70 },
|
||||
{ name: 'NPS净推荐值', unit: '分', targetValue: 60 },
|
||||
]
|
||||
|
||||
// ── 趋势分析 ──
|
||||
const kpiListRef = ref<InstanceType<typeof KPIListView> | null>(null)
|
||||
const trendType = ref('monthly')
|
||||
const trendLoading = ref(false)
|
||||
const trendData = ref<any[]>([])
|
||||
const trendChartRef = ref<HTMLElement | null>(null)
|
||||
let chartInstance: any = null
|
||||
|
||||
async function loadTrend() {
|
||||
// 从 KPIListView 内部获取当前 kpis
|
||||
// 由于 KPIListView 内部数据不暴露,直接通过 API 请求客户维度的前5个KPI
|
||||
trendLoading.value = true
|
||||
try {
|
||||
const r: any = await api.get('/kpis', { params: { dimension: 'customer', page_size: 5 } })
|
||||
const kpis = (r.data || []).slice(0, 5)
|
||||
const kpiIds = kpis.map((k: any) => k.id).filter(Boolean)
|
||||
if (kpiIds.length === 0) { trendLoading.value = false; return }
|
||||
const trendRes: any = await api.post('/dashboard/trend-analysis', {
|
||||
kpi_ids: kpiIds,
|
||||
dimension: 'customer',
|
||||
period_type: trendType.value,
|
||||
})
|
||||
trendData.value = trendRes.data || []
|
||||
trendLoading.value = false
|
||||
nextTick(() => renderChart())
|
||||
} catch {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!trendChartRef.value) return
|
||||
try {
|
||||
const chart = (window as any).echarts?.init(trendChartRef.value)
|
||||
if (chart) {
|
||||
chartInstance = chart
|
||||
const series = trendData.value.slice(0, 5).map((d: any) => ({
|
||||
name: d.kpi_name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: (d.period_values || []).map((pv: any) => pv.value),
|
||||
}))
|
||||
const months = trendData.value[0]?.period_values?.map((pv: any) => pv.period) || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: trendData.value.slice(0, 5).map((d: any) => d.kpi_name) },
|
||||
grid: { left: 60, right: 20, bottom: 30, top: 40 },
|
||||
xAxis: { type: 'category', data: months },
|
||||
yAxis: { type: 'value' },
|
||||
series,
|
||||
})
|
||||
}
|
||||
} catch { /* echarts not loaded */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 等待 KPIListView 加载完成后加载趋势
|
||||
setTimeout(() => loadTrend(), 500)
|
||||
// 尝试加载 ECharts
|
||||
if (!(window as any).echarts) {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/assets/echarts.min.js'
|
||||
script.onload = () => { nextTick(loadTrend) }
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.customer-dashboard { padding: 0; }
|
||||
.section-card { margin-bottom: 16px; margin-top: 16px; }
|
||||
.card-header-flex { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>差异分析看板</h3>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadReport">
|
||||
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
|
||||
</el-select>
|
||||
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadReport">
|
||||
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
|
||||
</el-select>
|
||||
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:110px;" @change="filterChanged">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
<el-select v-model="alertFilter" placeholder="预警级别" clearable style="width:120px;" @change="filterChanged">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="红色预警(超20%)" value="red" />
|
||||
<el-option label="黄色预警(超10%)" value="yellow" />
|
||||
<el-option label="正常(10%以内)" value="normal" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadReport">刷新</el-button>
|
||||
<el-button @click="exportReport">导出CSV</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 汇总统计卡片(维度级) -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">已录预算KPI</div>
|
||||
<div class="stat-value">{{ stats.total_kpis || 0 }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6" v-for="dim in dimensionCards" :key="dim.key">
|
||||
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + dim.color }" style="cursor:pointer;" @click="scrollToDim(dim.key)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ dim.icon }} {{ dim.name }}</div>
|
||||
<div class="stat-value" :class="dim.summary.level + '-text'">
|
||||
{{ dim.summary.deviation_rate != null ? dim.summary.deviation_rate.toFixed(1) + '%' : '-' }}
|
||||
</div>
|
||||
<div style="font-size:11px;color:#999;margin-top:2px;">
|
||||
{{ dim.summary.has_budget }}/{{ dim.summary.count }} KPI有预算
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 差异趋势图(简易柱状图) -->
|
||||
<el-card style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>差异趋势(各月实际 vs 预算)</span>
|
||||
<el-select v-model="trendKpi" placeholder="选择KPI" style="width:220px;" @change="loadTrend">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="trendData.length > 0" style="display:flex;align-items:flex-end;gap:8px;height:200px;padding:20px 0;overflow-x:auto;">
|
||||
<div v-for="(item, idx) in trendData" :key="idx" style="display:flex;flex-direction:column;align-items:center;min-width:60px;">
|
||||
<div :title="`实际: ${item.actual}, 预算: ${item.budget}`" class="trend-bar-group" style="display:flex;gap:3px;height:160px;align-items:flex-end;">
|
||||
<div
|
||||
class="trend-bar actual-bar"
|
||||
:style="{ height: barHeight(item.actual, maxTrend) + 'px' }"
|
||||
:title="`实际 ${item.actual}`"
|
||||
></div>
|
||||
<div
|
||||
class="trend-bar budget-bar"
|
||||
:style="{ height: barHeight(item.budget, maxTrend) + 'px' }"
|
||||
:title="`预算 ${item.budget}`"
|
||||
></div>
|
||||
</div>
|
||||
<span style="font-size:11px;color:#909399;margin-top:4px;">{{ item.period }}</span>
|
||||
<span v-if="item.deviation !== undefined" :style="{ fontSize:'10px', color: item.deviation > 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ item.deviation > 0 ? '+' : '' }}{{ item.deviation.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align:center;color:#999;padding:40px;">
|
||||
{{ trendLoading ? '加载中...' : '选择一个KPI查看趋势' }}
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 维度→目标→KPI 三层树形表格 -->
|
||||
<el-card v-if="hierarchyData.length > 0" :body-style="{ padding: 0 }">
|
||||
<el-table
|
||||
:data="flattenHierarchy"
|
||||
v-loading="loading"
|
||||
border stripe
|
||||
size="small"
|
||||
style="width:100%;"
|
||||
row-key="__uid"
|
||||
:tree-props="{ children: '_children' }"
|
||||
:indent="24"
|
||||
:default-expand-all="expandedAll"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column label="层级" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span v-if="row._type === 'dimension'" style="font-size:16px;">{{ row.icon }}</span>
|
||||
<span :style="{
|
||||
fontWeight: row._type === 'dimension' ? 700 : row._type === 'objective' ? 600 : 400,
|
||||
fontSize: row._type === 'dimension' ? '14px' : '13px',
|
||||
color: row._type === 'dimension' ? '#303133' : '#606266'
|
||||
}">{{ row._type === 'dimension' ? row.name + ' 维度' : row.name }}</span>
|
||||
<el-tag v-if="row._type === 'dimension'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
|
||||
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row._type === 'objective'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
|
||||
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
|
||||
</el-tag>
|
||||
<span v-if="row._type === 'dimension'" style="font-size:11px;color:#999;margin-left:8px;">
|
||||
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
|
||||
</span>
|
||||
<span v-if="row._type === 'objective'" style="font-size:11px;color:#999;margin-left:8px;">
|
||||
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" style="font-family:monospace;font-size:12px;">{{ row.kpi_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预算值" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" :style="{ color: row.budget_value != null ? '#303133' : '#999' }">
|
||||
{{ row.budget_value != null ? fmtVal(row.budget_value) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际值" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" :style="{ color: row.actual_value != null ? '#303133' : '#999' }">
|
||||
{{ row.actual_value != null ? fmtVal(row.actual_value) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" :style="{ color: (row.deviation_amount || 0) > 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ row.deviation_amount !== undefined ? fmtVal(row.deviation_amount) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异率" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row._type === 'kpi' && row.deviation_rate !== undefined" :type="deviationTag(row.deviation_rate)" size="small">
|
||||
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(2) }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="同比" width="85" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi' && row.yoy?.diff_rate != null"
|
||||
:style="{ color: (row.yoy.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
|
||||
{{ row.yoy.diff_rate > 0 ? '+' : '' }}{{ row.yoy.diff_rate.toFixed(1) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="环比" width="85" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi' && row.mom?.diff_rate != null"
|
||||
:style="{ color: (row.mom.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
|
||||
{{ row.mom.diff_rate > 0 ? '+' : '' }}{{ row.mom.diff_rate.toFixed(1) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预警" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row._type === 'kpi' && row.deviation_rate != null" :type="deviationTag(row.deviation_rate)" size="small">
|
||||
{{ row.deviation_rate > 20 ? '红色' : row.deviation_rate > 10 ? '黄色' : '正常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<el-empty v-if="!loading && hierarchyData.length === 0 && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { budgetApi, kpiApi } from '../api/index'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = computed(() => {
|
||||
const years: number[] = []
|
||||
for (let y = currentYear - 2; y <= currentYear + 2; y++) years.push(y)
|
||||
return years
|
||||
})
|
||||
|
||||
const filterYear = ref(currentYear)
|
||||
const filterMonth = ref(new Date().getMonth() + 1)
|
||||
const filterDimension = ref('')
|
||||
const alertFilter = ref('')
|
||||
const loading = ref(false)
|
||||
const reportData = ref<any[]>([])
|
||||
const hierarchyData = ref<any[]>([])
|
||||
const stats = ref<any>({})
|
||||
const expandedAll = ref(true)
|
||||
|
||||
// ── 维度卡片计算 ──
|
||||
const dimensionCards = computed(() => {
|
||||
return hierarchyData.value.map(d => ({
|
||||
key: d.key,
|
||||
name: d.name,
|
||||
icon: d.icon,
|
||||
color: d.color,
|
||||
summary: d.summary
|
||||
}))
|
||||
})
|
||||
|
||||
// ── 平铺树结构为el-table可用的带_children的列表 ──
|
||||
const flattenHierarchy = computed(() => {
|
||||
const result: any[] = []
|
||||
let uid = 0
|
||||
for (const dim of hierarchyData.value) {
|
||||
const dimRow = { ...dim, _type: 'dimension', _children: [], __uid: `dim_${++uid}` }
|
||||
for (const obj of dim.objectives) {
|
||||
const objRow = { ...obj, _type: 'objective', _children: [], __uid: `obj_${++uid}` }
|
||||
for (const kpi of obj.kpis) {
|
||||
objRow._children.push({
|
||||
...kpi,
|
||||
_type: 'kpi',
|
||||
_children: undefined,
|
||||
__uid: `kpi_${++uid}_${kpi.kpi_id}`
|
||||
})
|
||||
}
|
||||
dimRow._children.push(objRow)
|
||||
}
|
||||
result.push(dimRow)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ── 工具函数 ──
|
||||
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
|
||||
|
||||
function levelTag(level: string) {
|
||||
if (level === 'red') return 'danger'
|
||||
if (level === 'yellow') return 'warning'
|
||||
if (level === 'green') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function deviationTag(rate: number) {
|
||||
if (rate > 20) return 'danger'
|
||||
if (rate > 10) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function fmtVal(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// ── 筛选变更(前端过滤树) ──
|
||||
function filterChanged() {
|
||||
// 维度或预警级别变化时,后端的hierarchical接口不支持前端过滤
|
||||
// 重新加载(后端过滤)
|
||||
loadReport()
|
||||
}
|
||||
|
||||
function scrollToDim(key: string) {
|
||||
// 展开所有,让目标维度可见
|
||||
expandedAll.value = true
|
||||
}
|
||||
|
||||
function onRowClick(row: any) {
|
||||
// KPI级点击跳到详情
|
||||
if (row._type === 'kpi' && row.kpi_id) {
|
||||
window.open('/#/kpis/' + row.kpi_id, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载差异报告 ──
|
||||
async function loadReport() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { hierarchical: true }
|
||||
if (filterYear.value) params.year = filterYear.value
|
||||
if (filterMonth.value) params.month = filterMonth.value
|
||||
if (filterDimension.value) params.dimension = filterDimension.value
|
||||
if (alertFilter.value) params.alert_level = alertFilter.value
|
||||
const r: any = await budgetApi.deviationReport(params)
|
||||
reportData.value = r.items || []
|
||||
hierarchyData.value = r.hierarchy || []
|
||||
stats.value = r.summary || {}
|
||||
// 如果hierarchy有数据,用层级中的统计补全stats
|
||||
if (hierarchyData.value.length > 0) {
|
||||
let total = 0, hasBudget = 0
|
||||
for (const dim of hierarchyData.value) {
|
||||
total += dim.summary.count || 0
|
||||
hasBudget += dim.summary.has_budget || 0
|
||||
}
|
||||
stats.value.total_kpis = total
|
||||
stats.value.has_budget = hasBudget
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('加载差异报告失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ── 趋势图 ──
|
||||
const trendKpi = ref<number | null>(null)
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const trendData = ref<any[]>([])
|
||||
const trendLoading = ref(false)
|
||||
const maxTrend = ref(0)
|
||||
|
||||
async function loadKpiOptions() {
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 200 })
|
||||
const data = r.data || r || []
|
||||
kpiOptions.value = Array.isArray(data) ? data : (data.items || [])
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadTrend() {
|
||||
if (!trendKpi.value) { trendData.value = []; return }
|
||||
trendLoading.value = true
|
||||
try {
|
||||
const r: any = await budgetApi.deviationReport({ kpi_id: trendKpi.value })
|
||||
const data = r.data || r || []
|
||||
const items = Array.isArray(data) ? data : (data.items || [])
|
||||
const monthly = items.filter((i: any) => i.budget_value !== null)
|
||||
.sort((a: any, b: any) => {
|
||||
const keyA = a.period || ''
|
||||
const keyB = b.period || ''
|
||||
return keyA.localeCompare(keyB)
|
||||
})
|
||||
trendData.value = monthly.map((i: any) => ({
|
||||
period: i.period,
|
||||
actual: i.actual_value || 0,
|
||||
budget: i.budget_value || 0,
|
||||
deviation: i.deviation_rate,
|
||||
}))
|
||||
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1)
|
||||
} catch (e) { /* ignore */ }
|
||||
trendLoading.value = false
|
||||
}
|
||||
|
||||
function barHeight(val: number, max: number) {
|
||||
if (!max) return 0
|
||||
return Math.max(4, (val / max) * 150)
|
||||
}
|
||||
|
||||
// ── 导出CSV ──
|
||||
function exportReport() {
|
||||
const dims = hierarchyData.value
|
||||
if (dims.length === 0) { ElMessage.warning('无数据可导出'); return }
|
||||
const headers = ['维度', '战略目标', 'KPI编码', 'KPI名称', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
|
||||
const csvRows = [headers.join(',')]
|
||||
for (const dim of dims) {
|
||||
for (const obj of dim.objectives) {
|
||||
if (obj.kpis.length === 0) {
|
||||
csvRows.push([dim.name, `"${obj.name}"`].join(','))
|
||||
}
|
||||
for (const k of obj.kpis) {
|
||||
csvRows.push([
|
||||
dim.name, `"${obj.name}"`, k.kpi_code || '', `"${k.kpi_name || ''}"`,
|
||||
k.budget_value ?? '', k.actual_value ?? '',
|
||||
k.deviation_amount ?? '', k.deviation_rate?.toFixed(2) ?? '',
|
||||
k.yoy?.diff_rate?.toFixed(1) ?? '', k.mom?.diff_rate?.toFixed(1) ?? '',
|
||||
k.deviation_rate != null ? (k.deviation_rate > 20 ? '红色' : k.deviation_rate > 10 ? '黄色' : '正常') : ''
|
||||
].join(','))
|
||||
}
|
||||
}
|
||||
}
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `偏差分析报告_${filterYear.value}年${filterMonth.value}月.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReport()
|
||||
loadKpiOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { text-align: center; padding: 4px 0; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-bottom: 6px; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #303133; }
|
||||
.red-text { color: #f56c6c; }
|
||||
.yellow-text { color: #e6a23c; }
|
||||
.green-text { color: #67c23a; }
|
||||
.gray-text { color: #909399; }
|
||||
.trend-bar { width: 18px; border-radius: 3px 3px 0 0; transition: height 0.3s; }
|
||||
.actual-bar { background: #409eff; }
|
||||
.budget-bar { background: #e6a23c; }
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="dupont-analysis" v-loading="loading">
|
||||
<!-- 1. ROE大数字 -->
|
||||
<div class="roe-hero" v-if="data.roe !== null">
|
||||
<div class="roe-value">{{ data.roe }}<span class="roe-unit">%</span></div>
|
||||
<div class="roe-label">净资产收益率 (ROE)</div>
|
||||
<div class="roe-change" :class="data.history?.trend || 'stable'">
|
||||
<span v-if="data.history?.trend === 'up'">↑</span>
|
||||
<span v-else-if="data.history?.trend === 'down'">↓</span>
|
||||
<span v-else>→</span>
|
||||
{{ data.history?.change?.roe_label || '无对比' }}
|
||||
<span class="change-sub">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="杜邦分析数据不足以计算,请确认营收、总资产、净资产数据已同步" />
|
||||
|
||||
<!-- 2. 三因子卡片 -->
|
||||
<div class="factor-cards" v-if="data.factors && Object.keys(data.factors).length">
|
||||
<div class="factor-card" v-for="(f, key) in data.factors" :key="key">
|
||||
<div class="factor-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="factor-label">{{ f.label }}</div>
|
||||
<div class="factor-desc">{{ f.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 杜邦拆解树 -->
|
||||
<div class="dupont-tree" v-if="data.roe !== null && data.raw_data">
|
||||
<div class="tree-title">杜邦拆解链路</div>
|
||||
<div class="tree-container">
|
||||
<!-- 第一层:ROE -->
|
||||
<div class="tree-node tree-root">
|
||||
<div class="node-value">{{ data.roe }}%</div>
|
||||
<div class="node-label">ROE</div>
|
||||
</div>
|
||||
<!-- 连线 -->
|
||||
<div class="tree-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal">
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二层:三因子 -->
|
||||
<div class="tree-row">
|
||||
<div class="tree-node tree-factor" v-for="(f, key) in data.factors" :key="'f-'+key">
|
||||
<div class="node-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="node-label">{{ f.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 连线 第二层到第三层 -->
|
||||
<div class="tree-lines-second">
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第三层:原始数据 -->
|
||||
<div class="tree-row" v-if="data.raw_data">
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.profit_net) }}</div>
|
||||
<div class="node-label sub">净利润</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label sub">总资产</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label">总资产</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.equity_total) }}</div>
|
||||
<div class="node-label sub">净资产</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 一句话AI解读 -->
|
||||
<div class="ai-insight" v-if="aiAnalysis">
|
||||
<div class="insight-label">💡 AI解读</div>
|
||||
<div class="insight-text">{{ aiAnalysis }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any>({})
|
||||
const aiAnalysis = ref('')
|
||||
|
||||
function formatFactor(val: number, key: string): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (key === 'equity_multiplier') return val.toFixed(2) + 'x'
|
||||
if (key === 'asset_turnover') return val.toFixed(2) + 'x'
|
||||
return (val * 100).toFixed(2) + '%'
|
||||
}
|
||||
|
||||
function formatMoney(val: number): string {
|
||||
if (!val) return '-'
|
||||
if (val >= 100000000) return (val / 100000000).toFixed(2) + '亿'
|
||||
if (val >= 10000) return (val / 10000).toFixed(0) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
|
||||
async function fetchDupont() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await dashboardApi.dupont?.() || await (await fetch('/api/cma/dashboard/dupont')).json()
|
||||
data.value = res
|
||||
} catch (e) {
|
||||
console.error('杜邦分析加载失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchDupont)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dupont-analysis {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ROE大数字 */
|
||||
.roe-hero {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.roe-value {
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.roe-unit { font-size: 24px; color: #8892b0; }
|
||||
.roe-label { font-size: 14px; color: #8892b0; margin-top: 4px; }
|
||||
.roe-change {
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
.roe-change.up { color: #2ecc71; background: rgba(46,204,113,0.15); }
|
||||
.roe-change.down { color: #e74c3c; background: rgba(231,76,60,0.15); }
|
||||
.roe-change.stable { color: #f39c12; background: rgba(243,156,18,0.15); }
|
||||
.change-sub { font-size: 12px; color: #8892b0; margin-left: 4px; font-weight: 400; }
|
||||
|
||||
/* 三因子卡片 */
|
||||
.factor-cards {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.factor-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
.factor-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
.factor-value { font-size: 28px; font-weight: 700; color: #2c3e50; }
|
||||
.factor-label { font-size: 14px; color: #7f8c8d; margin-top: 4px; }
|
||||
.factor-desc { font-size: 11px; color: #b0b0b0; margin-top: 8px; }
|
||||
|
||||
/* 杜邦拆解树 */
|
||||
.dupont-tree {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tree-title { font-size: 16px; font-weight: 600; margin-bottom: 20px; color: #2c3e50; }
|
||||
.tree-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.tree-node {
|
||||
text-align: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.tree-root {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
.tree-root .node-value { font-size: 32px; font-weight: 700; }
|
||||
.tree-root .node-label { font-size: 13px; opacity: 0.85; }
|
||||
.tree-factor {
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #d0d8f0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.tree-factor .node-value { font-size: 22px; font-weight: 700; }
|
||||
.tree-factor .node-label { font-size: 13px; color: #7f8c8d; }
|
||||
.tree-leaf {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
min-width: 110px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.tree-leaf .node-value { font-size: 16px; font-weight: 600; color: #2c3e50; }
|
||||
.tree-leaf .node-label { font-size: 12px; color: #95a5a6; }
|
||||
.tree-leaf .node-label.sub { font-size: 11px; color: #b0b0b0; margin-top: 2px; }
|
||||
.tree-leaf .node-hint { font-size: 14px; font-weight: 600; color: #667eea; margin-top: 8px; }
|
||||
|
||||
/* 连线 */
|
||||
.tree-lines, .tree-lines-second {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
}
|
||||
.line-vertical { width: 2px; height: 20px; background: #d0d8f0; }
|
||||
.line-horizontal {
|
||||
display: flex;
|
||||
gap: 160px;
|
||||
height: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.line-point { width: 2px; height: 20px; background: #d0d8f0; }
|
||||
.tree-row {
|
||||
display: flex;
|
||||
gap: 80px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.tree-lines-second {
|
||||
flex-direction: row;
|
||||
gap: 80px;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.sub-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.sub-lines .line-horizontal { gap: 50px; }
|
||||
|
||||
/* AI解读 */
|
||||
.ai-insight {
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.insight-label { font-size: 14px; font-weight: 600; color: #d48806; margin-bottom: 8px; }
|
||||
.insight-text { font-size: 14px; color: #5a4e2b; line-height: 1.7; }
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="alignment-page">
|
||||
<div class="page-header">
|
||||
<h3>战略执行看板</h3>
|
||||
<div>
|
||||
<el-button v-if="alignConfigured" @click="loadTree" :loading="loading" :disabled="loading" style="margin-right:8px;">
|
||||
刷新数据
|
||||
</el-button>
|
||||
<el-button type="primary" @click="showSelector = true">
|
||||
{{ alignConfigured ? '切换模式' : '配置对齐模式' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模式选择器 -->
|
||||
<div v-if="showSelector" class="align-setup">
|
||||
<el-alert v-if="!alignConfigured" title="系统尚未配置KPI对齐模式" type="warning" :closable="false" show-icon style="margin-bottom:16px;" />
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||
<el-card v-for="mode in alignModes" :key="mode.key"
|
||||
:class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }"
|
||||
shadow="hover" @click="selectedMode = mode.key"
|
||||
>
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span style="font-weight:600;">{{ mode.name }}</span>
|
||||
<el-radio :model-value="selectedMode === mode.key" :label="mode.key"> </el-radio>
|
||||
</div>
|
||||
</template>
|
||||
<p style="font-size:13px;color:#666;line-height:1.6;">{{ mode.description }}</p>
|
||||
<el-tag type="info" size="small">示例</el-tag>
|
||||
<p style="font-size:12px;color:#999;margin-top:4px;background:#f5f7fa;padding:6px 8px;border-radius:4px;">{{ mode.example }}</p>
|
||||
</el-card>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:12px;">
|
||||
<el-button type="primary" size="large" @click="saveAlignConfig" :loading="saving" :disabled="!selectedMode">
|
||||
{{ alignConfigured ? '切换为「' + selectedModeName + '」' : '确定并启用「' + selectedModeName + '」' }}
|
||||
</el-button>
|
||||
<el-button v-if="alignConfigured" @click="showSelector = false; selectedMode = currentMode?.key || ''">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 看板主体 -->
|
||||
<div v-else>
|
||||
<!-- 地图选择器 -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<span style="font-size:13px;color:#666;white-space:nowrap;">筛选地图:</span>
|
||||
<el-select v-model="selectedMapId" placeholder="全部KPI" clearable style="width:240px;" @change="onMapChange">
|
||||
<el-option v-for="m in mapList" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<span v-if="selectedMapId" style="font-size:12px;color:#409eff;">仅显示该地图关联KPI的对齐关系</span>
|
||||
<span v-else style="font-size:12px;color:#999;">显示全部KPI</span>
|
||||
</div>
|
||||
<!-- 状态栏 -->
|
||||
<div style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">对齐模式</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ currentMode?.name || '-' }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">KPI总数</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ treeData?.total || 0 }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">有数据</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ kpiWithData }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">红黄灯</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">
|
||||
<span v-if="redCount > 0" style="color:#f56c6c;">{{ redCount }}红</span>
|
||||
<span v-if="redCount > 0 && yellowCount > 0" style="color:#999;margin:0 4px;">/</span>
|
||||
<span v-if="yellowCount > 0" style="color:#e6a23c;">{{ yellowCount }}黄</span>
|
||||
<span v-if="redCount === 0 && yellowCount === 0" style="color:#67c23a;">全部正常</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 对齐树 -->
|
||||
<div style="border:1px solid #e4e7ed;border-radius:8px;padding:12px;background:#fff;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<span style="font-weight:500;">对齐关系({{ treeData?.mode_name || '' }})</span>
|
||||
<span style="font-size:12px;color:#999;" v-if="treeData">
|
||||
最后数据期间:{{ latestPeriod }}
|
||||
<el-button text size="small" @click="loadTree" :loading="loading" style="margin-left:8px;">刷新</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<el-tree v-if="treeData" :data="treeData.tree || []" :props="{ label: 'kpi_name', children: 'children' }" default-expand-all>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node-row">
|
||||
<!-- 维度组 -->
|
||||
<template v-if="data.is_dimension_group">
|
||||
<span :style="{color: dimColor(data.dimension), fontWeight:600}">{{ data.kpi_name }}</span>
|
||||
<span class="tree-meta">({{ data.child_count }}个KPI)</span>
|
||||
<span v-if="data.description" class="tree-desc">— {{ data.description }}</span>
|
||||
</template>
|
||||
<!-- 类别组 -->
|
||||
<template v-else-if="data.is_category_group">
|
||||
<el-tag size="small" type="success">{{ data.category_label }}</el-tag>
|
||||
<span v-if="data.feeds" class="tree-drive">→ 驱动 {{ feedLabels(data.feeds) }}</span>
|
||||
<span v-else class="tree-drive" style="color:#67c23a;">最终结果</span>
|
||||
<span class="tree-meta">({{ data.child_count }}个)</span>
|
||||
</template>
|
||||
<!-- KPI节点 -->
|
||||
<template v-else>
|
||||
<span class="tree-level-dot" :class="'level-' + (data.alert_level || 'none')"></span>
|
||||
<span class="tree-code">{{ data.kpi_code }}</span>
|
||||
<span class="tree-name">{{ data.kpi_name }}</span>
|
||||
<span v-if="data.alignment_type === 'vertical_split'" class="tree-tag tag-amber">承接 {{ data.parent_code }}</span>
|
||||
<span v-if="data.drives" class="tree-tag tag-gray">→ {{ dimLabel(data.drives) }}</span>
|
||||
<!-- 目标值 -->
|
||||
<span class="tree-val" v-if="data.target_value != null">
|
||||
目标:<strong>{{ fmtNum(data.target_value) }}</strong>{{ data.unit }}
|
||||
</span>
|
||||
<!-- 实际值 -->
|
||||
<span class="tree-val" v-if="data.actual_value != null" :class="statusClass(data)">
|
||||
实绩:<strong>{{ fmtNum(data.actual_value) }}</strong>{{ data.unit }}
|
||||
</span>
|
||||
<span v-else class="tree-val tree-val-na">实绩:-</span>
|
||||
<!-- 偏差率 -->
|
||||
<span v-if="data.actual_value != null && data.target_value != null && data.target_value > 0"
|
||||
class="tree-deviation" :class="devClass(data)">
|
||||
{{ calcDev(data) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
<div v-else-if="!loading" style="text-align:center;padding:40px;color:#999;">
|
||||
暂无对齐数据,请先配置对齐模式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const alignModes = ref<any[]>([])
|
||||
const alignConfigured = ref(false)
|
||||
const currentMode = ref<any>(null)
|
||||
const selectedMode = ref('')
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const treeData = ref<any>(null)
|
||||
const showSelector = ref(false)
|
||||
|
||||
// 地图选择
|
||||
const mapList = ref<any[]>([])
|
||||
const selectedMapId = ref<number | null>(null)
|
||||
|
||||
async function loadMapList() {
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const r = await fetch('/api/cma/maps', {
|
||||
headers: { 'Authorization': `'Bearer ' + token` },
|
||||
}).then(r => r.json())
|
||||
mapList.value = r.data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onMapChange(val: number | null) {
|
||||
selectedMapId.value = val
|
||||
// 更新URL(可选)
|
||||
if (val) {
|
||||
router.replace(`/alignment?map_id=${val}`)
|
||||
} else {
|
||||
router.replace('/alignment')
|
||||
}
|
||||
loadTree()
|
||||
}
|
||||
|
||||
const selectedModeName = computed(() => {
|
||||
const m = alignModes.value.find(m => m.key === selectedMode.value)
|
||||
return m ? m.name : ''
|
||||
})
|
||||
|
||||
// 统计
|
||||
const kpiWithData = computed(() => {
|
||||
if (!treeData.value) return 0
|
||||
return countKpiField(treeData.value.tree || [], 'actual_value')
|
||||
})
|
||||
const redCount = computed(() => {
|
||||
if (!treeData.value) return 0
|
||||
return countAlert(treeData.value.tree || [], 'red')
|
||||
})
|
||||
const yellowCount = computed(() => {
|
||||
if (!treeData.value) return 0
|
||||
return countAlert(treeData.value.tree || [], 'yellow')
|
||||
})
|
||||
const latestPeriod = computed(() => {
|
||||
if (!treeData.value) return '-'
|
||||
return findLatestPeriod(treeData.value.tree || [])
|
||||
})
|
||||
|
||||
function countKpiField(nodes: any[], field: string): number {
|
||||
let count = 0
|
||||
for (const n of nodes) {
|
||||
if (n.children) count += countKpiField(n.children, field)
|
||||
else if (n[field] != null) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
function countAlert(nodes: any[], level: string): number {
|
||||
let count = 0
|
||||
for (const n of nodes) {
|
||||
if (n.children) count += countAlert(n.children, level)
|
||||
else if (n.alert_level === level) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
function findLatestPeriod(nodes: any[]): string {
|
||||
let latest = ''
|
||||
for (const n of nodes) {
|
||||
if (n.children) {
|
||||
const sub = findLatestPeriod(n.children)
|
||||
if (sub > latest) latest = sub
|
||||
} else if (n.period && n.period > latest) {
|
||||
latest = n.period
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
const dimColor = (dim: string) => {
|
||||
const colors: Record<string, string> = { finance: '#409eff', customer: '#67c23a', process: '#e6a23c', learning: '#f56c6c' }
|
||||
return colors[dim] || '#999'
|
||||
}
|
||||
const dimLabel = (dim: string) => {
|
||||
const labels: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长', internal_process: '内部流程' }
|
||||
return labels[dim] || dim
|
||||
}
|
||||
const feedLabels = (feeds: string[] | null) => {
|
||||
if (!feeds) return ''
|
||||
const map: Record<string, string> = {
|
||||
supply_chain: '供应链效率', delivery_quality: '交付质量',
|
||||
customer_scale: '客户规模', customer_concentration: '客户集中度',
|
||||
customer_satisfaction: '客户满意', revenue_growth: '收入增长',
|
||||
profitability: '盈利水平', cost_control: '成本费用', asset_efficiency: '资产效率',
|
||||
}
|
||||
return feeds.map(f => map[f] || f).join('、')
|
||||
}
|
||||
const fmtNum = (v: number) => {
|
||||
if (v == null) return '-'
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
if (Number.isInteger(v)) return v.toLocaleString()
|
||||
return v.toFixed(1)
|
||||
}
|
||||
const calcDev = (d: any) => {
|
||||
if (d.actual_value == null || d.target_value == null || d.target_value === 0) return ''
|
||||
const dev = ((d.actual_value - d.target_value) / d.target_value * 100)
|
||||
const sign = dev > 0 ? '+' : ''
|
||||
return `${sign}${dev.toFixed(1)}%`
|
||||
}
|
||||
const statusClass = (d: any) => {
|
||||
if (d.alert_level === 'red') return 'val-red'
|
||||
if (d.alert_level === 'yellow') return 'val-yellow'
|
||||
if (d.alert_level === 'green') return 'val-green'
|
||||
return ''
|
||||
}
|
||||
const devClass = (d: any) => {
|
||||
if (d.alert_level === 'red') return 'dev-red'
|
||||
if (d.alert_level === 'yellow') return 'dev-yellow'
|
||||
return 'dev-green'
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/cma/alignment/config').then(r => r.json())
|
||||
alignModes.value = r.modes || []
|
||||
alignConfigured.value = r.configured || false
|
||||
if (r.configured && r.mode) {
|
||||
currentMode.value = alignModes.value.find(m => m.key === r.mode.mode) || null
|
||||
selectedMode.value = r.mode.mode
|
||||
}
|
||||
showSelector.value = !r.configured
|
||||
if (r.configured) loadTree()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveAlignConfig() {
|
||||
if (!selectedMode.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
await fetch('/api/cma/alignment/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `'Bearer ' + token` },
|
||||
body: JSON.stringify({ mode: selectedMode.value }),
|
||||
}).then(r => r.json())
|
||||
ElMessage.success('对齐模式已设置')
|
||||
currentMode.value = alignModes.value.find(m => m.key === selectedMode.value) || null
|
||||
alignConfigured.value = true
|
||||
showSelector.value = false
|
||||
loadTree()
|
||||
} catch { ElMessage.error('设置失败') }
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
let url = '/api/cma/alignment/tree'
|
||||
if (selectedMapId.value) url += `?map_id=${selectedMapId.value}`
|
||||
const r = await fetch(url, {
|
||||
headers: { 'Authorization': `'Bearer ' + token` },
|
||||
}).then(r => r.json())
|
||||
treeData.value = r
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '加载失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
loadMapList()
|
||||
// 如果URL有map_id参数,自动选中对应地图
|
||||
if (route.query.map_id) {
|
||||
const mid = Number(route.query.map_id)
|
||||
if (mid) {
|
||||
selectedMapId.value = mid
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alignment-page { padding: 16px; height: 100%; overflow-y: auto; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.page-header h3 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
|
||||
.mode-card { cursor: pointer; transition: all 0.2s; border: 2px solid transparent; }
|
||||
.mode-card:hover { border-color: #409eff; }
|
||||
.selected-mode { border-color: #409eff; background: #f0f7ff; }
|
||||
|
||||
.tree-node-row { display: flex; align-items: center; gap: 6px; font-size: 13px; padding: 3px 0; flex-wrap: wrap; }
|
||||
.tree-code { font-family: monospace; font-size: 11px; color: #909399; }
|
||||
.tree-name { color: #303133; }
|
||||
.tree-meta { font-size: 11px; color: #999; }
|
||||
.tree-desc { font-size: 11px; color: #909399; }
|
||||
.tree-drive { font-size: 11px; color: #e6a23c; }
|
||||
.tree-tag { font-size: 11px; padding: 1px 6px; border-radius: 3px; }
|
||||
.tag-amber { background: #fdf6ec; color: #e6a23c; }
|
||||
.tag-gray { background: #f4f4f5; color: #909399; }
|
||||
.tree-level-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.level-red { background: #f56c6c; }
|
||||
.level-yellow { background: #e6a23c; }
|
||||
.level-green { background: #67c23a; }
|
||||
.level-none { background: #dcdfe6; }
|
||||
.tree-val { font-size: 12px; color: #606266; margin-left: 4px; }
|
||||
.tree-val-na { color: #ccc; }
|
||||
.val-red { color: #f56c6c; }
|
||||
.val-yellow { color: #e6a23c; }
|
||||
.val-green { color: #67c23a; }
|
||||
.tree-deviation { font-size: 11px; font-weight: 500; padding: 1px 5px; border-radius: 3px; }
|
||||
.dev-red { background: #fef0f0; color: #f56c6c; }
|
||||
.dev-yellow { background: #fdf6ec; color: #e6a23c; }
|
||||
.dev-green { background: #f0f9eb; color: #67c23a; }
|
||||
</style>
|
||||
@@ -0,0 +1,738 @@
|
||||
<template>
|
||||
<div class="kpi-dict-page">
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<h3>KPI字典</h3>
|
||||
<div class="header-actions">
|
||||
<el-button @click="showTemplateSelect = true">📋 从模板创建</el-button>
|
||||
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<!-- 左侧:BSC分类导航树 -->
|
||||
<div class="left-tree">
|
||||
<div class="tree-header">BSC分类导航</div>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="categoryTree"
|
||||
node-key="key"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
:default-expanded-keys="defaultExpanded"
|
||||
:show-checkbox="true"
|
||||
:check-strictly="true"
|
||||
@check="onTreeCheck"
|
||||
highlight-current
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="tree-node">
|
||||
<span class="tree-node-label">{{ data.label }}</span>
|
||||
<el-tag size="small" :type="data.children && data.children.length > 0 ? '' : 'info'" class="tree-count">{{ data.count }}</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:搜索+表格 -->
|
||||
<div class="right-content">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索KPI名称/编码" clearable style="width:220px" @clear="loadKpis" @keyup.enter="loadKpis" />
|
||||
<el-select v-model="searchDimension" placeholder="维度" clearable style="width:110px" @change="loadKpis">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
<el-select v-model="searchCategory" placeholder="二级类别" clearable style="width:140px" @change="loadKpis">
|
||||
<el-option v-for="c in allCategories" :key="c.value" :label="c.label" :value="c.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadKpis">查询</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<div class="batch-bar" v-if="selectedIds.length > 0">
|
||||
<span class="selected-info">已选 {{ selectedIds.length }} 条</span>
|
||||
<el-button size="small" @click="batchDelete">批量删除</el-button>
|
||||
<el-button size="small" @click="batchExport">导出CSV</el-button>
|
||||
<el-button size="small" @click="selectedIds=[]">取消选择</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="kpis"
|
||||
v-loading="loading"
|
||||
style="width:100%"
|
||||
@selection-change="onSelectionChange"
|
||||
@row-click="(row) => $router.push('/kpis/' + row.id)"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
>
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="kpi-code">{{ row.kpi_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="kpi_name" label="名称" min-width="170" />
|
||||
<el-table-column prop="dimension" label="维度" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="二级类别" width="90">
|
||||
<template #default="{ row }">{{ catLabel(row.category) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_value" label="目标值" width="90">
|
||||
<template #default="{ row }">{{ formatTarget(row.target_value, row.unit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="frequency" label="频率" width="70">
|
||||
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="data_source_type" label="数据源" width="70">
|
||||
<template #default="{ row }">{{ sourceLabel(row.data_source_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前值" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.actual_value !== null" :class="'status-' + (row.alert_level || 'none')">
|
||||
{{ row.actual_value }}{{ row.unit }}
|
||||
</span>
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_system" size="small" type="info">系统</el-tag>
|
||||
<el-tag v-else size="small" type="success">自定义</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预警" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.alert_level && row.alert_level !== 'none'" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
|
||||
{{ row.alert_level === 'red' ? '红灯' : row.alert_level === 'yellow' ? '黄灯' : '绿灯' }}
|
||||
</el-tag>
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click.stop="$router.push('/kpis/' + row.id)">编辑</el-button>
|
||||
<el-button size="small" text type="danger" @click.stop="doDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<span class="total-info">共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="loadKpis"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<MyDialog v-model="showForm" :title="editMode ? '编辑KPI' : '新建KPI'" :width="640">
|
||||
<el-form :model="form" label-width="110px" size="small">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码"><el-input v-model="form.kpi_code" :disabled="editMode" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI名称"><el-input v-model="form.kpi_name" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="维度">
|
||||
<el-select v-model="form.dimension" style="width:100%" @change="onDimensionChange">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="二级类别">
|
||||
<el-select v-model="form.category" style="width:100%" :disabled="!form.dimension">
|
||||
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标值"><el-input-number v-model="form.target_value" :min="0" style="width:100%" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, 元, 次, 个..." /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="频率">
|
||||
<el-select v-model="form.frequency" style="width:100%">
|
||||
<el-option label="月度" value="monthly" />
|
||||
<el-option label="季度" value="quarterly" />
|
||||
<el-option label="年度" value="yearly" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="form.data_source_type" style="width:100%">
|
||||
<el-option label="ERP自动" value="erp" />
|
||||
<el-option label="Excel导入" value="excel" />
|
||||
<el-option label="手工填报" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责部门"><el-input v-model="form.responsible_dept" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人"><el-input v-model="form.responsible_user" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="计算公式"><el-input v-model="form.formula" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="绿灯阈值">
|
||||
<el-input v-model="form.threshold_green" placeholder="如 >=80" />
|
||||
<div class="threshold-hint">超过此值视为正常</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="黄灯阈值">
|
||||
<el-input v-model="form.threshold_yellow" placeholder="如 >=60" />
|
||||
<div class="threshold-hint">超过此值预警</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="红灯阈值">
|
||||
<el-input v-model="form.threshold_red" placeholder="如 <60" />
|
||||
<div class="threshold-hint">触发此值紧急</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="所属Epic">
|
||||
<el-select v-model="form.epic" style="width:100%">
|
||||
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showForm=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveKPI">{{ editMode ? '保存' : '创建' }}</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 从模板创建弹窗 -->
|
||||
<el-dialog v-model="showTemplateSelect" title="从模板创建KPI" width="760" :close-on-click-modal="false">
|
||||
<div v-loading="templateLoading">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="search-bar" style="margin-bottom:12px;">
|
||||
<el-input v-model="templateKeyword" placeholder="搜索模板名称/编码" clearable style="width:200px" />
|
||||
<el-select v-model="templateDimFilter" placeholder="维度" clearable style="width:110px">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
<el-button @click="loadTemplates">查询</el-button>
|
||||
</div>
|
||||
<!-- 模板列表 -->
|
||||
<el-table :data="templates" style="width:100%" border stripe size="small"
|
||||
:highlight-current-row="true"
|
||||
@current-change="onTemplateSelect"
|
||||
>
|
||||
<el-table-column type="index" width="40" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="120">
|
||||
<template #default="{ row }"><span class="kpi-code">{{ row.kpi_code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="kpi_name" label="名称" min-width="140" />
|
||||
<el-table-column prop="dimension" label="维度" width="70">
|
||||
<template #default="{ row }"><el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类别" width="80">
|
||||
<template #default="{ row }">{{ catLabel(row.category) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
<el-table-column prop="target_value" label="建议目标" width="80">
|
||||
<template #default="{ row }">{{ row.target_value ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="来源" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_system" size="small" type="info">系统</el-tag>
|
||||
<el-tag v-else size="small" type="success">自定义</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!selectedTemplate" style="color:#999;font-size:13px;margin-top:8px;">请选择一个模板,然后填写实例化信息</div>
|
||||
<!-- 实例化表单 -->
|
||||
<div v-if="selectedTemplate" class="instantiate-form" style="margin-top:12px;border-top:1px solid #eee;padding-top:12px;">
|
||||
<div style="font-size:14px;font-weight:500;margin-bottom:8px;">从「{{ selectedTemplate.kpi_name }}」创建KPI实例</div>
|
||||
<el-form :model="instantiateForm" label-width="90px" size="small">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码"><el-input v-model="instantiateForm.kpi_code" :placeholder="selectedTemplate.kpi_code" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI名称"><el-input v-model="instantiateForm.kpi_name" :placeholder="selectedTemplate.kpi_name" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标值"><el-input-number v-model="instantiateForm.target_value" :min="0" style="width:100%" :placeholder="String(selectedTemplate.target_value ?? '')" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="instantiateForm.data_source_type" style="width:100%">
|
||||
<el-option label="ERP自动" value="erp" />
|
||||
<el-option label="Excel导入" value="excel" />
|
||||
<el-option label="手工填报" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责部门"><el-input v-model="instantiateForm.responsible_dept" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人"><el-input v-model="instantiateForm.responsible_user" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<div style="color:#999;font-size:12px;margin-top:4px;">* 编码和名称不填则使用模板默认值,后续可在KPI字典中修改</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showTemplateSelect=false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedTemplate" @click="doInstantiate">从模板创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { kpiApi, templateApi, dashboardApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
// ── 数据 ──
|
||||
const kpis = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
// ── 搜索筛选 ──
|
||||
const searchKeyword = ref('')
|
||||
const searchDimension = ref('')
|
||||
const searchCategory = ref('')
|
||||
|
||||
// ── 分类树 ──
|
||||
const categoryTree = ref<any[]>([])
|
||||
const defaultExpanded = ref<string[]>([])
|
||||
const treeRef = ref<any>(null)
|
||||
const treeFilterDims = ref<string[]>([])
|
||||
const treeFilterCats = ref<string[]>([])
|
||||
|
||||
// ── 多选 ──
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
// ── 新建/编辑表单 ──
|
||||
const showForm = ref(false)
|
||||
const editMode = ref(false)
|
||||
const form = ref<any>({})
|
||||
|
||||
// ── 维度映射 ──
|
||||
const DIM_MAP: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
const CAT_MAP: Record<string, string> = {
|
||||
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
|
||||
asset_efficiency: '资产效率', cash_risk: '现金流风控',
|
||||
customer_scale: '客户规模', customer_concentration: '客户集中度', customer_satisfaction: '客户满意',
|
||||
supply_chain: '供应链效率', delivery_quality: '交付质量',
|
||||
talent_pipeline: '人才梯队', employee_engagement: '员工敬业', innovation: '创新改善',
|
||||
}
|
||||
const FREQ_MAP: Record<string, string> = { daily: '日', weekly: '周', monthly: '月', quarterly: '季', yearly: '年' }
|
||||
const SOURCE_MAP: Record<string, string> = { erp: 'ERP', excel: 'Excel', manual: '手工' }
|
||||
|
||||
// 维度→二级类别 对照映射
|
||||
const DIM_CAT_MAP: Record<string, string[]> = {
|
||||
finance: ['revenue_growth', 'profitability', 'cost_control', 'asset_efficiency', 'cash_risk'],
|
||||
customer: ['customer_scale', 'customer_concentration', 'customer_satisfaction'],
|
||||
process: ['supply_chain', 'delivery_quality'],
|
||||
learning: ['talent_pipeline', 'employee_engagement', 'innovation'],
|
||||
}
|
||||
|
||||
// 二级类别选项(联动维度)
|
||||
const categoryOptions = computed(() => {
|
||||
const dim = form.value?.dimension
|
||||
if (!dim) return []
|
||||
const catKeys = DIM_CAT_MAP[dim] || []
|
||||
return catKeys.map(key => ({ value: key, label: CAT_MAP[key] || key }))
|
||||
})
|
||||
|
||||
// 所有二级类别(搜索栏用)
|
||||
const allCategories = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
const result: { value: string; label: string }[] = []
|
||||
for (const key in DIM_CAT_MAP) {
|
||||
for (const cat of DIM_CAT_MAP[key]) {
|
||||
if (!seen.has(cat)) {
|
||||
seen.add(cat)
|
||||
result.push({ value: cat, label: CAT_MAP[cat] || cat })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function dimLabel(d: string) { return DIM_MAP[d] || d }
|
||||
function catLabel(c: string) { return CAT_MAP[c] || c }
|
||||
function freqLabel(f: string) { return FREQ_MAP[f] || f }
|
||||
function sourceLabel(s: string) { return SOURCE_MAP[s] || s }
|
||||
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
|
||||
|
||||
function formatTarget(val: number, unit: string) {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (val >= 10000) return (val / 10000).toFixed(0) + '万' + (unit || '')
|
||||
return val + (unit || '')
|
||||
}
|
||||
|
||||
// ── 加载分类树 ──
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const r: any = await kpiApi.listCategories()
|
||||
categoryTree.value = [
|
||||
{ key: '_all', label: '全部', count: r.total, children: r.tree || [] }
|
||||
]
|
||||
defaultExpanded.value = ['_all', ...(r.tree || []).map((n: any) => n.key)]
|
||||
} catch (e) {
|
||||
console.error('加载分类树失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 树节点勾选 ──
|
||||
function onTreeCheck(data: any, { checkedKeys }: any) {
|
||||
// 简单模式:只支持单选过滤
|
||||
if (data.key === '_all') {
|
||||
// 勾选全部=清空过滤
|
||||
treeFilterDims.value = []
|
||||
treeFilterCats.value = []
|
||||
} else if (data.children) {
|
||||
// 维度节点
|
||||
treeFilterDims.value = checkedKeys.includes(data.key) ? [data.key] : []
|
||||
treeFilterCats.value = []
|
||||
} else {
|
||||
// 类别节点
|
||||
treeFilterCats.value = checkedKeys.includes(data.key) ? [data.key] : []
|
||||
treeFilterDims.value = []
|
||||
}
|
||||
loadKpis()
|
||||
}
|
||||
|
||||
// ── 加载KPI列表 ──
|
||||
async function loadKpis() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize }
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||
if (searchDimension.value) params.dimension = searchDimension.value
|
||||
if (searchCategory.value) params.category = searchCategory.value
|
||||
if (treeFilterDims.value.length > 0 && !params.dimension) params.dimension = treeFilterDims.value[0]
|
||||
if (treeFilterCats.value.length > 0 && !params.category) params.category = treeFilterCats.value[0]
|
||||
|
||||
const r: any = await kpiApi.list(params)
|
||||
kpis.value = r.data || []
|
||||
total.value = r.total || 0
|
||||
} catch (e) {
|
||||
console.error('加载KPI失败', e)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 获取当前值
|
||||
async function loadActualValues() {
|
||||
try {
|
||||
const r: any = await dashboardApi.kpis({ role: 'ceo', period: 'month' })
|
||||
const vals = r.data || []
|
||||
const valMap: Record<number, any> = {}
|
||||
for (const v of vals) {
|
||||
valMap[v.id] = v
|
||||
}
|
||||
for (const kpi of kpis.value) {
|
||||
if (valMap[kpi.id]) {
|
||||
kpi.actual_value = valMap[kpi.id].actual_value
|
||||
kpi.alert_level = valMap[kpi.id].alert_level
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchKeyword.value = ''
|
||||
searchDimension.value = ''
|
||||
searchCategory.value = ''
|
||||
treeFilterDims.value = []
|
||||
treeFilterCats.value = []
|
||||
if (treeRef.value) treeRef.value.setCheckedKeys([])
|
||||
page.value = 1
|
||||
loadKpis()
|
||||
}
|
||||
|
||||
// ── 多选 ──
|
||||
function onSelectionChange(rows: any[]) {
|
||||
selectedIds.value = rows.map((r: any) => r.id)
|
||||
}
|
||||
|
||||
function batchDelete() {
|
||||
ElMessageBox.confirm(`确定删除选中的 ${selectedIds.value.length} 条KPI?`, '提示').then(async () => {
|
||||
for (const id of selectedIds.value) {
|
||||
try { await kpiApi.delete(id) } catch (e) {}
|
||||
}
|
||||
ElMessage.success('批量删除完成')
|
||||
selectedIds.value = []
|
||||
loadKpis()
|
||||
loadCategories()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function batchExport() {
|
||||
const header = '编码,名称,维度,二级类别,目标值,频率,数据源,负责人,计算公式'
|
||||
const rows = kpis.value.map((k: any) =>
|
||||
[k.kpi_code, k.kpi_name, dimLabel(k.dimension), catLabel(k.category), k.target_value, freqLabel(k.frequency), sourceLabel(k.data_source_type), k.responsible_user, (k.formula || '')].join(',')
|
||||
)
|
||||
const bom = '\uFEFF' // Excel UTF-8 BOM
|
||||
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = 'KPI字典_' + new Date().toISOString().slice(0, 10) + '.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── 新建/保存 ──
|
||||
function onDimensionChange(val: string) {
|
||||
form.value.category = ''
|
||||
}
|
||||
|
||||
async function saveKPI() {
|
||||
try {
|
||||
if (editMode.value) {
|
||||
await kpiApi.update(form.value.id, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await kpiApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
showForm.value = false
|
||||
loadKpis()
|
||||
loadCategories()
|
||||
} catch (e) {
|
||||
ElMessage.error(editMode.value ? '保存失败' : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
function doDelete(id: number) {
|
||||
ElMessageBox.confirm('确定删除此KPI?', '提示').then(async () => {
|
||||
try { await kpiApi.delete(id); ElMessage.success('已删除'); loadKpis(); loadCategories() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.detail || '删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ── 从模板创建 ──
|
||||
const showTemplateSelect = ref(false)
|
||||
const templateLoading = ref(false)
|
||||
const templates = ref<any[]>([])
|
||||
const templateKeyword = ref('')
|
||||
const templateDimFilter = ref('')
|
||||
const selectedTemplate = ref<any>(null)
|
||||
const instantiateForm = ref<any>({})
|
||||
|
||||
async function loadTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (templateKeyword.value) params.keyword = templateKeyword.value
|
||||
if (templateDimFilter.value) params.dimension = templateDimFilter.value
|
||||
const r: any = await templateApi.list(params)
|
||||
templates.value = r.data || []
|
||||
} catch (e) {
|
||||
console.error('加载模板失败', e)
|
||||
}
|
||||
templateLoading.value = false
|
||||
}
|
||||
|
||||
function onTemplateSelect(tpl: any) {
|
||||
selectedTemplate.value = tpl
|
||||
instantiateForm.value = {
|
||||
kpi_code: '',
|
||||
kpi_name: '',
|
||||
target_value: undefined,
|
||||
data_source_type: 'manual',
|
||||
responsible_dept: '',
|
||||
responsible_user: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstantiate() {
|
||||
if (!selectedTemplate.value) return
|
||||
try {
|
||||
const data: any = {}
|
||||
if (instantiateForm.value.kpi_code) data.kpi_code = instantiateForm.value.kpi_code
|
||||
if (instantiateForm.value.kpi_name) data.kpi_name = instantiateForm.value.kpi_name
|
||||
if (instantiateForm.value.target_value !== undefined && instantiateForm.value.target_value !== null)
|
||||
data.target_value = instantiateForm.value.target_value
|
||||
if (instantiateForm.value.data_source_type) data.data_source_type = instantiateForm.value.data_source_type
|
||||
if (instantiateForm.value.responsible_dept) data.responsible_dept = instantiateForm.value.responsible_dept
|
||||
if (instantiateForm.value.responsible_user) data.responsible_user = instantiateForm.value.responsible_user
|
||||
await templateApi.instantiate(selectedTemplate.value.id, data)
|
||||
ElMessage.success(`已从模板「${selectedTemplate.value.kpi_name}」创建KPI`)
|
||||
showTemplateSelect.value = false
|
||||
selectedTemplate.value = null
|
||||
instantiateForm.value = {}
|
||||
loadKpis()
|
||||
loadCategories()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadKpis()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-dict-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.page-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.main-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.left-tree {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tree-header {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.tree-node-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
.tree-count {
|
||||
font-size: 11px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.right-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #ecf5ff;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.selected-info {
|
||||
font-size: 13px;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.kpi-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
.threshold-hint {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.status-red { color: #f56c6c; font-weight: 500; }
|
||||
.status-yellow { color: #e6a23c; font-weight: 500; }
|
||||
.status-green { color: #67c23a; font-weight: 500; }
|
||||
.status-none { color: #606266; }
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.total-info {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
:deep(.el-tree-node__content) {
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<KPIListView
|
||||
:dimension="'learning'"
|
||||
:title="'学习成长维度KPI看板'"
|
||||
:icon="'📚'"
|
||||
:stat-labels="{ total: '学习指标总数', green: '达标', yellow: '预警', red: '未达标', noData: '无数据' }"
|
||||
:templates="LEARNING_TEMPLATES"
|
||||
:has-categories="true"
|
||||
:categories="CAPITAL_CATEGORIES"
|
||||
:name-placeholder="'关键岗位胜任度'"
|
||||
:unit-options="['%', '分', '小时', '次', '个']"
|
||||
form-title-create="新建学习成长KPI"
|
||||
form-title-edit="编辑学习成长KPI"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import KPIListView from '../components/KPIListView.vue'
|
||||
|
||||
// ── 9个预设指标模板 ──
|
||||
const LEARNING_TEMPLATES = [
|
||||
// 人力资本 (3)
|
||||
{ category: '人力资本', name: '关键岗位胜任度', unit: '%', targetValue: 90 },
|
||||
{ category: '人力资本', name: '培训完成率', unit: '%', targetValue: 100 },
|
||||
{ category: '人力资本', name: '核心技能掌握度', unit: '%', targetValue: 85 },
|
||||
// 信息资本 (3)
|
||||
{ category: '信息资本', name: '系统覆盖率', unit: '%', targetValue: 95 },
|
||||
{ category: '信息资本', name: '数据自动化率', unit: '%', targetValue: 80 },
|
||||
{ category: '信息资本', name: '报表生成时间', unit: '小时', targetValue: 2 },
|
||||
// 组织资本 (3)
|
||||
{ category: '组织资本', name: '战略认知度', unit: '%', targetValue: 85 },
|
||||
{ category: '组织资本', name: '员工满意度', unit: '%', targetValue: 80 },
|
||||
{ category: '组织资本', name: '跨部门协作评分', unit: '分', targetValue: 4 },
|
||||
]
|
||||
|
||||
// ── 资本类型分类配置 ──
|
||||
const CAPITAL_CATEGORIES = [
|
||||
{ key: '人力资本', label: '人力资本', icon: '🧑🎓', subtitle: '关键岗位胜任度 / 培训完成率 / 核心技能掌握度', color: '#409EFF' },
|
||||
{ key: '信息资本', label: '信息资本', icon: '💻', subtitle: '系统覆盖率 / 数据自动化率 / 报表生成时间', color: '#67C23A' },
|
||||
{ key: '组织资本', label: '组织资本', icon: '🏛️', subtitle: '战略认知度 / 员工满意度 / 跨部门协作评分', color: '#E6A23C' },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,979 @@
|
||||
<template>
|
||||
<div class="map-canvas-page" ref="canvasRef">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<h3>战略地图画布 — 四层泳道</h3>
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadCanvas">
|
||||
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls" title="画布缩放">
|
||||
<el-button size="small" @click="zoomOut" :disabled="zoomLevel <= 0.5">−</el-button>
|
||||
<span class="zoom-label">{{ Math.round(zoomLevel * 100) }}%</span>
|
||||
<el-button size="small" @click="zoomIn" :disabled="zoomLevel >= 1.5">+</el-button>
|
||||
<el-button size="small" @click="zoomReset">100%</el-button>
|
||||
</div>
|
||||
<el-button type="primary" @click="saveCanvas">保存</el-button>
|
||||
<el-button type="success" @click="publishMap" v-if="currentMap?.status === 'draft'">发布</el-button>
|
||||
<el-button @click="showVersions = true; loadVersions()">版本历史</el-button>
|
||||
<el-button @click="goAlignment" type="info" plain>
|
||||
<svg style="width:14px;height:14px;margin-right:4px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h4l2-3 3 3 4-7 3 5 2-2 4 4"/>
|
||||
</svg>
|
||||
查看执行看板
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连线模式提示 -->
|
||||
<div v-if="linkingFrom" class="linking-bar">
|
||||
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
|
||||
<el-button size="small" @click="cancelLink">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state">
|
||||
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写">
|
||||
<template #image>
|
||||
<svg viewBox="0 0 120 120" width="120" height="120">
|
||||
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" />
|
||||
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" />
|
||||
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" />
|
||||
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" />
|
||||
</svg>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 画布主体(纵向泳道布局,CSS zoom缩放) -->
|
||||
<div class="canvas-scroll-wrap" ref="scrollWrapRef" :style="{ zoom: zoomLevel }">
|
||||
<div class="canvas-body" ref="bodyRef">
|
||||
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
|
||||
<div
|
||||
v-for="(cfg, idx) in orderedLayerConfigs"
|
||||
:key="cfg.key"
|
||||
:ref="el => setLayerRef(cfg.key, el)"
|
||||
class="layer-swimlane-wrapper"
|
||||
:style="{ borderLeft: '4px solid ' + cfg.color }"
|
||||
>
|
||||
<StrategyLayer
|
||||
:ref="el => setLayerCompRef(cfg.key, el)"
|
||||
:layer-key="cfg.key"
|
||||
:label="cfg.label"
|
||||
:icon="cfg.icon"
|
||||
:color="cfg.color"
|
||||
:objectives="getLayerNodes(cfg.key)"
|
||||
:linking-from-key="linkingFrom?.key ?? null"
|
||||
:drag-connect-target="dragConnectTarget"
|
||||
:icon-map="iconMap"
|
||||
:kpi-name-map="kpiNameMap"
|
||||
:all-kpis="allKpis"
|
||||
:get-node-level="getNodeLevel"
|
||||
:get-node-progress="getNodeProgress"
|
||||
@add-objective="(k: string) => openAddDialog(k)"
|
||||
@edit-objective="(p: any) => openEditDialog(p.dimKey, p.idx, p.obj)"
|
||||
@delete-objective="(p: any) => deleteNode(p.dimKey, p.idx)"
|
||||
@start-link="(p: any) => { const ps = p.key.split('-'); startLink(ps[0], parseInt(ps[ps.length-1]), p.obj) }"
|
||||
@link-drag-start="(e: MouseEvent, dk: string, oi: number, obj: any) => onLinkDragStart(e, dk, oi, obj)"
|
||||
@kpi-click="(code: string) => goKPI(code)"
|
||||
@show-plan-list="(p: any) => showPlanList(p.dimKey, p.idx, p.obj)"
|
||||
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
|
||||
@reorder="(dk: string) => onLayerReorder(dk)"
|
||||
/>
|
||||
|
||||
<!-- 跨层箭头指示器(本层→下层) -->
|
||||
<div v-if="idx < orderedLayerConfigs.length - 1" class="cross-layer-arrow">
|
||||
<svg width="24" height="32" viewBox="0 0 24 32">
|
||||
<line x1="12" y1="0" x2="12" y2="24" stroke="#909399" stroke-width="2" stroke-dasharray="4,3" />
|
||||
<polygon points="4,22 12,30 20,22" fill="#909399" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end canvas-body -->
|
||||
</div><!-- end canvas-scroll-wrap -->
|
||||
|
||||
<!-- SVG 连线层 (覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
<ConnectionLines
|
||||
ref="connectionLinesRef"
|
||||
:layer-refs="layerRefs"
|
||||
:node-refs="nodeRefs"
|
||||
:layer-keys="layerKeysOrdered"
|
||||
:connections="connections"
|
||||
:temp-line="tempLine"
|
||||
:container-key="recalcTrigger"
|
||||
@select-connection="onSelectConnection"
|
||||
@delete-connection="onDeleteConnection"
|
||||
/>
|
||||
|
||||
<!-- 节点编辑弹窗 -->
|
||||
<NodeEditDialog
|
||||
:visible="showNodeDialog"
|
||||
:node-data="editingNodeData"
|
||||
:layer-key="editingLayerKey"
|
||||
@update:visible="showNodeDialog = $event"
|
||||
@save="onNodeSave"
|
||||
/>
|
||||
|
||||
<!-- 版本历史弹窗 -->
|
||||
<div v-if="showVersions" class="mc-dialog-overlay" @click.self="showVersions=false">
|
||||
<div class="mc-dialog-box" style="width:620px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>版本历史</span>
|
||||
<button class="mc-dialog-close" @click="showVersions=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="mc-table" v-if="versions.length > 0">
|
||||
<thead><tr><th>说明</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="v in versions" :key="v.id">
|
||||
<td>{{ v.comment || '-' }}</td>
|
||||
<td>{{ v.created_at }}</td>
|
||||
<td><button class="mc-btn mc-btn-sm mc-btn-warn" @click="rollback(v.id)">回滚</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else style="text-align:center;color:#999;padding:20px;">暂无版本记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI详情浮层 -->
|
||||
<div v-if="showKpiDetail" class="mc-dialog-overlay" @click.self="showKpiDetail=false">
|
||||
<div class="mc-dialog-box" style="width:560px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>📊 {{ kpiDetailObjName }} — KPI详情</span>
|
||||
<button class="mc-dialog-close" @click="showKpiDetail=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:360px;overflow-y:auto;">
|
||||
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
该目标暂未关联KPI
|
||||
</div>
|
||||
<div v-for="(item, idx) in kpiDetailList" :key="idx" class="kpi-detail-card" :class="'kpi-detail-' + getKpiLevel(item)">
|
||||
<div class="kpi-detail-top">
|
||||
<span class="kpi-detail-badge">{{ badgeIcon(getKpiLevel(item)) }}</span>
|
||||
<span class="kpi-detail-code">{{ item.code }}</span>
|
||||
<span class="kpi-detail-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-values">
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">实际值</span>
|
||||
<span class="kdv-val">{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}</span>
|
||||
</div>
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">目标值</span>
|
||||
<span class="kdv-val">{{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
|
||||
</div>
|
||||
<div v-if="item.actual != null && item.target" class="kdv-item">
|
||||
<span class="kdv-label">达成率</span>
|
||||
<span class="kdv-val" :style="{ color: getKpiLevel(item) === 'red' ? '#f56c6c' : getKpiLevel(item) === 'yellow' ? '#e6a23c' : '#67c23a' }">
|
||||
{{ (item.actual / item.target * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.unit" class="kdv-item">
|
||||
<span class="kdv-label">单位</span>
|
||||
<span class="kdv-val">{{ item.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<el-button size="small" @click="showKpiDetail=false">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="editKpiDetailObj">编辑目标</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 行动方案列表弹窗 -->
|
||||
<div v-if="showPlanPopup" class="mc-dialog-overlay" @click.self="showPlanPopup=false">
|
||||
<div class="mc-dialog-box" style="width:600px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>📋 {{ planPopupObjName }} — 关联行动方案</span>
|
||||
<button class="mc-dialog-close" @click="showPlanPopup=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
|
||||
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
暂无关联的行动方案
|
||||
</div>
|
||||
<div v-for="p in planPopupList" :key="p.id" class="plan-popup-card">
|
||||
<div class="plan-popup-top">
|
||||
<span class="plan-popup-status" :class="'pps-' + p.status">{{ planStatusLabel(p.status) }}</span>
|
||||
<span class="plan-popup-title">{{ p.title }}</span>
|
||||
</div>
|
||||
<div class="plan-popup-meta">
|
||||
<span v-if="p.assignee">👤 {{ p.assignee }}</span>
|
||||
<span v-if="p.due_date">📅 {{ p.due_date.slice(0, 10) }}</span>
|
||||
<span v-if="p.progress > 0">进度 {{ p.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<el-button size="small" @click="showPlanPopup=false">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="quickCreatePlan">+ 新建行动方案</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- P1-3: 关联知识点 -->
|
||||
<div style="margin-top:12px;">
|
||||
<KnowledgePanel related-page="/maps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import { mapApi, kpiApi } from "../api/index"
|
||||
import KnowledgePanel from '../components/KnowledgePanel.vue'
|
||||
import api from "../api/index"
|
||||
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
||||
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
|
||||
import NodeEditDialog from "../components/strategy-map/NodeEditDialog.vue"
|
||||
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
|
||||
|
||||
// ── 四层泳道配置 ──
|
||||
const layerKeysOrdered = [...LAYER_KEYS]
|
||||
const orderedLayerConfigs = getLayerList()
|
||||
|
||||
// ── 状态 ──
|
||||
const maps = ref<any[]>([])
|
||||
const selectedMap = ref<number | null>(null)
|
||||
const currentMap = ref<any>(null)
|
||||
const allKpis = ref<any[]>([])
|
||||
const kpiNameMap = reactive<Record<string, string>>({})
|
||||
|
||||
// 维度数据(对应backend的dimensions)
|
||||
const dimensions = reactive<any[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// 连线
|
||||
const connections = ref<any[]>([])
|
||||
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
|
||||
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
|
||||
const dragConnectTarget = ref<string | null>(null)
|
||||
const selectedConnIdx = ref<number | null>(null)
|
||||
|
||||
// 缩放
|
||||
const zoomLevel = ref(1)
|
||||
const scrollWrapRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 节点状态(红黄绿灯)
|
||||
const objectiveLevels = reactive<Record<string, string>>({})
|
||||
|
||||
interface KpiProgressItem {
|
||||
ratio: number
|
||||
level: string
|
||||
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[]
|
||||
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null
|
||||
}
|
||||
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
|
||||
|
||||
// 弹窗
|
||||
const showNodeDialog = ref(false)
|
||||
const editingNodeData = ref<any>(null)
|
||||
const editingLayerKey = ref('')
|
||||
const showVersions = ref(false)
|
||||
const versions = ref<any[]>([])
|
||||
|
||||
// DOM引用
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const layerRefs: Record<string, HTMLElement> = {}
|
||||
const nodeRefs: Record<string, HTMLElement> = {}
|
||||
const connectionLinesRef = ref<any>(null)
|
||||
const recalcTrigger = ref(0)
|
||||
|
||||
// 泳道组件实例引用(用于汇聚各层内部的nodeRefs给ConnectionLines)
|
||||
const layerCompRefs: Record<string, any> = {}
|
||||
function setLayerCompRef(key: string, el: any) {
|
||||
if (el) layerCompRefs[key] = el
|
||||
}
|
||||
|
||||
/** 从四个泳道组件汇聚所有节点DOM引用用于连线渲染,同步到nodeRefs */
|
||||
function collectAllNodeRefs(): Record<string, any> {
|
||||
void (0) // keep as side-effect function
|
||||
// 清空旧refs
|
||||
Object.keys(nodeRefs).forEach(k => delete (nodeRefs as any)[k])
|
||||
// 从各泳道组件汇聚
|
||||
for (const key of layerKeysOrdered) {
|
||||
const comp = layerCompRefs[key]
|
||||
if (comp?.nodeRefs) {
|
||||
Object.assign(nodeRefs, comp.nodeRefs)
|
||||
}
|
||||
}
|
||||
return nodeRefs
|
||||
}
|
||||
|
||||
// 拖拽引用(由 vuedraggable 接管,内部不维护)
|
||||
|
||||
// KPI详情浮层
|
||||
const showKpiDetail = ref(false)
|
||||
const kpiDetailObjName = ref("")
|
||||
const kpiDetailDimKey = ref("")
|
||||
const kpiDetailObjIdx = ref(-1)
|
||||
const kpiDetailList = ref<any[]>([])
|
||||
|
||||
// 行动方案弹窗
|
||||
const showPlanPopup = ref(false)
|
||||
const planPopupObjName = ref("")
|
||||
const planPopupDimKey = ref("")
|
||||
const planPopupObjIdx = ref(-1)
|
||||
const planPopupList = ref<any[]>([])
|
||||
|
||||
// ── 图标映射(从旧MapCanvas复用) ──
|
||||
const iconMap: Record<string, string> = {
|
||||
target: "Star", star: "StarFilled", rocket: "Top", chart: "DataLine",
|
||||
team: "UserFilled", light: "Lightning", shield: "Shield", gear: "Setting",
|
||||
handshake: "Handshake", medal: "Trophy",
|
||||
}
|
||||
|
||||
// ── 辅助方法 ──
|
||||
function getLayerNodes(key: string): any[] {
|
||||
const dim = dimensions.find((d: any) => d.key === key)
|
||||
return dim?.objectives || []
|
||||
}
|
||||
|
||||
function setLayerRef(key: string, el: any) {
|
||||
if (el) layerRefs[key] = el
|
||||
}
|
||||
|
||||
function getNodeLevel(key: string): string {
|
||||
return objectiveLevels[key] || 'gray'
|
||||
}
|
||||
function getNodeProgress(key: string): any {
|
||||
return kpiProgressMap[key] || null
|
||||
}
|
||||
|
||||
/** 确保dimensions中存在所有4层 */
|
||||
function ensureFourLayers() {
|
||||
for (const cfg of orderedLayerConfigs) {
|
||||
if (!dimensions.find((d: any) => d.key === cfg.key)) {
|
||||
dimensions.push({
|
||||
key: cfg.key,
|
||||
name: cfg.label,
|
||||
label: cfg.label,
|
||||
icon: cfg.icon,
|
||||
color: cfg.color,
|
||||
objectives: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 缩放 ──
|
||||
function zoomIn() { zoomLevel.value = Math.min(1.5, Math.round((zoomLevel.value + 0.1) * 10) / 10) }
|
||||
function zoomOut() { zoomLevel.value = Math.max(0.5, Math.round((zoomLevel.value - 0.1) * 10) / 10) }
|
||||
function zoomReset() { zoomLevel.value = 1 }
|
||||
|
||||
// ── 画布加载 ──
|
||||
function loadCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
dimensions.splice(0, dimensions.length)
|
||||
connections.value = []
|
||||
linkingFrom.value = null
|
||||
selectedConnIdx.value = null
|
||||
loading.value = true
|
||||
|
||||
api.get("/maps").then((r: any) => {
|
||||
const data = r.data || []
|
||||
const map = data.find((m: any) => m.id === selectedMap.value)
|
||||
currentMap.value = map
|
||||
if (map?.dimensions) {
|
||||
for (const dim of map.dimensions) {
|
||||
dimensions.push({
|
||||
key: dim.key,
|
||||
label: dim.name || dim.label,
|
||||
icon: dim.icon || '📋',
|
||||
color: dim.color || LAYER_CONFIG[dim.key]?.color || '#409eff',
|
||||
objectives: dim.objectives || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
// 确保四层都存在
|
||||
ensureFourLayers()
|
||||
connections.value = map?.canvas_data?.connections || []
|
||||
loading.value = false
|
||||
nextTick(() => {
|
||||
triggerRecalc()
|
||||
loadObjectiveLevels()
|
||||
})
|
||||
}).catch(() => {
|
||||
ensureFourLayers()
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ── 触发连线重算 ──
|
||||
function triggerRecalc() {
|
||||
recalcTrigger.value++
|
||||
collectAllNodeRefs()
|
||||
nextTick(() => {
|
||||
connectionLinesRef.value?.recalcAll()
|
||||
})
|
||||
}
|
||||
|
||||
// ── 连线交互 ──
|
||||
function startLink(dimKey: string, oi: number, obj: any) {
|
||||
selectedConnIdx.value = null
|
||||
linkingFrom.value = { key: `${dimKey}-${oi}`, obj }
|
||||
}
|
||||
|
||||
function cancelLink() {
|
||||
linkingFrom.value = null
|
||||
tempLine.value = null
|
||||
dragConnectTarget.value = null
|
||||
}
|
||||
|
||||
function onNodeClick(dimKey: string, oi: number, obj: any) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
// 连线模式
|
||||
if (linkingFrom.value) {
|
||||
if (linkingFrom.value.key === key) { cancelLink(); return }
|
||||
completeConnection(linkingFrom.value.key, key)
|
||||
return
|
||||
}
|
||||
// 有KPI则展示详情
|
||||
if (kpiProgressMap[key]?.kpiList?.length > 0) {
|
||||
showKpiDetailModal(dimKey, oi, obj)
|
||||
} else {
|
||||
openEditDialog(dimKey, oi, obj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发
|
||||
* SortableJS 已通过 :list 引用直接更新了响应式数组,
|
||||
* 这里只需触发连线重算和自动保存
|
||||
*/
|
||||
function onLayerReorder(dimKey: string) {
|
||||
nextTick(() => {
|
||||
triggerRecalc()
|
||||
debouncedSaveCanvas()
|
||||
})
|
||||
}
|
||||
|
||||
function completeConnection(fromKey: string, toKey: string) {
|
||||
const fromDim = fromKey.split('-')[0]
|
||||
const toDim = toKey.split('-')[0]
|
||||
if (fromDim === toDim) {
|
||||
// 同层连线 -> 同层右侧箭头
|
||||
}
|
||||
// 检查重复
|
||||
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
|
||||
ElMessage.warning("已存在相同连线"); cancelLink(); return
|
||||
}
|
||||
api.post(`/maps/${selectedMap.value}/connections`, {
|
||||
from: fromKey, to: toKey,
|
||||
}).then(() => {
|
||||
ElMessage.success("连线已添加")
|
||||
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
|
||||
cancelLink()
|
||||
nextTick(() => { triggerRecalc(); saveConnections() })
|
||||
}).catch((e: any) => {
|
||||
if (e?.response?.status !== 400) {
|
||||
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
|
||||
ElMessage.success("连线已添加(本地)")
|
||||
} else {
|
||||
ElMessage.warning(e?.response?.data?.detail || "连线失败")
|
||||
}
|
||||
cancelLink()
|
||||
nextTick(triggerRecalc)
|
||||
})
|
||||
}
|
||||
|
||||
// 跳转KPI详情
|
||||
function goKPI(kpiCode: string) {
|
||||
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
|
||||
window.location.href = found ? '/kpis/' + found.id : '/kpis'
|
||||
}
|
||||
|
||||
// ── 拖拽连线的鼠标跟踪 ──
|
||||
function onLinkDragStart(e: MouseEvent, dimKey: string, oi: number, obj: any) {
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
startLink(dimKey, oi, obj)
|
||||
const onUp = (up: MouseEvent) => {
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
const dist = Math.hypot(up.clientX - startX, up.clientY - startY)
|
||||
if (dist >= 5 && linkingFrom.value) {
|
||||
let targetKey: string | null = null
|
||||
for (const key of Object.keys(nodeRefs)) {
|
||||
if (key === linkingFrom.value.key) continue
|
||||
const el = nodeRefs[key]
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (up.clientX >= rect.left && up.clientX <= rect.right &&
|
||||
up.clientY >= rect.top && up.clientY <= rect.bottom) {
|
||||
targetKey = key; break
|
||||
}
|
||||
}
|
||||
if (targetKey) {
|
||||
const parts = targetKey.split('-')
|
||||
const tDim = parts[0]
|
||||
const tIdx = parseInt(parts[1])
|
||||
const dim = dimensions.find((d: any) => d.key === tDim)
|
||||
if (dim && dim.objectives[tIdx]) {
|
||||
completeConnection(linkingFrom.value.key, targetKey)
|
||||
}
|
||||
}
|
||||
cancelLink()
|
||||
}
|
||||
dragConnectTarget.value = null
|
||||
}
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
function onSelectConnection(idx: number) {
|
||||
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
|
||||
}
|
||||
|
||||
function onDeleteConnection(idx: number) {
|
||||
if (selectedConnIdx.value !== idx) return
|
||||
const conn = connections.value[idx]
|
||||
if (!conn) return
|
||||
api.delete(`/maps/${selectedMap.value}/connections`, {
|
||||
data: { from: conn.from, to: conn.to },
|
||||
}).then(() => {
|
||||
ElMessage.success("连线已删除")
|
||||
connections.value.splice(idx, 1)
|
||||
selectedConnIdx.value = null
|
||||
nextTick(triggerRecalc)
|
||||
}).catch(() => {
|
||||
connections.value.splice(idx, 1)
|
||||
selectedConnIdx.value = null
|
||||
nextTick(triggerRecalc)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 节点CRUD ──
|
||||
function openAddDialog(layerKey: string) {
|
||||
editingLayerKey.value = layerKey
|
||||
editingNodeData.value = null
|
||||
showNodeDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(dimKey: string, oi: number, obj: any) {
|
||||
editingLayerKey.value = dimKey
|
||||
editingNodeData.value = {
|
||||
id: obj.id,
|
||||
name: obj.name,
|
||||
targetValue: obj.targetValue ?? obj.target_value ?? null,
|
||||
currentValue: obj.currentValue ?? obj.current_value ?? null,
|
||||
unit: obj.unit || '%',
|
||||
owner: obj.owner || '',
|
||||
isLeading: obj.isLeading ?? obj.is_leading ?? false,
|
||||
description: obj.description || '',
|
||||
}
|
||||
// 保存编辑索引以便更新
|
||||
editingNodeData.value._dimKey = dimKey
|
||||
editingNodeData.value._index = oi
|
||||
showNodeDialog.value = true
|
||||
}
|
||||
|
||||
function onNodeSave(data: any) {
|
||||
const dim = dimensions.find((d: any) => d.key === data.layer)
|
||||
if (!dim) return
|
||||
|
||||
// 构建节点对象
|
||||
const node = {
|
||||
name: data.name,
|
||||
targetValue: data.targetValue,
|
||||
currentValue: data.currentValue,
|
||||
unit: data.unit,
|
||||
owner: data.owner,
|
||||
isLeading: data.isLeading,
|
||||
description: data.description || '',
|
||||
layer: data.layer,
|
||||
}
|
||||
|
||||
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === data.layer) {
|
||||
// 编辑已有节点
|
||||
const idx = editingNodeData.value._index
|
||||
if (dim.objectives[idx]) {
|
||||
// 保留原有kpis等额外字段
|
||||
const extra = {
|
||||
kpis: dim.objectives[idx].kpis || [],
|
||||
icon: dim.objectives[idx].icon || 'target',
|
||||
}
|
||||
dim.objectives[idx] = { ...node, ...extra }
|
||||
ElMessage.success("节点已更新")
|
||||
}
|
||||
} else {
|
||||
// 新增节点
|
||||
dim.objectives.push({
|
||||
...node,
|
||||
kpis: [],
|
||||
icon: 'target',
|
||||
})
|
||||
ElMessage.success("节点已添加")
|
||||
}
|
||||
|
||||
editingNodeData.value = null
|
||||
debouncedSaveCanvas()
|
||||
nextTick(triggerRecalc)
|
||||
}
|
||||
|
||||
async function deleteNode(dimKey: string, oi: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要删除这个节点吗?", "确认删除")
|
||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||
if (dim) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
|
||||
dim.objectives.splice(oi, 1)
|
||||
ElMessage.success("节点已删除")
|
||||
nextTick(() => { triggerRecalc(); debouncedSaveCanvas() })
|
||||
}
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isSaving = false
|
||||
|
||||
function debouncedSaveCanvas() {
|
||||
if (isSaving) return
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => { saveCanvas() }, 500)
|
||||
}
|
||||
|
||||
async function saveCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
if (isSaving) return
|
||||
isSaving = true
|
||||
const mapData = dimensions.map((d: any) => ({
|
||||
key: d.key, name: d.label || d.name, icon: d.icon, color: d.color,
|
||||
objectives: d.objectives,
|
||||
}))
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
version_num: currentMap.value?.version_num,
|
||||
})
|
||||
ElMessage.success("已保存")
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
ElMessage.warning("地图已被修改,刷新后重试")
|
||||
} else {
|
||||
console.error("saveCanvas error:", e)
|
||||
}
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConnections() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
canvas_data: { connections: connections.value },
|
||||
})
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── 发布/版本 ──
|
||||
async function publishMap() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, { status: "published" })
|
||||
ElMessage.success("已发布(自动创建版本快照)")
|
||||
currentMap.value.status = "published"
|
||||
loadCanvas()
|
||||
} catch (e) {
|
||||
ElMessage.error("发布失败")
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/versions`)
|
||||
versions.value = r.data || []
|
||||
} catch { versions.value = [] }
|
||||
}
|
||||
|
||||
async function rollback(verId: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
|
||||
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
|
||||
ElMessage.success("回滚成功,刷新页面")
|
||||
window.location.reload()
|
||||
} catch (e: any) {
|
||||
if (e !== "cancel") ElMessage.error("回滚失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载目标状态 ──
|
||||
async function loadObjectiveLevels() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
|
||||
for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key]
|
||||
for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key]
|
||||
const dims = r.dimensions || []
|
||||
for (const dim of dims) {
|
||||
const dimKey = dim.key
|
||||
const localDim = dimensions.find((d: any) => d.key === dimKey)
|
||||
if (!localDim) continue
|
||||
for (let i = 0; i < localDim.objectives.length; i++) {
|
||||
const key = `${dimKey}-${i}`
|
||||
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null }
|
||||
objectiveLevels[key] = 'gray'
|
||||
}
|
||||
dim.objectives.forEach((obj: any) => {
|
||||
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name)
|
||||
if (localIdx >= 0) {
|
||||
const key = `${dimKey}-${localIdx}`
|
||||
objectiveLevels[key] = obj.level || 'gray'
|
||||
const kpis = obj.kpis || []
|
||||
const kpiList: any[] = []
|
||||
let totalRatio = 0
|
||||
let count = 0
|
||||
for (const k of kpis) {
|
||||
kpiList.push({
|
||||
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value, unit: k.unit,
|
||||
})
|
||||
if (k.actual_value != null && k.target_value) {
|
||||
totalRatio += k.actual_value / k.target_value; count++
|
||||
}
|
||||
}
|
||||
if (kpiList.length > 0) {
|
||||
kpiProgressMap[key] = {
|
||||
ratio: count > 0 ? Math.round((totalRatio / count) * 100) : 0,
|
||||
level: obj.level || 'gray', kpiList,
|
||||
planSummary: obj.action_plan_summary || null,
|
||||
}
|
||||
} else if (obj.action_plan_summary?.total > 0) {
|
||||
kpiProgressMap[key] = { ratio: 0, level: obj.level || 'gray', kpiList: [], planSummary: obj.action_plan_summary }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── KPI详情浮层 ──
|
||||
function showKpiDetailModal(dimKey: string, oi: number, obj: any) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
kpiDetailObjName.value = obj.name
|
||||
kpiDetailDimKey.value = dimKey
|
||||
kpiDetailObjIdx.value = oi
|
||||
kpiDetailList.value = kpiProgressMap[key]?.kpiList || []
|
||||
showKpiDetail.value = true
|
||||
}
|
||||
|
||||
function editKpiDetailObj() {
|
||||
showKpiDetail.value = false
|
||||
const dim = dimensions.find((d: any) => d.key === kpiDetailDimKey.value)
|
||||
if (dim && dim.objectives[kpiDetailObjIdx.value]) {
|
||||
openEditDialog(kpiDetailDimKey.value, kpiDetailObjIdx.value, dim.objectives[kpiDetailObjIdx.value])
|
||||
}
|
||||
}
|
||||
|
||||
function getKpiLevel(item: any): string {
|
||||
if (item.actual != null && item.target) {
|
||||
const ratio = item.actual / item.target
|
||||
if (ratio >= 0.9) return 'green'
|
||||
if (ratio >= 0.7) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function fmtKpiVal(val: any): string {
|
||||
if (val == null) return '—'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function badgeIcon(level: string): string {
|
||||
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
|
||||
}
|
||||
|
||||
// ── 行动方案 ──
|
||||
async function showPlanList(dimKey: string, oi: number, obj: any) {
|
||||
planPopupObjName.value = obj.name
|
||||
planPopupDimKey.value = dimKey
|
||||
planPopupObjIdx.value = oi
|
||||
planPopupList.value = []
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
|
||||
const dims = r.dimensions || []
|
||||
const allPlans = r.action_plans || []
|
||||
for (const dim of dims) {
|
||||
if (dim.key !== dimKey) continue
|
||||
for (const robj of dim.objectives || []) {
|
||||
if (robj.name !== obj.name) continue
|
||||
const objKpiIds = (robj.kpis || []).map((k: any) => k.kpi_id)
|
||||
planPopupList.value = allPlans.filter((p: any) => objKpiIds.includes(p.kpi_id))
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
showPlanPopup.value = true
|
||||
}
|
||||
|
||||
function planStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function quickCreatePlan() {
|
||||
showPlanPopup.value = false
|
||||
window.location.href = `/action-plans?dimension=${planPopupDimKey.value}`
|
||||
}
|
||||
|
||||
function goAlignment() {
|
||||
const url = currentMap.value ? `/alignment?map_id=${currentMap.value.id}` : '/alignment'
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await mapApi.list()
|
||||
maps.value = r.data || []
|
||||
if (maps.value.length > 0) {
|
||||
selectedMap.value = maps.value[0].id
|
||||
loadCanvas()
|
||||
} else {
|
||||
ensureFourLayers()
|
||||
loading.value = false
|
||||
}
|
||||
const k: any = await kpiApi.list({ page_size: 100 })
|
||||
allKpis.value = k.data || []
|
||||
for (const kpi of allKpis.value) {
|
||||
if (kpi.kpi_code) kpiNameMap[kpi.kpi_code] = kpi.kpi_name || kpi.kpi_code
|
||||
}
|
||||
window.addEventListener('resize', triggerRecalc)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', triggerRecalc)
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── 页面布局 ── */
|
||||
.map-canvas-page { padding: 16px; height: 100vh; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
|
||||
.toolbar h3 { margin: 0; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.zoom-controls { display: flex; align-items: center; gap: 2px; margin-right: 8px; }
|
||||
.zoom-controls .el-button { padding: 4px 8px !important; font-size: 13px; min-height: auto; }
|
||||
.zoom-label { display: inline-block; min-width: 32px; text-align: center; font-size: 12px; color: #666; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.linking-bar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
|
||||
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ── 画布滚动容器 ── */
|
||||
.canvas-scroll-wrap {
|
||||
flex: 1; min-height: 0; overflow: auto;
|
||||
position: relative; width: 100%;
|
||||
}
|
||||
|
||||
.canvas-body {
|
||||
display: flex; flex-direction: column; gap: 0;
|
||||
position: relative; isolation: isolate;
|
||||
width: 100%; padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ── 泳道外层包装 ── */
|
||||
.layer-swimlane-wrapper {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── 跨层箭头指示器 ── */
|
||||
.cross-layer-arrow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 重新调用的样式(从旧MapCanvas复用) ── */
|
||||
.kpi-detail-card {
|
||||
border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px;
|
||||
}
|
||||
.kpi-detail-red { border-left: 4px solid #f56c6c; }
|
||||
.kpi-detail-yellow { border-left: 4px solid #e6a23c; }
|
||||
.kpi-detail-green { border-left: 4px solid #67c23a; }
|
||||
.kpi-detail-gray { border-left: 4px solid #ccc; }
|
||||
.kpi-detail-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.kpi-detail-badge { font-size: 14px; }
|
||||
.kpi-detail-code { font-size: 11px; color: #999; font-family: monospace; }
|
||||
.kpi-detail-name { font-weight: 600; font-size: 14px; }
|
||||
.kpi-detail-values { padding-left: 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.kdv-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.kdv-label { color: #999; width: 56px; }
|
||||
.kdv-val { font-weight: 500; }
|
||||
|
||||
.plan-popup-card {
|
||||
border: 1px solid #eee; border-radius: 8px; padding: 10px 12px; margin-bottom: 6px;
|
||||
}
|
||||
.plan-popup-card:hover { background: #fafafa; }
|
||||
.plan-popup-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.plan-popup-status {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
|
||||
}
|
||||
.pps-pending { background: #f5f7fa; color: #909399; }
|
||||
.pps-in_progress { background: #fdf6ec; color: #e6a23c; }
|
||||
.pps-completed { background: #f0f9eb; color: #67c23a; }
|
||||
.pps-cancelled { background: #f5f7fa; color: #bbb; }
|
||||
.plan-popup-title { font-weight: 500; font-size: 13px; }
|
||||
.plan-popup-meta { display: flex; gap: 12px; font-size: 11px; color: #999; padding-left: 4px; }
|
||||
|
||||
/* ── 自定义弹窗(从旧MapCanvas复用) ── */
|
||||
.mc-dialog-overlay {
|
||||
position: fixed; top:0; left:0; right:0; bottom:0;
|
||||
background:rgba(0,0,0,0.5); z-index:9999;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.mc-dialog-box {
|
||||
background:#fff; border-radius:12px; width:520px;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.18); overflow:hidden;
|
||||
}
|
||||
.mc-dialog-header {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
padding:16px 20px; border-bottom:1px solid #f0f0f0;
|
||||
font-weight:600; font-size:16px;
|
||||
}
|
||||
.mc-dialog-close {
|
||||
background:none; border:none; font-size:22px; color:#999;
|
||||
cursor:pointer; padding:0 4px; line-height:1;
|
||||
}
|
||||
.mc-dialog-close:hover { color:#333; }
|
||||
.mc-dialog-body { padding:20px; }
|
||||
.mc-dialog-footer {
|
||||
display:flex; justify-content:flex-end; gap:10px;
|
||||
padding:12px 20px; border-top:1px solid #f0f0f0;
|
||||
}
|
||||
.mc-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.mc-table th, .mc-table td { padding:10px 12px; text-align:left; border-bottom:1px solid #f0f0f0; }
|
||||
.mc-table th { background:#fafafa; font-weight:600; color:#666; font-size:12px; }
|
||||
.mc-table tr:hover td { background:#f5f7fa; }
|
||||
.mc-btn {
|
||||
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
|
||||
background:#fff; color:#333; font-size:14px; cursor:pointer;
|
||||
}
|
||||
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
|
||||
.mc-btn-sm { padding:5px 12px; font-size:12px; }
|
||||
.mc-btn-warn { color:#e6a23c; border-color:#e6a23c; }
|
||||
.mc-btn-warn:hover { background:#e6a23c; color:#fff; }
|
||||
.mc-btn:hover { opacity:0.85; }
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="review-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="review-header">
|
||||
<div class="header-left">
|
||||
<el-button text @click="$router.push('/maps')">← 返回战略地图</el-button>
|
||||
<el-button text type="primary" @click="goCanvas" v-if="selectedMap">🗺️ 打开画布</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, useRouter } from "vue-router"
|
||||
import { ElMessage } from "element-plus"
|
||||
import api from "../api/index"
|
||||
|
||||
const route = useRoute()
|
||||
const $router = useRouter()
|
||||
|
||||
// ── 状态 ──
|
||||
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}天`
|
||||
}
|
||||
|
||||
function goCanvas() {
|
||||
if (selectedMap.value) {
|
||||
$router.push('/maps/canvas/' + selectedMap.value)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预测模拟</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<!-- Tab 1: CVP本量利分析 -->
|
||||
<el-tab-pane label="本量利分析" name="cvp">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="cvpForm" label-width="140px">
|
||||
<el-form-item label="产品单价"><el-input-number v-model="cvpForm.unit_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="单位变动成本"><el-input-number v-model="cvpForm.unit_variable_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="固定成本"><el-input-number v-model="cvpForm.fixed_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="实际销量(可选)"><el-input-number v-model="cvpForm.actual_volume" :min="0" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="目标利润(可选)"><el-input-number v-model="cvpForm.target_profit" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doCvp" :loading="cvpLoading">计算</el-button>
|
||||
<el-button @click="cvpForm = { unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 }">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="cvpResult.bep_units">
|
||||
<template #header>分析结果</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销量</div><div class="r-value">{{ cvpResult.bep_units }} 件</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销售额</div><div class="r-value">{{ formatMoney(cvpResult.bep_revenue) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">边际贡献率</div><div class="r-value blue">{{ cvpResult.contribution_ratio }}%</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;">
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_units !== undefined"><div class="result-card"><div class="r-label">安全边际(量)</div><div class="r-value green">{{ cvpResult.safety_margin_units }} 件</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_ratio !== undefined"><div class="result-card"><div class="r-label">安全边际率</div><div class="r-value" :class="safetyColor(cvpResult.safety_margin_ratio)">{{ cvpResult.safety_margin_ratio }}%</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.actual_profit !== undefined"><div class="result-card"><div class="r-label">当前利润</div><div class="r-value orange">{{ formatMoney(cvpResult.actual_profit) }}</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;" v-if="cvpResult.target_units">
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需销量</div><div class="r-value purple">{{ cvpResult.target_units }} 件</div></div></el-col>
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需收入</div><div class="r-value purple">{{ formatMoney(cvpResult.target_revenue) }}</div></div></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!cvpLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 投资决策 -->
|
||||
<el-tab-pane label="投资决策" name="investment">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="invForm" label-width="140px">
|
||||
<el-form-item label="初始投资"><el-input-number v-model="invForm.initial_investment" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="折现率(%)"><el-input-number v-model="invForm.discount_rate" :min="0" :max="100" :precision="1" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="现金流(每年)">
|
||||
<div v-for="(cf, idx) in invForm.cash_flows" :key="idx" style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<span style="line-height:32px;width:80px;">第{{ idx+1 }}年</span>
|
||||
<el-input-number v-model="invForm.cash_flows[idx]" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-button v-if="invForm.cash_flows.length > 1" size="small" type="danger" @click="invForm.cash_flows.splice(idx, 1)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="invForm.cash_flows.push(0)">+ 添加年份</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doInvestment" :loading="invLoading">计算</el-button>
|
||||
<el-button @click="fillInvExample">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="invResult.npv_analysis">
|
||||
<template #header>分析结果</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">净现值 (NPV)</div><div class="r-value" :class="invResult.npv_analysis.npv > 0 ? 'green' : 'red'">{{ formatMoney(invResult.npv_analysis.npv) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">内部收益率 (IRR)</div><div class="r-value blue">{{ invResult.irr_analysis.irr }}%</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">盈利指数 (PI)</div><div class="r-value purple">{{ invResult.npv_analysis.profitability_index }}</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">静态回收期</div><div class="r-value">{{ invResult.irr_analysis.payback_period || '--' }} 年</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">动态回收期</div><div class="r-value">{{ invResult.irr_analysis.discounted_payback_period || '>N期' }} 年</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">可行性</div><div class="r-value" :class="invResult.npv_analysis.is_viable ? 'green' : 'red'">{{ invResult.npv_analysis.is_viable ? '✅ 可行' : '❌ 不可行' }}</div></div></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!invLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 敏感性分析 -->
|
||||
<el-tab-pane label="敏感性分析" name="sensitivity">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="sensForm" label-width="140px">
|
||||
<el-form-item label="基准收入"><el-input-number v-model="sensForm.base_revenue" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="基准成本"><el-input-number v-model="sensForm.base_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="变动步长(%)"><el-input-number v-model="sensForm.step" :min="1" :max="50" style="width:200px;" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doSensitivity" :loading="sensLoading">计算</el-button>
|
||||
<el-button @click="sensForm = { base_revenue: 1000000, base_cost: 700000, step: 5 }">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="sensResult.base_revenue">
|
||||
<template #header>
|
||||
敏感性分析结果 — 基准利润:{{ formatMoney(sensResult.base_profit) }}
|
||||
</template>
|
||||
<el-table :data="sensResult.factors" border stripe size="small" style="width:100%;" max-height="400">
|
||||
<el-table-column prop="change_pct" label="变动%" width="70" align="center">
|
||||
<template #default="{ row }"><span :style="{ color: row.change_pct > 0 ? '#f56c6c' : '#67c23a', fontWeight:600 }">{{ row.change_pct > 0 ? '+' : '' }}{{ row.change_pct }}%</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="revenue_change_profit" label="收入变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.revenue_change_profit) }}</template></el-table-column>
|
||||
<el-table-column prop="revenue_sensitivity" label="收入敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.revenue_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.revenue_sensitivity > 0 ? '+' : '' }}{{ row.revenue_sensitivity }}%</span></template></el-table-column>
|
||||
<el-table-column prop="cost_change_profit" label="成本变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.cost_change_profit) }}</template></el-table-column>
|
||||
<el-table-column prop="cost_sensitivity" label="成本敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.cost_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.cost_sensitivity > 0 ? '+' : '' }}{{ row.cost_sensitivity }}%</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!sensLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 情景模拟 -->
|
||||
<el-tab-pane label="情景模拟" name="scenario">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入三个情景</template>
|
||||
<el-form label-width="120px">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="success" size="small">乐观情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.optimistic.revenue" placeholder="收入" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.optimistic.cost" placeholder="成本" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="warning" size="small">中性情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.base.revenue" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.base.cost" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="danger" size="small">悲观情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.pessimistic.revenue" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.pessimistic.cost" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="doScenario" :loading="scenarioLoading">模拟</el-button>
|
||||
<el-button @click="fillScenarioExample">填充示例</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="scenarioResult.scenarios">
|
||||
<template #header>
|
||||
模拟结果
|
||||
<span style="margin-left:12px;font-size:12px;color:#999;">
|
||||
期望利润:{{ formatMoney(scenarioResult.expected_profit) }}
|
||||
| 标准差:{{ formatMoney(scenarioResult.std_deviation) }}
|
||||
</span>
|
||||
</template>
|
||||
<el-table :data="scenarioResult.scenarios" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="scenario" label="情景" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.scenario === '乐观' ? 'success' : row.scenario === '中性' ? 'warning' : 'danger'" size="small">{{ row.scenario }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="revenue" label="收入" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.revenue) }}</template></el-table-column>
|
||||
<el-table-column prop="cost" label="成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.cost) }}</template></el-table-column>
|
||||
<el-table-column prop="profit" label="利润" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.profit >= 0 ? '#67c23a' : '#f56c6c', fontWeight:600 }">{{ formatMoney(row.profit) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="profit_margin" label="利润率" width="80" align="right"><template #default="{ row }">{{ row.profit_margin }}%</template></el-table-column>
|
||||
<el-table-column prop="deviation_from_base" label="偏差" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.deviation_from_base > 0 ? '#67c23a' : '#f56c6c' }">{{ row.deviation_from_base > 0 ? '+' : '' }}{{ formatMoney(row.deviation_from_base) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviation_pct" label="偏差%" width="80" align="right"><template #default="{ row }">{{ row.deviation_pct }}%</template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!scenarioLoading" description="输入三个情景后点击「模拟」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { predictApi } from '../api/index'
|
||||
|
||||
const activeTab = ref('cvp')
|
||||
|
||||
function formatMoney(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function safetyColor(r: number) { return r > 25 ? 'green' : r > 10 ? 'orange' : 'red' }
|
||||
|
||||
// ── CVP ──
|
||||
const cvpLoading = ref(false)
|
||||
const cvpForm = ref({ unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 })
|
||||
const cvpResult = ref<any>({})
|
||||
|
||||
async function doCvp() {
|
||||
cvpLoading.value = true
|
||||
try {
|
||||
cvpResult.value = await predictApi.cvp(cvpForm.value) as any
|
||||
} catch { ElMessage.error('CVP计算失败') }
|
||||
cvpLoading.value = false
|
||||
}
|
||||
|
||||
// ── 投资决策 ──
|
||||
const invLoading = ref(false)
|
||||
const invForm = ref({ initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] })
|
||||
const invResult = ref<any>({})
|
||||
|
||||
function fillInvExample() {
|
||||
invForm.value = { initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] }
|
||||
}
|
||||
|
||||
async function doInvestment() {
|
||||
invLoading.value = true
|
||||
try {
|
||||
invResult.value = await predictApi.investment(invForm.value) as any
|
||||
} catch { ElMessage.error('投资决策计算失败') }
|
||||
invLoading.value = false
|
||||
}
|
||||
|
||||
// ── 敏感性 ──
|
||||
const sensLoading = ref(false)
|
||||
const sensForm = ref({ base_revenue: 1000000, base_cost: 700000, step: 5 })
|
||||
const sensResult = ref<any>({})
|
||||
|
||||
async function doSensitivity() {
|
||||
sensLoading.value = true
|
||||
try {
|
||||
sensResult.value = await predictApi.sensitivity(sensForm.value) as any
|
||||
} catch { ElMessage.error('敏感性分析失败') }
|
||||
sensLoading.value = false
|
||||
}
|
||||
|
||||
// ── 情景 ──
|
||||
const scenarioLoading = ref(false)
|
||||
const scenarioForm = ref({
|
||||
optimistic: { revenue: 130, cost: 90 },
|
||||
base: { revenue: 100, cost: 100 },
|
||||
pessimistic: { revenue: 80, cost: 110 },
|
||||
})
|
||||
const scenarioResult = ref<any>({})
|
||||
|
||||
function fillScenarioExample() {
|
||||
scenarioForm.value = {
|
||||
optimistic: { revenue: 130, cost: 90 },
|
||||
base: { revenue: 100, cost: 100 },
|
||||
pessimistic: { revenue: 80, cost: 110 },
|
||||
}
|
||||
}
|
||||
|
||||
async function doScenario() {
|
||||
scenarioLoading.value = true
|
||||
try {
|
||||
scenarioResult.value = await predictApi.scenario(scenarioForm.value) as any
|
||||
} catch { ElMessage.error('情景模拟失败') }
|
||||
scenarioLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-card { background: #f5f7fa; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.r-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
|
||||
.r-value { font-size: 22px; font-weight: bold; color: #303133; }
|
||||
.r-value.green { color: #67c23a; }
|
||||
.r-value.blue { color: #409eff; }
|
||||
.r-value.orange { color: #e6a23c; }
|
||||
.r-value.red { color: #f56c6c; }
|
||||
.r-value.purple { color: #8b5cf6; }
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<div class="report-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="report-header">
|
||||
<h3 style="margin:0;">📊 CMA管理报表</h3>
|
||||
<div class="header-right">
|
||||
<el-date-picker
|
||||
v-model="reportPeriod"
|
||||
type="month"
|
||||
placeholder="选择月份"
|
||||
value-format="YYYY-MM"
|
||||
size="small"
|
||||
style="width:140px"
|
||||
@change="onPeriodChange"
|
||||
/>
|
||||
<el-button text size="small" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 四个标签页 -->
|
||||
<el-tabs v-model="activeTab" type="border-card" class="report-tabs">
|
||||
|
||||
<!-- ════ 报表1:管理利润表 ════ -->
|
||||
<el-tab-pane label="💰 管理利润表" name="profit">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">基于管理会计视角的利润结构分析:收入 → 变动成本 → 边际贡献 → 固定成本 → 息税前利润</div>
|
||||
<el-table :data="profitItems" border stripe size="small" style="width:100%;margin-top:12px;" :summary-method="profitSummary" show-summary>
|
||||
<el-table-column prop="name" label="项目" width="200">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: row.is_total ? 700 : row.is_subtotal ? 600 : 400 }">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本期金额" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: row.is_total ? 700 : 400, color: (row.name?.includes('利润') || row.name?.includes('贡献')) && row.value != null && row.value < 0 ? '#f56c6c' : '#333' }">
|
||||
{{ row.value != null ? '¥ ' + row.value.toLocaleString() : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上期金额" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color:#999;">{{ row.prev_value != null ? '¥ ' + row.prev_value.toLocaleString() : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="环比变化" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.change_rate != null" :style="{ color: row.change_rate > 0 ? '#f56c6c' : row.change_rate < 0 ? '#67c23a' : '#999', fontWeight: 600 }">
|
||||
{{ row.change_rate > 0 ? '↑' : '↓' }}{{ Math.abs(row.change_rate).toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else style="color:#ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="占比" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.ratio != null" style="color:#666;">{{ row.ratio.toFixed(1) }}%</span>
|
||||
<span v-else style="color:#ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ════ 报表2:预算执行报告 ════ -->
|
||||
<el-tab-pane label="📋 预算执行报告" name="budget">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">各KPI预算 vs 实际差异分析。正值=超预算,负值=节约。</div>
|
||||
<div class="summary-row" style="margin-top:12px;">
|
||||
<div class="stat-card blue"><div class="stat-val">{{ budgetSummary.total }}</div><div class="stat-label">KPI总数</div></div>
|
||||
<div class="stat-card green"><div class="stat-val">{{ budgetSummary.with_budget }}</div><div class="stat-label">有预算</div></div>
|
||||
<div class="stat-card red"><div class="stat-val">{{ budgetSummary.over_budget }}</div><div class="stat-label">超预算</div></div>
|
||||
<div class="stat-card gray"><div class="stat-val">{{ budgetSummary.under_budget }}</div><div class="stat-label">节约</div></div>
|
||||
<div class="stat-card yellow"><div class="stat-val">{{ budgetSummary.normal }}</div><div class="stat-label">正常</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin:12px 0;">
|
||||
<el-select v-model="budgetDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadBudget">
|
||||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-select v-model="budgetLevel" placeholder="状态" clearable size="small" style="width:120px;" @change="loadBudget">
|
||||
<el-option label="超预算" value="red" />
|
||||
<el-option label="预警" value="yellow" />
|
||||
<el-option label="正常" value="normal" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="budgetItems" border stripe size="small" style="width:100%;" @sort-change="onBudgetSort">
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" sortable="custom" />
|
||||
<el-table-column prop="dimension" label="维度" width="90">
|
||||
<template #default="{ row }">{{ dimLabel(row.dimension) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际值" width="120" align="right" prop="actual_value" sortable="custom">
|
||||
<template #default="{ row }">{{ row.actual_value != null ? row.actual_value.toLocaleString() : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预算值" width="120" align="right" prop="budget_value" sortable="custom">
|
||||
<template #default="{ row }">{{ row.budget_value != null ? row.budget_value.toLocaleString() : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异额" width="120" align="right" prop="deviation_amount" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.deviation_amount > 0 ? '#f56c6c' : row.deviation_amount < 0 ? '#67c23a' : '#999' }">
|
||||
{{ row.deviation_amount != null ? (row.deviation_amount > 0 ? '+' : '') + row.deviation_amount.toLocaleString() : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异率" width="100" align="right" prop="deviation_rate" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.deviation_rate != null" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
|
||||
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(1) }}%
|
||||
</el-tag>
|
||||
<span v-else style="color:#ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ════ 报表3:KPI趋势报告 ════ -->
|
||||
<el-tab-pane label="📈 KPI趋势报告" name="trends">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">选定KPI的历史趋势分析,含目标线标记。</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
|
||||
<el-select v-model="trendDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadTrends">
|
||||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-select v-model="trendKpiId" placeholder="选择KPI" clearable filterable size="small" style="width:200px;" @change="loadTrends">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="k.kpi_name" :value="k.id" />
|
||||
</el-select>
|
||||
<el-select v-model="trendMonths" size="small" style="width:100px;" @change="loadTrends">
|
||||
<el-option label="12期" :value="12" />
|
||||
<el-option label="24期" :value="24" />
|
||||
<el-option label="30期" :value="30" />
|
||||
</el-select>
|
||||
</div>
|
||||
<!-- 趋势图表 -->
|
||||
<div v-if="trendData.length > 0" class="trend-grid">
|
||||
<el-card v-for="t in trendData" :key="t.kpi_id" class="trend-card-2">
|
||||
<template #header>
|
||||
<div class="trend-hd">
|
||||
<span class="trend-name">{{ t.kpi_name }}</span>
|
||||
<span class="trend-unit">{{ t.unit }}</span>
|
||||
<span class="trend-stat">
|
||||
均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }}
|
||||
</span>
|
||||
<span class="trend-dir" :class="t.trend_dir">
|
||||
{{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div :ref="el => setTrendChartRef(t.kpi_id, el)" style="height:180px;"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-else description="请选择KPI查看趋势" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ════ 报表4:BSC评分卡 ════ -->
|
||||
<el-tab-pane label="🎯 四维度绩效评分卡" name="bsc">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">基于BSC框架的四维度绩效评分,从已发布战略地图构建。</div>
|
||||
<div v-if="bscData.overall_score != null" style="margin-top:12px;">
|
||||
<!-- 总分 -->
|
||||
<div class="bsc-overall">
|
||||
<div class="bsc-score-ring" :style="{ borderColor: bscScoreColor(bscData.overall_score) }">
|
||||
<span class="bsc-score-val">{{ bscData.overall_score }}</span>
|
||||
<span class="bsc-score-label">综合评分</span>
|
||||
</div>
|
||||
<div class="bsc-map-info" v-if="bscData.map_title">
|
||||
基于:{{ bscData.map_title }} | 周期:{{ bscData.period }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 四维度 -->
|
||||
<div class="bsc-dims">
|
||||
<div v-for="dim in bscData.dimensions" :key="dim.key" class="bsc-dim-card">
|
||||
<div class="bsc-dim-head" :style="{ borderLeft: '4px solid ' + dim.color }">
|
||||
<span class="bsc-dim-icon">{{ dim.icon }}</span>
|
||||
<span class="bsc-dim-name">{{ dim.name }}</span>
|
||||
<span class="bsc-dim-score" :style="{ color: bscScoreColor(dim.score) }">{{ dim.score }}</span>
|
||||
</div>
|
||||
<div class="bsc-dim-body">
|
||||
<div v-for="obj in dim.objectives" :key="obj.name" class="bsc-obj-row">
|
||||
<div class="bsc-obj-name">{{ obj.name }}</div>
|
||||
<div class="bsc-obj-kpis" v-if="obj.kpis && obj.kpis.length > 0">
|
||||
<div v-for="k in obj.kpis.slice(0, 3)" :key="k.code" class="bsc-kpi-mini">
|
||||
<span class="bsc-kpi-dot" :class="'dot-' + k.level"></span>
|
||||
<span class="bsc-kpi-name">{{ k.name }}</span>
|
||||
<span class="bsc-kpi-score">{{ k.score ?? '-' }}</span>
|
||||
</div>
|
||||
<div v-if="obj.kpis.length > 3" class="bsc-kpi-more">+{{ obj.kpis.length - 3 }} 更多</div>
|
||||
</div>
|
||||
<div v-else class="bsc-obj-empty">暂无KPI数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无已发布的战略地图,请先在战略地图中发布" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
// ── 状态 ──
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('profit')
|
||||
const reportPeriod = ref('2026-06')
|
||||
|
||||
// API 配置
|
||||
const api = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
||||
api.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
if (token) config.headers.Authorization = `'Bearer ' + token`
|
||||
return config
|
||||
})
|
||||
|
||||
// ── 报表1:管理利润表 ──
|
||||
const profitItems = ref<any[]>([])
|
||||
|
||||
function profitSummary(param: any) {
|
||||
const sums: string[] = ['合计']
|
||||
const last = profitItems.value[profitItems.value.length - 1]
|
||||
if (last) {
|
||||
sums.push(last.value != null ? '¥ ' + last.value.toLocaleString() : '-')
|
||||
sums.push(last.prev_value != null ? '¥ ' + last.prev_value.toLocaleString() : '-')
|
||||
sums.push(last.change_rate != null ? (last.change_rate > 0 ? '↑' : '↓') + Math.abs(last.change_rate).toFixed(1) + '%' : '-')
|
||||
sums.push(last.ratio != null ? last.ratio.toFixed(1) + '%' : '-')
|
||||
} else {
|
||||
sums.push('-', '-', '-', '-')
|
||||
}
|
||||
return sums
|
||||
}
|
||||
|
||||
// ── 报表2:预算执行报告 ──
|
||||
const budgetSummary = ref({ total: 0, with_budget: 0, over_budget: 0, under_budget: 0, normal: 0 })
|
||||
const budgetItems = ref<any[]>([])
|
||||
const budgetDim = ref('')
|
||||
const budgetLevel = ref('')
|
||||
|
||||
// ── 报表3:KPI趋势报告 ──
|
||||
const trendDim = ref('')
|
||||
const trendKpiId = ref<number | null>(null)
|
||||
const trendMonths = ref(24)
|
||||
const trendData = ref<any[]>([])
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const trendChartRefs: Record<string, HTMLElement> = {}
|
||||
|
||||
function setTrendChartRef(id: number, el: any) {
|
||||
if (el) trendChartRefs['chart-' + id] = el
|
||||
}
|
||||
|
||||
// ── 报表4:BSC评分卡 ──
|
||||
const bscData = ref<any>({})
|
||||
|
||||
// ── 公共 ──
|
||||
const dimensions = [
|
||||
{ value: 'finance', label: '财务维度' },
|
||||
{ value: 'customer', label: '客户维度' },
|
||||
{ value: 'process', label: '内部流程' },
|
||||
{ value: 'learning', label: '学习成长' },
|
||||
]
|
||||
|
||||
function dimLabel(d: string): string {
|
||||
const map: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
return map[d] || d
|
||||
}
|
||||
|
||||
function bscScoreColor(score: number): string {
|
||||
if (score >= 80) return '#67c23a'
|
||||
if (score >= 60) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
function onPeriodChange() {
|
||||
loadAll()
|
||||
}
|
||||
|
||||
// ── 加载 ──
|
||||
async function loadProfit() {
|
||||
try {
|
||||
const r = await api.get('/reports/profit-summary', { params: { period: reportPeriod.value } })
|
||||
profitItems.value = (r as any).data?.items || []
|
||||
} catch { profitItems.value = [] }
|
||||
}
|
||||
|
||||
async function loadBudget() {
|
||||
try {
|
||||
const params: any = { period: reportPeriod.value }
|
||||
if (budgetDim.value) params.dimension = budgetDim.value
|
||||
if (budgetLevel.value) params.alert_level = budgetLevel.value
|
||||
const r = await api.get('/reports/budget-execution', { params })
|
||||
const d = (r as any).data || {}
|
||||
budgetSummary.value = d.summary || {}
|
||||
budgetItems.value = d.items || []
|
||||
} catch { budgetItems.value = [] }
|
||||
}
|
||||
|
||||
async function loadTrends() {
|
||||
try {
|
||||
const params: any = { months: trendMonths.value }
|
||||
if (trendKpiId.value) params.kpi_id = trendKpiId.value
|
||||
else if (trendDim.value) params.dimension = trendDim.value
|
||||
const r = await api.get('/reports/kpi-trends', { params })
|
||||
trendData.value = (r as any).data?.data || []
|
||||
nextTick(() => renderTrendCharts())
|
||||
} catch { trendData.value = [] }
|
||||
}
|
||||
|
||||
async function loadBsc() {
|
||||
try {
|
||||
const r = await api.get('/reports/bsc-scorecard', { params: { period: reportPeriod.value } })
|
||||
bscData.value = (r as any).data || {}
|
||||
} catch { bscData.value = {} }
|
||||
}
|
||||
|
||||
async function loadKpiOptions() {
|
||||
try {
|
||||
const r = await api.get('/kpis', { params: { page_size: 100 } })
|
||||
kpiOptions.value = (r as any).data?.data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
loading.value = true
|
||||
Promise.all([loadProfit(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
|
||||
}
|
||||
|
||||
// ── ECharts 渲染 ──
|
||||
function renderTrendCharts() {
|
||||
import('echarts').then(echarts => {
|
||||
trendData.value.forEach((t: any) => {
|
||||
const el = trendChartRefs['chart-' + t.kpi_id]
|
||||
if (!el) return
|
||||
const existing = echarts.getInstanceByDom(el)
|
||||
if (existing) existing.dispose()
|
||||
const chart = echarts.init(el)
|
||||
const dates = t.trend.map((d: any) => d.period)
|
||||
const values = t.trend.map((d: any) => d.value)
|
||||
const series: any[] = [{
|
||||
type: 'line', data: values, smooth: true, lineStyle: { width: 2 },
|
||||
areaStyle: { opacity: 0.1 }, symbol: 'circle', symbolSize: 4,
|
||||
name: '实际值',
|
||||
}]
|
||||
// 目标线
|
||||
if (t.target_value != null) {
|
||||
series.push({
|
||||
type: 'line', data: Array(dates.length).fill(t.target_value),
|
||||
lineStyle: { type: 'dashed', width: 1, color: '#f56c6c' },
|
||||
symbol: 'none', name: '目标值',
|
||||
})
|
||||
}
|
||||
chart.setOption({
|
||||
grid: { left: 50, right: 15, top: 10, bottom: 28 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 10, rotate: 45 } },
|
||||
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed', color: '#eee' } } },
|
||||
series,
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { show: true, bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── 排序 ──
|
||||
function onBudgetSort(sort: any) {
|
||||
if (!sort.prop || !sort.order) return
|
||||
const items = [...budgetItems.value]
|
||||
items.sort((a: any, b: any) => {
|
||||
const av = a[sort.prop] ?? 0
|
||||
const bv = b[sort.prop] ?? 0
|
||||
return sort.order === 'ascending' ? av - bv : bv - av
|
||||
})
|
||||
budgetItems.value = items
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadKpiOptions()
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-page { max-width: 1400px; margin: 0 auto; }
|
||||
.report-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.header-right { display: flex; gap: 8px; align-items: center; }
|
||||
.report-tabs { margin-bottom: 20px; }
|
||||
.report-desc { font-size: 13px; color: #888; padding: 8px 0; }
|
||||
|
||||
/* 统计卡片 */
|
||||
.summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-top: 3px solid #ccc; }
|
||||
.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: 22px; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-label { font-size: 12px; color: #888; margin-top: 2px; }
|
||||
|
||||
/* 趋势卡片 */
|
||||
.trend-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-top: 12px; }
|
||||
.trend-card-2 { border-radius: 10px; }
|
||||
.trend-hd { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.trend-name { font-weight: 600; font-size: 14px; flex: 1; }
|
||||
.trend-unit { color: #999; font-size: 12px; }
|
||||
.trend-stat { font-size: 11px; color: #999; }
|
||||
.trend-dir { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
||||
.trend-dir.up { background: #fff1f0; color: #cf1322; }
|
||||
.trend-dir.down { background: #f6ffed; color: #389e0d; }
|
||||
.trend-dir.stable { background: #f5f5f5; color: #999; }
|
||||
|
||||
/* BSC评分卡 */
|
||||
.bsc-overall { text-align: center; padding: 20px 0; }
|
||||
.bsc-score-ring {
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
width: 120px; height: 120px; border-radius: 50%; border: 6px solid #67c23a;
|
||||
background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.bsc-score-val { font-size: 36px; font-weight: 700; color: #1a1a2e; line-height: 1.1; }
|
||||
.bsc-score-label { font-size: 12px; color: #999; margin-top: 2px; }
|
||||
.bsc-map-info { font-size: 12px; color: #999; margin-top: 8px; }
|
||||
.bsc-dims { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||
.bsc-dim-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); overflow: hidden; }
|
||||
.bsc-dim-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; background: #fafafa; }
|
||||
.bsc-dim-icon { font-size: 18px; }
|
||||
.bsc-dim-name { font-weight: 600; font-size: 14px; flex: 1; }
|
||||
.bsc-dim-score { font-size: 24px; font-weight: 700; }
|
||||
.bsc-dim-body { padding: 10px 14px; }
|
||||
.bsc-obj-row { margin-bottom: 10px; }
|
||||
.bsc-obj-name { font-size: 13px; font-weight: 500; color: #555; margin-bottom: 4px; }
|
||||
.bsc-obj-kpis { display: flex; flex-direction: column; gap: 3px; }
|
||||
.bsc-kpi-mini { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 6px; background: #f9f9f9; border-radius: 4px; }
|
||||
.bsc-kpi-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-green { background: #67c23a; }
|
||||
.dot-yellow { background: #e6a23c; }
|
||||
.dot-red { background: #f56c6c; }
|
||||
.dot-gray { background: #ddd; }
|
||||
.bsc-kpi-name { flex: 1; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bsc-kpi-score { font-weight: 600; color: #333; }
|
||||
.bsc-kpi-more { font-size: 11px; color: #409eff; text-align: center; padding: 2px; }
|
||||
.bsc-obj-empty { font-size: 11px; color: #ccc; text-align: center; padding: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="slider-captcha">
|
||||
<!-- 触发按钮(未验证时显示) -->
|
||||
<el-button
|
||||
v-if="!verified"
|
||||
type="warning"
|
||||
size="large"
|
||||
plain
|
||||
style="width:100%; margin-bottom:12px"
|
||||
:loading="loading"
|
||||
@click="openDialog"
|
||||
>
|
||||
⚠ 请完成安全验证
|
||||
</el-button>
|
||||
|
||||
<!-- 已验证 -->
|
||||
<div v-else class="verified-badge">
|
||||
✅ 安全验证通过
|
||||
</div>
|
||||
|
||||
<!-- 验证对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="安全验证"
|
||||
width="380px"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
top="20vh"
|
||||
>
|
||||
<div class="captcha-body">
|
||||
<!-- 图形验证码模式 -->
|
||||
<template v-if="captchaType === 'image'">
|
||||
<div class="image-captcha-wrapper">
|
||||
<img :src="captchaImage" class="captcha-img" alt="验证码" />
|
||||
<el-input
|
||||
v-model="imageAnswer"
|
||||
placeholder="输入验证码"
|
||||
size="large"
|
||||
maxlength="4"
|
||||
style="width:120px"
|
||||
/>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||||
<el-button type="primary" size="small" @click="verifyImageCaptcha">确认</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 滑块拼图模式 -->
|
||||
<template v-else>
|
||||
<div class="slider-wrapper">
|
||||
<div class="slider-bg">
|
||||
<img :src="sliderBg" alt="滑块背景" />
|
||||
<div
|
||||
class="slider-gap-marker"
|
||||
:style="{ left: sliderGapX + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="slider-piece-track">
|
||||
<div
|
||||
class="slider-piece"
|
||||
:style="{ left: sliderPos + 'px', background: dragging ? '#409eff' : '#67c23a' }"
|
||||
></div>
|
||||
<div
|
||||
class="slider-rail"
|
||||
ref="railRef"
|
||||
@mousedown.prevent="startDrag"
|
||||
@touchstart.prevent="startDrag"
|
||||
>
|
||||
<div class="slider-thumb" :style="{ left: sliderPos + 'px' }">
|
||||
{{ dragging ? '→' : '→' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="slider-hint">{{ dragging ? '松开完成验证' : '拖动滑块完成拼图' }}</p>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { securityApi } from '../api/index'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'verified', token: string): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const verified = ref(false)
|
||||
const captchaToken = ref('')
|
||||
const captchaType = ref<'image' | 'slider'>('slider')
|
||||
|
||||
// 图形验证码
|
||||
const captchaImage = ref('')
|
||||
const imageAnswer = ref('')
|
||||
const captchaId = ref('')
|
||||
|
||||
// 滑块拼图
|
||||
const sliderBg = ref('')
|
||||
const sliderGapX = ref(0)
|
||||
const sliderPos = ref(0)
|
||||
const dragging = ref(false)
|
||||
const railRef = ref<HTMLElement | null>(null)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartPos = ref(0)
|
||||
|
||||
// 容差像素
|
||||
const TOLERANCE = 6
|
||||
|
||||
async function fetchCaptcha() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.requestCaptcha(captchaType.value)
|
||||
captchaId.value = res.captcha_id
|
||||
if (captchaType.value === 'image') {
|
||||
captchaImage.value = res.image
|
||||
imageAnswer.value = ''
|
||||
} else {
|
||||
sliderBg.value = res.bg
|
||||
sliderGapX.value = res.gap_x
|
||||
sliderPos.value = 0
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取验证码失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function openDialog() {
|
||||
captchaType.value = Math.random() > 0.3 ? 'slider' : 'image'
|
||||
dialogVisible.value = true
|
||||
await fetchCaptcha()
|
||||
}
|
||||
|
||||
async function verifyImageCaptcha() {
|
||||
if (!imageAnswer.value || imageAnswer.value.length !== 4) {
|
||||
ElMessage.warning('请输入4位验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.verifyCaptcha({
|
||||
captcha_id: captchaId.value,
|
||||
answer: imageAnswer.value.toUpperCase(),
|
||||
captcha_type: 'image',
|
||||
})
|
||||
captchaToken.value = res.token
|
||||
verified.value = true
|
||||
dialogVisible.value = false
|
||||
emit('verified', res.token)
|
||||
ElMessage.success('验证通过')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||||
await fetchCaptcha()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent | TouchEvent) {
|
||||
dragging.value = true
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
dragStartX.value = clientX
|
||||
dragStartPos.value = sliderPos.value
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', endDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', endDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent | TouchEvent) {
|
||||
if (!dragging.value) return
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const rail = railRef.value
|
||||
if (!rail) return
|
||||
const railRect = rail.getBoundingClientRect()
|
||||
const max = railRect.width - 40
|
||||
let newPos = dragStartPos.value + (clientX - dragStartX.value)
|
||||
newPos = Math.max(0, Math.min(newPos, max))
|
||||
sliderPos.value = newPos
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!dragging.value) return
|
||||
dragging.value = false
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', endDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', endDrag)
|
||||
|
||||
// 验证滑块位置是否匹配缺口位置
|
||||
const rail = railRef.value
|
||||
if (!rail) return
|
||||
const railWidth = rail.getBoundingClientRect().width - 40
|
||||
const piecePos = (sliderPos.value / railWidth) * 280 // 映射到280px背景图宽度
|
||||
const gapX = sliderGapX.value
|
||||
if (Math.abs(piecePos - gapX) <= TOLERANCE) {
|
||||
// 验证通过 → 调用后端确认
|
||||
confirmSlider()
|
||||
} else {
|
||||
ElMessage.warning('位置不匹配,请再试一次')
|
||||
sliderPos.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSlider() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.verifyCaptcha({
|
||||
captcha_id: captchaId.value,
|
||||
answer: 'verified',
|
||||
captcha_type: 'slider',
|
||||
})
|
||||
captchaToken.value = res.token
|
||||
verified.value = true
|
||||
dialogVisible.value = false
|
||||
emit('verified', res.token)
|
||||
ElMessage.success('验证通过')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||||
sliderPos.value = 0
|
||||
await fetchCaptcha()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 对外暴露重置方法
|
||||
function reset() {
|
||||
verified.value = false
|
||||
captchaToken.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-body {
|
||||
text-align: center;
|
||||
}
|
||||
.image-captcha-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.captcha-img {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
height: 50px;
|
||||
}
|
||||
.captcha-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.slider-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.slider-bg {
|
||||
position: relative;
|
||||
margin: 0 auto 12px;
|
||||
width: 280px;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.slider-bg img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slider-gap-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid rgba(255, 0, 0, 0.5);
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.slider-piece-track {
|
||||
position: relative;
|
||||
margin: 0 auto 8px;
|
||||
width: 280px;
|
||||
}
|
||||
.slider-piece {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
z-index: 2;
|
||||
transition: none;
|
||||
}
|
||||
.slider-rail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
background: #ebeef5;
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||
}
|
||||
.slider-thumb:active { cursor: grabbing; }
|
||||
.slider-hint { color: #909399; font-size: 12px; margin: 0; }
|
||||
.verified-badge {
|
||||
color: #67c23a;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user