init: 管理会计OS初始代码
包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预警中心</h3>
|
||||
<div style="margin:12px 0;display:flex;gap:12px;">
|
||||
<el-radio-group v-model="activeTab" size="small">
|
||||
<el-radio-button value="alerts">待处理预警</el-radio-button>
|
||||
<el-radio-button value="plans">改善行动</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- ===== 预警列表 ===== -->
|
||||
<template v-if="activeTab === 'alerts'">
|
||||
<el-table :data="alerts" v-loading="loading" style="width:100%" border stripe>
|
||||
<el-table-column prop="alert_message" label="预警信息" min-width="300" />
|
||||
<el-table-column prop="alert_level" label="级别" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.alert_level==='red'?'danger':'warning'" size="small">
|
||||
{{ row.alert_level==='red'?'🔴紧急':'🟡警告' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">{{ {pending:'待处理',processing:'处理中',resolved:'已处理'}[row.status] || row.status }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='pending'" size="small" type="primary" @click="openResolveDialog(row)">处理</el-button>
|
||||
<el-button v-if="row.status==='pending'" size="small" @click="openPlanDialog(row)">创建改善计划</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- ===== 改善行动计划 ===== -->
|
||||
<template v-if="activeTab === 'plans'">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button type="primary" size="small" @click="showPlanForm = true; planForm = { title: '', kpi_id: null, assignee: '', priority: 'medium', description: '' }">新建改善计划</el-button>
|
||||
</div>
|
||||
<el-table :data="plans" v-loading="loadingPlans" style="width:100%" border stripe>
|
||||
<el-table-column prop="title" label="计划名称" min-width="200" />
|
||||
<el-table-column prop="kpi_name" label="关联KPI" width="140" />
|
||||
<el-table-column prop="assignee" label="负责人" width="100" />
|
||||
<el-table-column prop="priority" label="优先级" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.priority==='high'?'danger':row.priority==='medium'?'warning':'info'" size="small">
|
||||
{{ {high:'高',medium:'中',low:'低'}[row.priority] || row.priority }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status==='completed'?'success':row.status==='in_progress'?'warning':'info'" size="small">
|
||||
{{ {pending:'待开始',in_progress:'进行中',completed:'已完成',cancelled:'已取消'}[row.status] || row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="进度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress || 0" :status="row.progress >= 100 ? 'success' : ''" :stroke-width="12" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editPlan(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deletePlan(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- 处理预警弹窗 -->
|
||||
<el-dialog v-model="showDialog" title="处理预警" width="500px">
|
||||
<el-form :model="resolveForm" label-width="100px">
|
||||
<el-form-item label="处理人">
|
||||
<el-select v-model="resolveForm.assignee" filterable style="width:100%;">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理结果">
|
||||
<el-input v-model="resolveForm.resolution" type="textarea" :rows="3" placeholder="描述处理结果" />
|
||||
</el-form-item>
|
||||
<el-form-item label="创建改善计划">
|
||||
<el-checkbox v-model="resolveForm.createPlan">同时创建改善行动计划</el-checkbox>
|
||||
</el-form-item>
|
||||
<template v-if="resolveForm.createPlan">
|
||||
<el-form-item label="计划名称">
|
||||
<el-input v-model="resolveForm.planTitle" placeholder="如:制定客户拓展方案" />
|
||||
</el-form-item>
|
||||
<el-form-item label="截止日期">
|
||||
<el-date-picker v-model="resolveForm.planDueDate" type="date" placeholder="选择截止日期" style="width:100%;" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="doResolve" :loading="resolving">确认处理</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建/编辑改善计划弹窗 -->
|
||||
<el-dialog v-model="showPlanForm" :title="editingPlan ? '编辑改善计划' : '新建改善计划'" width="550px">
|
||||
<el-form :model="planForm" label-width="100px">
|
||||
<el-form-item label="计划名称"><el-input v-model="planForm.title" /></el-form-item>
|
||||
<el-form-item label="关联KPI">
|
||||
<el-select v-model="planForm.kpi_id" filterable style="width:100%;">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="k.kpi_name" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-select v-model="planForm.assignee" filterable style="width:100%;">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="planForm.priority">
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="低" value="low" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="planForm.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="截止日期">
|
||||
<el-date-picker v-model="planForm.due_date" type="date" placeholder="选择截止日期" style="width:100%;" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPlanForm=false">取消</el-button>
|
||||
<el-button type="primary" @click="savePlan" :loading="savingPlan">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { alertApi, userApi, actionPlanApi, kpiApi } from '../api/index'
|
||||
|
||||
const route = useRoute()
|
||||
const activeTab = ref((route.meta?.tab as string) || 'alerts')
|
||||
|
||||
// 预警
|
||||
const alerts = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const currentAlert = ref<any>(null)
|
||||
const resolving = ref(false)
|
||||
const resolveForm = ref({
|
||||
assignee: '', resolution: '',
|
||||
createPlan: false, planTitle: '', planDueDate: null,
|
||||
})
|
||||
const users = ref<any[]>([])
|
||||
|
||||
// 改善计划
|
||||
const plans = ref<any[]>([])
|
||||
const loadingPlans = ref(false)
|
||||
const showPlanForm = ref(false)
|
||||
const editingPlan = ref<any>(null)
|
||||
const savingPlan = ref(false)
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const planForm = ref<any>({
|
||||
title: '', kpi_id: null, assignee: '', priority: 'medium',
|
||||
description: '', due_date: null,
|
||||
})
|
||||
|
||||
function openResolveDialog(row: any) {
|
||||
currentAlert.value = row
|
||||
resolveForm.value = {
|
||||
assignee: '', resolution: '',
|
||||
createPlan: false, planTitle: `${row.alert_message?.slice(0, 20) || '改善'}方案`,
|
||||
planDueDate: null,
|
||||
}
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function openPlanDialog(row: any) {
|
||||
// 从预警创建改善计划
|
||||
const kpiId = row.kpi_id || null
|
||||
showPlanForm.value = true
|
||||
editingPlan.value = null
|
||||
planForm.value = {
|
||||
title: `${row.alert_message?.slice(0, 30) || '改善'}方案`,
|
||||
kpi_id: kpiId,
|
||||
assignee: '',
|
||||
priority: 'high',
|
||||
description: row.alert_message || '',
|
||||
due_date: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function doResolve() {
|
||||
if (!resolveForm.value.assignee) { ElMessage.warning('请选择处理人'); return }
|
||||
resolving.value = true
|
||||
try {
|
||||
await alertApi.resolve(currentAlert.value.id, {
|
||||
assignee: resolveForm.value.assignee,
|
||||
resolution: resolveForm.value.resolution,
|
||||
})
|
||||
|
||||
// 如果勾选了创建改善计划
|
||||
if (resolveForm.value.createPlan && resolveForm.value.planTitle) {
|
||||
await actionPlanApi.create({
|
||||
title: resolveForm.value.planTitle,
|
||||
kpi_id: currentAlert.value.kpi_id || 1,
|
||||
alert_id: currentAlert.value.id,
|
||||
description: resolveForm.value.resolution || '',
|
||||
assignee: resolveForm.value.assignee,
|
||||
priority: 'high',
|
||||
due_date: resolveForm.value.planDueDate || null,
|
||||
})
|
||||
}
|
||||
|
||||
ElMessage.success('已处理')
|
||||
showDialog.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
resolving.value = false
|
||||
}
|
||||
|
||||
async function savePlan() {
|
||||
if (!planForm.value.title || !planForm.value.kpi_id) {
|
||||
ElMessage.warning('请填写计划名称并选择关联KPI')
|
||||
return
|
||||
}
|
||||
savingPlan.value = true
|
||||
try {
|
||||
const data = { ...planForm.value }
|
||||
if (data.due_date) data.due_date = new Date(data.due_date).toISOString()
|
||||
|
||||
if (editingPlan.value) {
|
||||
await actionPlanApi.update(editingPlan.value.id, data)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await actionPlanApi.create(data)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
showPlanForm.value = false
|
||||
loadPlans()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
savingPlan.value = false
|
||||
}
|
||||
|
||||
function editPlan(row: any) {
|
||||
editingPlan.value = row
|
||||
planForm.value = {
|
||||
title: row.title,
|
||||
kpi_id: row.kpi_id,
|
||||
assignee: row.assignee,
|
||||
priority: row.priority,
|
||||
description: row.description,
|
||||
due_date: row.due_date || null,
|
||||
}
|
||||
showPlanForm.value = true
|
||||
}
|
||||
|
||||
async function deletePlan(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此改善计划?')
|
||||
await actionPlanApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
loadPlans()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { const r: any = await alertApi.list(); alerts.value = r.data || [] } catch (e) {}
|
||||
try { const u: any = await userApi.list(); users.value = u.data || [] } catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
loadingPlans.value = true
|
||||
try { const r: any = await actionPlanApi.list(); plans.value = r.data || [] } catch (e) {}
|
||||
loadingPlans.value = false
|
||||
}
|
||||
|
||||
async function loadKpis() {
|
||||
try { const r: any = await kpiApi.list(); kpiOptions.value = r.data || [] } catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadPlans()
|
||||
loadKpis()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预算管理</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<!-- Tab 1: 预算录入 -->
|
||||
<el-tab-pane label="预算录入" name="input">
|
||||
<!-- 筛选栏 -->
|
||||
<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 @click="showBatchForm = true">批量录入</el-button>
|
||||
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 预算表格 -->
|
||||
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;">
|
||||
<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="140">
|
||||
<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:130px;" />
|
||||
<span v-else>{{ formatNumber(row.budget_value) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version" label="版本" width="70" />
|
||||
<el-table-column prop="status" label="状态" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.editing" size="small" @click="startEdit(row)">编辑</el-button>
|
||||
<template v-else>
|
||||
<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 size="small" type="danger" @click="doDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div style="margin-top:16px;display:flex;justify-content:flex-end;">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadBudget"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 年度自动分解 -->
|
||||
<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-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 批量录入弹窗 -->
|
||||
<MyDialog v-model="showBatchForm" title="批量录入预算" :width="600">
|
||||
<div style="margin-bottom:12px;">
|
||||
<p style="color:#666;font-size:13px;">从KPI字典中选择需要设置预算的KPI,统一填写预算值</p>
|
||||
<el-select v-model="batchYear" placeholder="年份" style="width:100px;margin-right:8px;">
|
||||
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
|
||||
</el-select>
|
||||
<el-select v-model="batchMonth" placeholder="月份" style="width:90px;">
|
||||
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="batchKpis" v-loading="batchLoading" border size="small" max-height="400" style="width:100%;" @selection-change="onBatchSelect">
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" />
|
||||
<el-table-column prop="kpi_name" label="名称" min-width="150" />
|
||||
<el-table-column prop="dimension" label="维度" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="dimTag(row.dimension)" style="font-size:11px;">{{ dimLabel(row.dimension) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预算值" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.batchValue" :min="0" :precision="0" controls-position="right" style="width:110px;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:12px;display:flex;gap:12px;align-items:center;">
|
||||
<span style="font-size:13px;color:#666;">已选 {{ batchSelected.length }} 条</span>
|
||||
<el-button size="small" @click="batchSetSame">设为相同值</el-button>
|
||||
<el-input-number v-model="batchSameValue" :min="0" controls-position="right" style="width:130px;" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showBatchForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="doBatchCreate" :loading="batchSaving">批量创建</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { budgetApi, kpiApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const activeTab = ref('input')
|
||||
|
||||
// ── 年份/月份选项 ──
|
||||
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(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 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) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// ── 加载预算列表 ──
|
||||
async function loadBudget() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (filterYear.value) params.budget_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 data = r.data || r || []
|
||||
// 如果后端返回分页格式
|
||||
if (Array.isArray(data)) {
|
||||
budgetList.value = data.map((item: any) => ({ ...item, editing: false, editValue: item.budget_value, saving: false }))
|
||||
total.value = data.length
|
||||
} else if (data.items) {
|
||||
budgetList.value = (data.items || []).map((item: any) => ({ ...item, editing: false, editValue: item.budget_value, saving: false }))
|
||||
total.value = data.total || data.items.length
|
||||
} else {
|
||||
budgetList.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e) { ElMessage.error('加载预算数据失败') }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ── 编辑操作 ──
|
||||
function startEdit(row: any) {
|
||||
row.editing = true
|
||||
row.editValue = row.budget_value
|
||||
}
|
||||
|
||||
function cancelEdit(row: any) {
|
||||
row.editing = false
|
||||
row.editValue = row.budget_value
|
||||
}
|
||||
|
||||
async function saveEdit(row: any) {
|
||||
row.saving = true
|
||||
try {
|
||||
await budgetApi.update(row.id, { budget_value: row.editValue })
|
||||
ElMessage.success('已更新')
|
||||
row.budget_value = row.editValue
|
||||
row.editing = 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 showBatchForm = ref(false)
|
||||
const batchYear = ref(currentYear)
|
||||
const batchMonth = ref(new Date().getMonth() + 1)
|
||||
const batchLoading = ref(false)
|
||||
const batchKpis = ref<any[]>([])
|
||||
const batchSelected = ref<any[]>([])
|
||||
const batchSameValue = ref(0)
|
||||
const batchSaving = ref(false)
|
||||
|
||||
async function loadBatchKpis() {
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 500 })
|
||||
const data = r.data || r || []
|
||||
const items = Array.isArray(data) ? data : (data.items || [])
|
||||
batchKpis.value = items.map((k: any) => ({ ...k, batchValue: 0 }))
|
||||
} catch (e) { ElMessage.error('加载KPI列表失败') }
|
||||
batchLoading.value = false
|
||||
}
|
||||
|
||||
function onBatchSelect(sel: any[]) {
|
||||
batchSelected.value = sel
|
||||
}
|
||||
|
||||
function batchSetSame() {
|
||||
batchSelected.value.forEach(item => { item.batchValue = batchSameValue.value })
|
||||
}
|
||||
|
||||
async function doBatchCreate() {
|
||||
if (batchSelected.value.length === 0) { ElMessage.warning('请先选择KPI'); return }
|
||||
batchSaving.value = true
|
||||
let success = 0
|
||||
for (const kpi of batchSelected.value) {
|
||||
try {
|
||||
await budgetApi.create({
|
||||
kpi_id: kpi.id,
|
||||
period: `${batchYear.value}-${String(batchMonth.value).padStart(2, '0')}`,
|
||||
budget_value: kpi.batchValue,
|
||||
budget_year: batchYear.value,
|
||||
budget_month: batchMonth.value,
|
||||
})
|
||||
success++
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
ElMessage.success(`批量创建完成:${success}/${batchSelected.value.length} 条成功`)
|
||||
batchSaving.value = false
|
||||
showBatchForm.value = false
|
||||
loadBudget()
|
||||
}
|
||||
|
||||
// ── 年度分解 ──
|
||||
const showDecompose = ref(false)
|
||||
const decomposeForm = ref({ year: currentYear, method: 'equal' })
|
||||
const decomposing = ref(false)
|
||||
const decomposeResult = ref('')
|
||||
|
||||
async function doDecompose() {
|
||||
decomposing.value = true
|
||||
decomposeResult.value = ''
|
||||
try {
|
||||
const r: any = await budgetApi.autoDecompose({
|
||||
year: decomposeForm.value.year,
|
||||
method: decomposeForm.value.method,
|
||||
})
|
||||
decomposeResult.value = r.message || '分解成功'
|
||||
ElMessage.success('年度预算分解完成')
|
||||
loadBudget()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '分解失败')
|
||||
}
|
||||
decomposing.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBudget()
|
||||
})
|
||||
</script>
|
||||
@@ -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,523 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>成本分析</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<!-- Tab 1: 成本总览 -->
|
||||
<el-tab-pane label="成本总览" name="overview">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="overviewPeriod" placeholder="期间" style="width:110px;" @change="loadOverview">
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadOverview">刷新</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">总成本</div><div class="stat-value">{{ formatMoney(overviewData.total_cost || 0) }}</div></div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover"><div class="stat-card"><div class="stat-label">ERP同步成本</div><div class="stat-value" style="color:#409eff;">{{ formatMoney(overviewData.erp_cost || 0) }}</div></div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover"><div class="stat-card"><div class="stat-label">手工录入成本</div><div class="stat-value" style="color:#e6a23c;">{{ formatMoney(overviewData.manual_cost || 0) }}</div></div></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover"><div class="stat-card"><div class="stat-label">产品数</div><div class="stat-value" style="color:#67c23a;">{{ (overviewData.products || []).length }}</div></div></el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 成本结构 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>成本结构(按类型)</template>
|
||||
<div v-if="(overviewData.structure || []).length > 0">
|
||||
<div v-for="s in overviewData.structure" :key="s.cost_type" style="margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px;">
|
||||
<span>{{ costTypeLabel(s.cost_type) }}</span>
|
||||
<span>{{ formatMoney(s.amount) }}({{ s.ratio }}%)</span>
|
||||
</div>
|
||||
<el-progress :percentage="s.ratio" :stroke-width="14" :color="costTypeColor(s.cost_type)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align:center;color:#999;padding:20px;">暂无成本数据,请先在「成本录入」中添加</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>各产品成本</template>
|
||||
<div v-if="(overviewData.products || []).length > 0">
|
||||
<div v-for="p in overviewData.products" :key="p.product_code" style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #f5f5f5;font-size:13px;">
|
||||
<span>{{ p.product_name || p.product_code }}</span>
|
||||
<span style="font-weight:500;">{{ formatMoney(p.total_cost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align:center;color:#999;padding:20px;">暂无产品数据</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 成本趋势 -->
|
||||
<el-card style="margin-top:16px;">
|
||||
<template #header>成本趋势(近6个月)</template>
|
||||
<div v-if="(overviewData.trend || []).length > 0" style="display:flex;align-items:flex-end;gap:12px;height:180px;padding:20px 0;overflow-x:auto;">
|
||||
<div v-for="(t, idx) in overviewData.trend" :key="idx" style="display:flex;flex-direction:column;align-items:center;min-width:80px;">
|
||||
<div class="trend-bar" :style="{ height: trendHeight(t.total_cost, maxTrend) + 'px' }" :title="formatMoney(t.total_cost)"></div>
|
||||
<span style="font-size:11px;color:#909399;margin-top:6px;">{{ t.period }}</span>
|
||||
<span style="font-size:10px;">{{ formatMoney(t.total_cost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align:center;color:#999;padding:20px;">暂无趋势数据</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 标准成本管理 -->
|
||||
<el-tab-pane label="标准成本" name="standard">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button type="primary" @click="showStdForm = true; stdForm = {}">+ 新建标准成本</el-button>
|
||||
<el-select v-model="stdProductFilter" placeholder="产品" clearable style="width:150px;margin-left:8px;" @change="loadStandardCosts">
|
||||
<el-option v-for="p in productOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="standardCosts" v-loading="stdLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="product_code" label="产品编码" width="100" />
|
||||
<el-table-column prop="product_name" label="产品名称" width="120" />
|
||||
<el-table-column prop="cost_type" label="成本类型" width="90">
|
||||
<template #default="{ row }"><el-tag size="small" :type="costTypeTag(row.cost_type)">{{ costTypeLabel(row.cost_type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="item_name" label="项目名称" min-width="140" />
|
||||
<el-table-column prop="standard_quantity" label="标准用量" width="90" align="right" />
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
<el-table-column prop="standard_price" label="标准单价" width="90" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.standard_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="standard_cost" label="标准成本" width="100" align="right">
|
||||
<template #default="{ row }"><span style="font-weight:500;">{{ formatMoney(row.standard_cost) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version" label="版本" width="60" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editStd(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteStd(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 成本录入 -->
|
||||
<el-tab-pane label="成本录入" name="entry">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button type="primary" @click="showActualForm = true; actualForm = {}">+ 录入实际成本</el-button>
|
||||
<el-select v-model="entryPeriod" placeholder="期间" style="width:110px;margin-left:8px;" @change="loadActualCosts">
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="actualCosts" v-loading="actualLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="period" label="期间" width="80" />
|
||||
<el-table-column prop="product_code" label="产品编码" width="90" />
|
||||
<el-table-column prop="product_name" label="产品名称" width="120" />
|
||||
<el-table-column prop="cost_type" label="成本类型" width="90">
|
||||
<template #default="{ row }"><el-tag size="small" :type="costTypeTag(row.cost_type)">{{ costTypeLabel(row.cost_type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="item_name" label="项目" min-width="120" />
|
||||
<el-table-column prop="actual_quantity" label="实际用量" width="90" align="right" />
|
||||
<el-table-column prop="actual_price" label="实际单价" width="90" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.actual_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_cost" label="实际成本" width="100" align="right">
|
||||
<template #default="{ row }"><span style="font-weight:500;">{{ formatMoney(row.actual_cost) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="70" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 量差价差分析 -->
|
||||
<el-tab-pane label="量差价差" name="variance">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<el-select v-model="varProduct" placeholder="选择产品" style="width:200px;">
|
||||
<el-option v-for="p in productOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-select v-model="varPeriod" placeholder="期间" style="width:110px;">
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadVariance" :loading="varLoading">分析</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="varianceData.summary">
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">标准成本</div><div class="stat-value" style="color:#409eff;">{{ formatMoney(varianceData.summary.total_standard_cost) }}</div></div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">实际成本</div><div class="stat-value" style="color:#e6a23c;">{{ formatMoney(varianceData.summary.total_actual_cost) }}</div></div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">总差异</div><div class="stat-value" :style="{ color: varianceData.summary.total_variance > 0 ? '#f56c6c' : '#67c23a' }">{{ formatMoney(varianceData.summary.total_variance) }}</div></div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">差异率</div><div class="stat-value" :style="{ color: varianceData.summary.variance_rate > 0 ? '#f56c6c' : '#67c23a' }">{{ varianceData.summary.variance_rate }}%</div></div></el-card></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table :data="varianceData.items" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="cost_type" label="类型" width="80">
|
||||
<template #default="{ row }"><el-tag size="small" :type="costTypeTag(row.cost_type)">{{ costTypeLabel(row.cost_type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="item_name" label="项目" width="140" />
|
||||
<el-table-column prop="standard_cost" label="标准" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.standard_cost) }}</template></el-table-column>
|
||||
<el-table-column prop="actual_cost" label="实际" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.actual_cost) }}</template></el-table-column>
|
||||
<el-table-column prop="qty_variance" label="量差" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.qty_variance > 0 ? '#f56c6c' : '#67c23a' }">{{ formatMoney(row.qty_variance) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price_variance" label="价差" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.price_variance > 0 ? '#f56c6c' : '#67c23a' }">{{ formatMoney(row.price_variance) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_variance" label="总差异" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.total_variance > 0 ? '#f56c6c' : '#67c23a', fontWeight:500 }">{{ formatMoney(row.total_variance) }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-empty v-else-if="!varLoading" description="请选择产品后点击「分析」" style="padding:40px 0;" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 5: ABC作业成本 -->
|
||||
<el-tab-pane label="ABC作业成本" name="abc">
|
||||
<el-tabs v-model="abcTab">
|
||||
<el-tab-pane label="作业中心" name="activities">
|
||||
<div style="margin-bottom:12px;"><el-button type="primary" @click="showAbcForm = true; abcForm = {}">+ 新建作业中心</el-button></div>
|
||||
<el-table :data="abcActivities" v-loading="abcLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="activity_code" label="编码" width="100" />
|
||||
<el-table-column prop="activity_name" label="作业名称" width="160" />
|
||||
<el-table-column prop="cost_driver" label="成本动因" width="120" />
|
||||
<el-table-column prop="driver_unit" label="动因单位" width="70" />
|
||||
<el-table-column prop="total_cost" label="总成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.total_cost) }}</template></el-table-column>
|
||||
<el-table-column prop="driver_volume" label="动因总量" width="90" align="right" />
|
||||
<el-table-column prop="driver_rate" label="分配率" width="90" align="right">{{ formatMoney(row.driver_rate) }}</el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ row }"><el-button size="small" @click="editAbc(row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="成本分配" name="allocations">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;">
|
||||
<el-button type="primary" @click="showAllocForm = true; allocForm = {}">+ 执行分配</el-button>
|
||||
<el-select v-model="allocPeriod" placeholder="期间" style="width:110px;" @change="loadAllocations">
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="abcAllocations" v-loading="allocLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="period" label="期间" width="80" />
|
||||
<el-table-column prop="activity_id" label="作业ID" width="70" />
|
||||
<el-table-column prop="product_code" label="产品" width="90" />
|
||||
<el-table-column prop="product_name" label="产品名" width="120" />
|
||||
<el-table-column prop="driver_consumed" label="动因消耗" width="90" align="right" />
|
||||
<el-table-column prop="allocated_cost" label="分配成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.allocated_cost) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 标准成本表单弹窗 -->
|
||||
<MyDialog v-model="showStdForm" :title="stdForm.id ? '编辑标准成本' : '新建标准成本'" :width="550">
|
||||
<el-form :model="stdForm" label-width="110px">
|
||||
<el-form-item label="产品编码"><el-input v-model="stdForm.product_code" /></el-form-item>
|
||||
<el-form-item label="产品名称"><el-input v-model="stdForm.product_name" /></el-form-item>
|
||||
<el-form-item label="成本类型">
|
||||
<el-select v-model="stdForm.cost_type"><el-option label="材料" value="material" /><el-option label="人工" value="labor" /><el-option label="制造费用" value="overhead" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称"><el-input v-model="stdForm.item_name" /></el-form-item>
|
||||
<el-form-item label="标准用量"><el-input-number v-model="stdForm.standard_quantity" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="stdForm.unit" style="width:150px;" /></el-form-item>
|
||||
<el-form-item label="标准单价"><el-input-number v-model="stdForm.standard_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showStdForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveStd" :loading="stdSaving">保存</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 实际成本录入弹窗 -->
|
||||
<MyDialog v-model="showActualForm" title="录入实际成本" :width="550">
|
||||
<el-form :model="actualForm" label-width="110px">
|
||||
<el-form-item label="期间"><el-input v-model="actualForm.period" placeholder="2026-05" /></el-form-item>
|
||||
<el-form-item label="产品编码"><el-input v-model="actualForm.product_code" /></el-form-item>
|
||||
<el-form-item label="产品名称"><el-input v-model="actualForm.product_name" /></el-form-item>
|
||||
<el-form-item label="成本类型"><el-select v-model="actualForm.cost_type"><el-option label="材料" value="material" /><el-option label="人工" value="labor" /><el-option label="制造费用" value="overhead" /></el-select></el-form-item>
|
||||
<el-form-item label="项目名称"><el-input v-model="actualForm.item_name" /></el-form-item>
|
||||
<el-form-item label="实际用量"><el-input-number v-model="actualForm.actual_quantity" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="实际单价"><el-input-number v-model="actualForm.actual_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showActualForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveActual" :loading="actualSaving">保存</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- ABC作业中心弹窗 -->
|
||||
<MyDialog v-model="showAbcForm" :title="abcForm.id ? '编辑作业中心' : '新建作业中心'" :width="500">
|
||||
<el-form :model="abcForm" label-width="110px">
|
||||
<el-form-item label="作业编码"><el-input v-model="abcForm.activity_code" /></el-form-item>
|
||||
<el-form-item label="作业名称"><el-input v-model="abcForm.activity_name" /></el-form-item>
|
||||
<el-form-item label="成本动因"><el-input v-model="abcForm.cost_driver" /></el-form-item>
|
||||
<el-form-item label="动因单位"><el-input v-model="abcForm.driver_unit" /></el-form-item>
|
||||
<el-form-item label="总成本"><el-input-number v-model="abcForm.total_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="动因总量"><el-input-number v-model="abcForm.driver_volume" :min="0" style="width:200px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAbcForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAbc" :loading="abcSaving">保存</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- ABC分配弹窗 -->
|
||||
<MyDialog v-model="showAllocForm" title="执行ABC成本分配" :width="450">
|
||||
<el-form :model="allocForm" label-width="110px">
|
||||
<el-form-item label="作业中心">
|
||||
<el-select v-model="allocForm.activity_id" style="width:250px;">
|
||||
<el-option v-for="a in abcActivities" :key="a.id" :label="a.activity_name" :value="a.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="期间"><el-input v-model="allocForm.period" placeholder="2026-05" /></el-form-item>
|
||||
<el-form-item label="产品编码"><el-input v-model="allocForm.product_code" /></el-form-item>
|
||||
<el-form-item label="产品名称"><el-input v-model="allocForm.product_name" /></el-form-item>
|
||||
<el-form-item label="动因消耗量"><el-input-number v-model="allocForm.driver_consumed" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAllocForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="doAllocate" :loading="allocSaving">执行分配</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { costApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const activeTab = ref('overview')
|
||||
const abcTab = ref('activities')
|
||||
|
||||
// ── 期间选项 ──
|
||||
const now = new Date()
|
||||
const periodOptions = computed(() => {
|
||||
const opts: string[] = []
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
opts.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
// ── 工具函数 ──
|
||||
const costTypeMap: Record<string, string> = { material: '材料', labor: '人工', overhead: '制造费用' }
|
||||
const costTypeTagMap: Record<string, string> = { material: 'primary', labor: 'success', overhead: 'warning' }
|
||||
function costTypeLabel(t: string) { return costTypeMap[t] || t }
|
||||
function costTypeTag(t: string) { return costTypeTagMap[t] || 'info' }
|
||||
function formatMoney(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 成本总览
|
||||
// ============================
|
||||
const overviewPeriod = ref(periodOptions.value[0] || now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0'))
|
||||
const overviewData = ref<any>({})
|
||||
const productOptions = ref<string[]>([])
|
||||
const maxTrend = ref(0)
|
||||
|
||||
async function loadOverview() {
|
||||
try {
|
||||
const r: any = await costApi.dashboard({ period: overviewPeriod.value })
|
||||
overviewData.value = r.data || r
|
||||
// 提取产品列表
|
||||
const products = overviewData.value.products || []
|
||||
productOptions.value = [...new Set(products.map((p: any) => p.product_code))]
|
||||
// 计算趋势最高值
|
||||
const trend = overviewData.value.overview?.trend || []
|
||||
if (trend.length > 0) {
|
||||
maxTrend.value = Math.max(...trend.map((t: any) => t.total_cost), 1)
|
||||
}
|
||||
} catch (e) { ElMessage.error('加载成本总览失败') }
|
||||
}
|
||||
|
||||
function trendHeight(val: number, max: number) {
|
||||
if (!max) return 0
|
||||
return Math.max(4, (val / max) * 140)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 标准成本管理
|
||||
// ============================
|
||||
const stdLoading = ref(false)
|
||||
const standardCosts = ref<any[]>([])
|
||||
const showStdForm = ref(false)
|
||||
const stdForm = ref<any>({})
|
||||
const stdSaving = ref(false)
|
||||
const stdProductFilter = ref('')
|
||||
|
||||
async function loadStandardCosts() {
|
||||
stdLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (stdProductFilter.value) params.product_code = stdProductFilter.value
|
||||
const r: any = await costApi.listStandardCosts(params)
|
||||
standardCosts.value = r.data || []
|
||||
} catch (e) { ElMessage.error('加载标准成本失败') }
|
||||
stdLoading.value = false
|
||||
}
|
||||
|
||||
function editStd(row: any) {
|
||||
stdForm.value = { ...row }
|
||||
showStdForm.value = true
|
||||
}
|
||||
|
||||
async function saveStd() {
|
||||
stdSaving.value = true
|
||||
try {
|
||||
if (stdForm.value.id) {
|
||||
await costApi.updateStandardCost(stdForm.value.id, stdForm.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await costApi.createStandardCost(stdForm.value)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
showStdForm.value = false
|
||||
loadStandardCosts()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
stdSaving.value = false
|
||||
}
|
||||
|
||||
async function deleteStd(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除「${row.item_name}」的标准成本?`, '确认')
|
||||
await costApi.deleteStandardCost(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadStandardCosts()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 成本录入
|
||||
// ============================
|
||||
const actualLoading = ref(false)
|
||||
const actualCosts = ref<any[]>([])
|
||||
const entryPeriod = ref(overviewPeriod.value)
|
||||
const showActualForm = ref(false)
|
||||
const actualForm = ref<any>({})
|
||||
const actualSaving = ref(false)
|
||||
|
||||
async function loadActualCosts() {
|
||||
actualLoading.value = true
|
||||
try {
|
||||
const params: any = { period: entryPeriod.value }
|
||||
const r: any = await costApi.listActualCosts(params)
|
||||
actualCosts.value = r.data || []
|
||||
} catch (e) { ElMessage.error('加载实际成本失败') }
|
||||
actualLoading.value = false
|
||||
}
|
||||
|
||||
async function saveActual() {
|
||||
actualSaving.value = true
|
||||
try {
|
||||
await costApi.createActualCost(actualForm.value)
|
||||
ElMessage.success('已录入')
|
||||
showActualForm.value = false
|
||||
loadActualCosts()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
actualSaving.value = false
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 量差价差分析
|
||||
// ============================
|
||||
const varProduct = ref('')
|
||||
const varPeriod = ref(overviewPeriod.value)
|
||||
const varLoading = ref(false)
|
||||
const varianceData = ref<any>({})
|
||||
|
||||
async function loadVariance() {
|
||||
if (!varProduct.value) { ElMessage.warning('请选择产品'); return }
|
||||
varLoading.value = true
|
||||
try {
|
||||
const r: any = await costApi.variance({ product_code: varProduct.value, period: varPeriod.value })
|
||||
varianceData.value = r
|
||||
} catch (e) { ElMessage.error('加载差异分析失败') }
|
||||
varLoading.value = false
|
||||
}
|
||||
|
||||
// ============================
|
||||
// ABC作业成本
|
||||
// ============================
|
||||
const abcLoading = ref(false)
|
||||
const abcActivities = ref<any[]>([])
|
||||
const showAbcForm = ref(false)
|
||||
const abcForm = ref<any>({})
|
||||
const abcSaving = ref(false)
|
||||
|
||||
async function loadAbcActivities() {
|
||||
abcLoading.value = true
|
||||
try {
|
||||
const r: any = await costApi.listAbcActivities()
|
||||
abcActivities.value = r.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
abcLoading.value = false
|
||||
}
|
||||
|
||||
function editAbc(row: any) {
|
||||
abcForm.value = { ...row }
|
||||
showAbcForm.value = true
|
||||
}
|
||||
|
||||
async function saveAbc() {
|
||||
abcSaving.value = true
|
||||
try {
|
||||
await costApi.createAbcActivity(abcForm.value)
|
||||
ElMessage.success('已保存')
|
||||
showAbcForm.value = false
|
||||
loadAbcActivities()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
abcSaving.value = false
|
||||
}
|
||||
|
||||
// ABC分配
|
||||
const showAllocForm = ref(false)
|
||||
const allocForm = ref<any>({})
|
||||
const allocPeriod = ref(overviewPeriod.value)
|
||||
const allocLoading = ref(false)
|
||||
const abcAllocations = ref<any[]>([])
|
||||
const allocSaving = ref(false)
|
||||
|
||||
async function loadAllocations() {
|
||||
allocLoading.value = true
|
||||
try {
|
||||
const r: any = await costApi.listAbcAllocations({ period: allocPeriod.value })
|
||||
abcAllocations.value = r.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
allocLoading.value = false
|
||||
}
|
||||
|
||||
async function doAllocate() {
|
||||
if (!allocForm.value.activity_id || !allocForm.value.product_code) {
|
||||
ElMessage.warning('请填写必要信息')
|
||||
return
|
||||
}
|
||||
allocSaving.value = true
|
||||
try {
|
||||
const r: any = await costApi.doAbcAllocate(allocForm.value)
|
||||
ElMessage.success(`分配完成:${r.allocated_cost || ''}`)
|
||||
showAllocForm.value = false
|
||||
loadAllocations()
|
||||
} catch (e) { ElMessage.error('分配失败') }
|
||||
allocSaving.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview()
|
||||
loadStandardCosts()
|
||||
loadActualCosts()
|
||||
loadAbcActivities()
|
||||
loadAllocations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { text-align: center; padding: 8px 0; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #303133; }
|
||||
.trend-bar { width: 40px; border-radius: 4px 4px 0 0; background: linear-gradient(to top, #409eff, #79bbff); transition: height 0.3s; }
|
||||
</style>
|
||||
@@ -0,0 +1,575 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 顶部导航条 -->
|
||||
<div class="dash-header">
|
||||
<div class="header-left">
|
||||
<el-tag size="large" :type="roleTagType">{{ roleLabel }}驾驶舱</el-tag>
|
||||
<el-radio-group v-model="periodType" size="small" @change="onPeriodChange">
|
||||
<el-radio-button value="month">本月</el-radio-button>
|
||||
<el-radio-button value="quarter">本季</el-radio-button>
|
||||
<el-radio-button value="year">本年</el-radio-button>
|
||||
<el-radio-button value="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-date-picker
|
||||
v-if="periodType === 'custom'"
|
||||
v-model="customStart"
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
style="width:140px"
|
||||
@change="onCustomRangeChange"
|
||||
/>
|
||||
<span v-if="periodType === 'custom'" style="color:#999;">至</span>
|
||||
<el-date-picker
|
||||
v-if="periodType === 'custom'"
|
||||
v-model="customEnd"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
size="small"
|
||||
style="width:140px"
|
||||
@change="onCustomRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span v-if="dataRange" class="data-range">📅 {{ dataRange }}</span>
|
||||
<el-button text size="small" @click="showSidebar = !showSidebar; if(showSidebar) refreshAnalysis()">
|
||||
{{ showSidebar ? '收起AI' : '🤖 AI分析' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== CEO视图 ====== -->
|
||||
<template v-if="userRole === 'ceo'">
|
||||
<!-- 摘要卡片行 -->
|
||||
<div class="summary-row">
|
||||
<div class="stat-card blue" @click="$router.push('/kpis')" style="cursor:pointer;"><div class="stat-val">{{ summary.kpi_total }}</div><div class="stat-label">KPI总数</div><div class="stat-sub click-hint">点击查看详情</div></div>
|
||||
<div class="stat-card red" @click="$router.push('/alerts')" style="cursor:pointer;"><div class="stat-val">{{ redAlerts }}</div><div class="stat-label">紧急异常</div><div class="stat-sub">待处理 {{ alerts.length }} 条</div></div>
|
||||
<div class="stat-card green" @click="$router.push('/kpis')" style="cursor:pointer;"><div class="stat-val">{{ greenKpis }}</div><div class="stat-label">正常</div></div>
|
||||
<div class="stat-card yellow" @click="$router.push('/alerts')" style="cursor:pointer;"><div class="stat-val">{{ yellowKpis }}</div><div class="stat-label">预警中</div></div>
|
||||
<div class="stat-card" :class="syncCardClass" @click="$router.push('/data')" style="cursor:pointer;">
|
||||
<div class="stat-val" style="font-size:14px;line-height:1.4;">{{ syncStatusText }}</div>
|
||||
<div class="stat-label">数据同步</div>
|
||||
<div class="stat-sub">{{ syncTimeText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区:双栏 -->
|
||||
<div class="ceo-main">
|
||||
<!-- 左栏 -->
|
||||
<div class="ceo-left">
|
||||
<!-- 异常高亮区 -->
|
||||
<div v-if="alerts.length > 0" class="alert-spotlight">
|
||||
<div class="spotlight-title">🔴 需要关注的异常</div>
|
||||
<div class="spotlight-list">
|
||||
<div v-for="a in alerts" :key="a.id" class="spotlight-item"
|
||||
:class="'level-' + a.alert_level"
|
||||
@click="$router.push('/alerts')">
|
||||
<span class="spotlight-badge">{{ a.alert_level === 'red' ? '紧急' : '预警' }}</span>
|
||||
<span class="spotlight-msg">{{ a.alert_message }}</span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI矩阵 — 四维度网格 -->
|
||||
<div class="matrix-grid">
|
||||
<div v-for="dim in kpiByDim" :key="dim.name" class="dim-card">
|
||||
<div class="dim-head" :style="{ background: dim.color }">
|
||||
<span class="dim-icon">{{ dim.icon }}</span>
|
||||
<span class="dim-name">{{ dim.name }}</span>
|
||||
<span class="dim-count">{{ dim.kpis.length }}</span>
|
||||
</div>
|
||||
<div class="dim-body">
|
||||
<div v-for="k in dim.kpis" :key="k.id" class="kpi-row" @click="showKPIDetail(k)">
|
||||
<div class="kpi-row-top">
|
||||
<span class="kpi-row-name">{{ k.kpi_name }}</span>
|
||||
<span class="kpi-row-badge" :style="{ color: alertColor(k.alert_level) }">{{ alertIcon(k.alert_level) }}</span>
|
||||
</div>
|
||||
<div class="kpi-row-btm">
|
||||
<span class="kpi-row-val">{{ fmtValue(k.actual_value, k.kpi_name) }}</span>
|
||||
<span class="kpi-row-target">/ {{ k.target_value ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏 -->
|
||||
<div class="ceo-right">
|
||||
<!-- 预测卡片 -->
|
||||
<div v-if="predictions.length > 0" class="right-card">
|
||||
<div class="right-card-title">🔮 下月预测</div>
|
||||
<div class="pred-list">
|
||||
<div v-for="p in predictions" :key="p.kpi_id" class="pred-row" :class="'trend-' + p.trend"
|
||||
@click="$router.push('/predict')" style="cursor:pointer;">
|
||||
<div class="pred-row-top">
|
||||
<span class="pred-name">{{ p.kpi_name }}</span>
|
||||
<span class="pred-arrow">{{ {up:'↑',down:'↓',stable:'→'}[p.trend] }}</span>
|
||||
</div>
|
||||
<div class="pred-row-val">
|
||||
<span class="pred-cur">{{ fmtValue(p.last_value, p.kpi_code) }}</span>
|
||||
<span class="pred-sep">→</span>
|
||||
<span class="pred-fut" :style="{ color: p.trend === 'down' ? '#f56c6c' : '#67c23a' }">
|
||||
{{ fmtValue(p.predicted_value, p.kpi_code) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pred-row-info">
|
||||
<span class="pred-conf" :class="p.confidence">{{ {high:'高',medium:'中',low:'低'}[p.confidence] }}置信</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====== 业务视图 ====== -->
|
||||
<template v-if="userRole === 'business'">
|
||||
<div class="summary-row">
|
||||
<div class="stat-card blue"><div class="stat-val">{{ myKpis.length }}</div><div class="stat-label">我的KPI</div></div>
|
||||
<div class="stat-card red"><div class="stat-val">{{ myKpis.filter((k:any)=>k.alert_level==='red').length }}</div><div class="stat-label">需关注</div></div>
|
||||
</div>
|
||||
<div v-if="myKpis.length === 0" class="empty-state">暂无关联到你的KPI</div>
|
||||
<div v-else class="kpi-card-grid">
|
||||
<div v-for="k in myKpis" :key="k.id" class="kpi-card-item" @click="showKPIDetail(k)">
|
||||
<div class="card-item-head">
|
||||
<span class="item-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="alertType(k.alert_level)">{{ alertIcon(k.alert_level) }}</el-tag>
|
||||
</div>
|
||||
<div class="card-item-val" :style="{ color: alertColor(k.alert_level) }">
|
||||
{{ fmtValue(k.actual_value, k.kpi_name) }}
|
||||
<span class="item-unit">{{ k.unit }}</span>
|
||||
</div>
|
||||
<div class="card-item-target">目标 {{ k.target_value ?? '-' }}</div>
|
||||
<div class="card-item-trend" v-if="k.trend && k.trend.length >= 2">
|
||||
<span v-for="(t, i) in k.trend.slice(-3)" :key="i" class="tdot" :style="{ background: trendColor(t.value, k) }"></span>
|
||||
<span class="trend-hint">近3月</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====== 财务视图 ====== -->
|
||||
<template v-if="userRole === 'finance'">
|
||||
<div class="summary-row">
|
||||
<div class="stat-card blue" @click="$router.push('/deviations')" style="cursor:pointer;"><div class="stat-val">{{ finSummary.total_sales ? fmtValue(finSummary.total_sales, 'SALES_TOTAL') : '-' }}</div><div class="stat-label">本月销售额</div></div>
|
||||
<div class="stat-card" :class="(finSummary.gross_profit_rate ?? 0) < 0 ? 'red' : 'green'" @click="$router.push('/deviations')" style="cursor:pointer;">
|
||||
<div class="stat-val">{{ finSummary.gross_profit_rate != null ? finSummary.gross_profit_rate.toFixed(1) + '%' : '-' }}</div>
|
||||
<div class="stat-label">毛利率</div>
|
||||
</div>
|
||||
<div class="stat-card yellow" @click="$router.push('/deviations')" style="cursor:pointer;"><div class="stat-val">{{ finSummary.receivable_turnover != null ? finSummary.receivable_turnover.toFixed(2) : '-' }}</div><div class="stat-label">应收周转率</div></div>
|
||||
</div>
|
||||
<div class="kpi-card-grid">
|
||||
<div v-for="k in finKpis" :key="k.id" class="kpi-card-item wide" @click="showKPIDetail(k)">
|
||||
<div class="card-item-head">
|
||||
<span class="item-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="alertType(k.alert_level)">{{ alertIcon(k.alert_level) }}</el-tag>
|
||||
</div>
|
||||
<div class="card-item-val" :style="{ color: alertColor(k.alert_level) }">
|
||||
{{ k.actual_value != null ? fmtValue(k.actual_value, k.kpi_code) : '-' }}
|
||||
<span class="item-unit">{{ k.unit }}</span>
|
||||
</div>
|
||||
<div class="trend-bars" v-if="k.trend && k.trend.length >= 2">
|
||||
<div v-for="(t, i) in k.trend" :key="i" class="tbar-wrap" :title="t.period + ': ' + (t.value ?? '-')">
|
||||
<div class="tbar" :style="{ height: barHeight(t.value, k.trend) + '%' }"></div>
|
||||
<div class="tbar-label">{{ t.period.slice(-2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====== IT视图 ====== -->
|
||||
<template v-if="userRole === 'it'">
|
||||
<div class="summary-row">
|
||||
<div class="stat-card blue"><div class="stat-val">{{ summary.kpi_total }}</div><div class="stat-label">活跃KPI</div></div>
|
||||
<div class="stat-card green"><div class="stat-val">{{ enabledChannels }}</div><div class="stat-label">通知渠道</div></div>
|
||||
<div class="stat-card gray"><div class="stat-val">{{ erpStatus }}</div><div class="stat-label">ERP同步</div></div>
|
||||
</div>
|
||||
<el-card shadow="never" class="sys-card">
|
||||
<template #header><span>⚙️ 系统概览</span></template>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="后端服务">{{ backendStatus }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库">{{ dbStatus }}</el-descriptions-item>
|
||||
<el-descriptions-item label="ERP网关">{{ erpGatewayStatus }}</el-descriptions-item>
|
||||
<el-descriptions-item label="定时任务">每日1:00</el-descriptions-item>
|
||||
<el-descriptions-item label="预警推送">企微应用</el-descriptions-item>
|
||||
<el-descriptions-item label="Redis">{{ redisStatus }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- AI分析浮窗 -->
|
||||
<div v-if="showSidebar" class="ai-panel">
|
||||
<div class="ai-head"><span>🤖 AI 分析</span><el-button text size="small" @click="showSidebar = false">✕</el-button></div>
|
||||
<div class="ai-body">
|
||||
<div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div>
|
||||
<div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div>
|
||||
<el-empty v-else description="暂无分析" />
|
||||
</div>
|
||||
<div class="ai-foot"><el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新</el-button></div>
|
||||
</div>
|
||||
|
||||
<!-- KPI详情弹窗 -->
|
||||
<el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px">
|
||||
<div v-if="selectedKPI">
|
||||
<div class="dlg-summary">
|
||||
<el-tag :type="alertType(selectedKPI.alert_level)" size="large">
|
||||
{{ periodType === 'month' ? '本月' : periodType }}值: {{ fmtValue(selectedKPI.actual_value, selectedKPI.kpi_name) }}
|
||||
</el-tag>
|
||||
<span>目标: {{ selectedKPI.target_value }} | {{ selectedKPI.responsible_dept || '' }}</span>
|
||||
</div>
|
||||
<div class="kpi-bar large" v-if="selectedKPI.target_value && selectedKPI.actual_value">
|
||||
<div class="bar-fill" :style="{ width: Math.min(100, selectedKPI.actual_value/selectedKPI.target_value*100)+'%', background: alertColor(selectedKPI.alert_level) }"></div>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div v-if="selectedKPIDetail" class="analysis-content" v-html="renderMd(selectedKPIDetail)"></div>
|
||||
<el-button v-else type="primary" plain @click="loadKPIAnalysis" :loading="loadingDetail">AI分析此KPI</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { dashboardApi, alertApi } from '../api/index'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const userStr = localStorage.getItem('cma_user') || '{}'
|
||||
const user = JSON.parse(userStr)
|
||||
const userRole = ref(user.role || 'ceo')
|
||||
|
||||
const roleTagType = computed(() => ({ ceo: 'danger', finance: 'warning', business: 'primary', it: 'info' }[userRole.value] || ''))
|
||||
const roleLabel = computed(() => ({ ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部' }[userRole.value] || userRole.value))
|
||||
|
||||
const periodType = ref('month')
|
||||
const customStart = ref('')
|
||||
const customEnd = ref('')
|
||||
const dataRange = ref('')
|
||||
const showSidebar = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const selectedKPI = ref<any>(null)
|
||||
const selectedKPIDetail = ref('')
|
||||
const loadingDetail = ref(false)
|
||||
const predictions = ref<any[]>([])
|
||||
const kpis = ref<any[]>([])
|
||||
const alerts = ref<any[]>([])
|
||||
const summary = ref({ kpi_total: 0, alert_count: 0, sync_status: { status: 'unknown', last_sync: null, detail: '' } })
|
||||
|
||||
// 同步状态卡片样式和文字
|
||||
const syncCardClass = computed(() => {
|
||||
const s = summary.value.sync_status?.status
|
||||
if (s === 'success') return 'green'
|
||||
if (s === 'failed') return 'red'
|
||||
return 'blue'
|
||||
})
|
||||
const syncStatusText = computed(() => {
|
||||
const s = summary.value.sync_status?.status
|
||||
if (s === 'success') return '✅ 同步正常'
|
||||
if (s === 'failed') return '❌ 同步失败'
|
||||
return '⏳ 待同步'
|
||||
})
|
||||
const syncTimeText = computed(() => {
|
||||
const raw = summary.value.sync_status?.last_sync || ''
|
||||
// 从日志行提取时间 2026-05-27 01:00:02
|
||||
const m = raw.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/)
|
||||
if (m) return m[1]
|
||||
return raw.substring(0, 20) || '--'
|
||||
})
|
||||
const redAlerts = computed(() => alerts.value.filter((a: any) => a.alert_level === 'red').length)
|
||||
const greenKpis = computed(() => kpis.value.filter((k: any) => k.alert_level === 'green' || k.alert_level === 'none').length)
|
||||
const yellowKpis = computed(() => kpis.value.filter((k: any) => k.alert_level === 'yellow' || k.alert_level === 'red').length)
|
||||
const myKpis = ref<any[]>([])
|
||||
const finKpis = ref<any[]>([])
|
||||
const finSummary = ref<any>({})
|
||||
const enabledChannels = ref(0)
|
||||
const aiAnalysis = ref('')
|
||||
const aiLoading = ref(false)
|
||||
|
||||
const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
||||
aiApi.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
const dimConfig: Record<string, { icon: string; color: string; name: string }> = {
|
||||
finance: { icon: '💰', color: '#409eff', name: '财务维度' },
|
||||
customer: { icon: '👥', color: '#67c23a', name: '客户维度' },
|
||||
process: { icon: '⚙️', color: '#e6a23c', name: '内部流程' },
|
||||
learning: { icon: '📚', color: '#f56c6c', name: '学习成长' },
|
||||
}
|
||||
|
||||
const kpiByDim = computed(() => {
|
||||
const dims: Record<string, any[]> = {}
|
||||
kpis.value.forEach((k: any) => {
|
||||
const d = k.dimension || 'other'
|
||||
if (!dims[d]) dims[d] = []
|
||||
dims[d].push(k)
|
||||
})
|
||||
return Object.entries(dims).map(([key, kpis]) => ({
|
||||
name: dimConfig[key]?.name || key,
|
||||
icon: dimConfig[key]?.icon || '📊',
|
||||
color: dimConfig[key]?.color || '#909399',
|
||||
kpis,
|
||||
}))
|
||||
})
|
||||
|
||||
function fmtValue(v: any, tag?: string): string {
|
||||
if (v == null) return '-'
|
||||
const n = Number(v)
|
||||
if (isNaN(n)) return String(v)
|
||||
if (tag === 'SALES_TOTAL') return '¥' + (n / 10000).toFixed(0) + '万'
|
||||
if (tag && (tag.includes('率') || tag.includes('RATE') || tag.includes('ratio') || tag.includes('RATIO'))) return n.toFixed(1) + '%'
|
||||
if (Math.abs(n) >= 10000) return (n / 10000).toFixed(1) + '万'
|
||||
if (Number.isInteger(n)) return n.toLocaleString()
|
||||
return n.toFixed(2)
|
||||
}
|
||||
function alertType(level: string): string {
|
||||
return level === 'red' ? 'danger' : level === 'yellow' ? 'warning' : level === 'green' ? 'success' : 'info'
|
||||
}
|
||||
function alertIcon(level: string): string {
|
||||
return level === 'red' ? '🔴' : level === 'yellow' ? '🟡' : level === 'green' ? '🟢' : '⚪'
|
||||
}
|
||||
function alertColor(level: string): string {
|
||||
return level === 'red' ? '#f56c6c' : level === 'yellow' ? '#e6a23c' : level === 'green' ? '#67c23a' : '#909399'
|
||||
}
|
||||
function trendColor(v: any, kpi: any): string {
|
||||
if (v == null) return '#ddd'
|
||||
if (kpi.threshold_red) {
|
||||
const threshold = parseFloat(kpi.threshold_red.replace(/[<>=]/g, ''))
|
||||
return v < threshold ? '#f56c6c' : '#67c23a'
|
||||
}
|
||||
return '#909399'
|
||||
}
|
||||
function barHeight(v: any, trend: any[]): number {
|
||||
if (v == null || trend.length === 0) return 0
|
||||
const values = trend.filter(t => t.value != null).map(t => Math.abs(t.value))
|
||||
if (values.length === 0) return 0
|
||||
return Math.max(...values) > 0 ? (Math.abs(v) / Math.max(...values)) * 80 : 0
|
||||
}
|
||||
function renderMd(text: string): string {
|
||||
if (!text) return ''
|
||||
return text.replace(/### (.+)/g, '<h4 style="margin:12px 0 6px">$1</h4>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
function showKPIDetail(k: any) {
|
||||
selectedKPI.value = k
|
||||
selectedKPIDetail.value = ''
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
async function loadKPIAnalysis() {
|
||||
if (!selectedKPI.value?.id) return
|
||||
loadingDetail.value = true
|
||||
try {
|
||||
const r: any = await aiApi.post('/ai/kpi-analysis', { kpi_id: selectedKPI.value.id, period: periodType.value })
|
||||
selectedKPIDetail.value = r.data || r.analysis || '暂无分析结果'
|
||||
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
|
||||
loadingDetail.value = false
|
||||
}
|
||||
|
||||
async function refreshAnalysis() {
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const r: any = await aiApi.post('/ai/dashboard-analysis', { role: userRole.value, period: periodType.value })
|
||||
aiAnalysis.value = r.data || r.analysis || '暂无分析'
|
||||
} catch { aiAnalysis.value = 'AI分析暂时不可用' }
|
||||
aiLoading.value = false
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
function getPeriodParams(): any {
|
||||
if (periodType.value === 'custom' && customStart.value && customEnd.value) {
|
||||
return { period: 'custom', start_date: customStart.value, end_date: customEnd.value }
|
||||
}
|
||||
return { period: periodType.value }
|
||||
}
|
||||
|
||||
function onPeriodChange(val: string) {
|
||||
if (val !== 'custom') loadData()
|
||||
}
|
||||
|
||||
function onCustomRangeChange(val: any) {
|
||||
if (customStart.value && customEnd.value) loadData()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const params = getPeriodParams()
|
||||
if (userRole.value === 'ceo') {
|
||||
const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([
|
||||
dashboardApi.kpis({ role: 'ceo', ...params }),
|
||||
alertApi.list({ limit: 10 }),
|
||||
dashboardApi.summary({ role: 'ceo', ...params }),
|
||||
dashboardApi.predict({}).catch(() => ({ data: [] })),
|
||||
])
|
||||
kpis.value = (kpiRes as any).data || []
|
||||
alerts.value = (alertRes as any).data?.filter((a: any) => a.status === 'pending') || []
|
||||
summary.value = { kpi_total: (sumRes as any).kpi_total || 0, alert_count: (sumRes as any).alert_count || 0 }
|
||||
predictions.value = (predRes as any)?.predictions || []
|
||||
dataRange.value = (kpiRes as any).range?.start + ' ~ ' + (kpiRes as any).range?.end
|
||||
} else if (userRole.value === 'business') {
|
||||
const r: any = await dashboardApi.myKpis?.(params)
|
||||
myKpis.value = (r as any)?.data || []
|
||||
dataRange.value = (r as any)?.period || ''
|
||||
} else if (userRole.value === 'finance') {
|
||||
const r: any = await dashboardApi.financeAnalysis?.(params)
|
||||
finKpis.value = (r as any)?.kpis || []
|
||||
finSummary.value = (r as any)?.summary || {}
|
||||
dataRange.value = (r as any)?.period || ''
|
||||
} else if (userRole.value === 'it') {
|
||||
const channelRes = await axios.get('/api/cma/notifications/channels').then(r => r.data)
|
||||
enabledChannels.value = channelRes.data?.filter((c: any) => c.enabled).length || 0
|
||||
}
|
||||
} catch (e) { console.error('加载数据失败:', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { loadData() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== 布局 ========== */
|
||||
.dashboard-page { max-width: 1400px; margin: 0 auto; position: relative; }
|
||||
|
||||
/* 顶部导航 */
|
||||
.dash-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; padding:12px 20px; background:#fff; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
|
||||
.header-left, .header-right { display:flex; align-items:center; gap:12px; }
|
||||
.data-range { color:#999; font-size:13px; }
|
||||
|
||||
/* ========== 摘要卡片行 ========== */
|
||||
.summary-row { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:14px; margin-bottom:20px; }
|
||||
.stat-card { background:#fff; border-radius:10px; padding:16px 20px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border-top:3px solid #ccc; transition:transform .15s; cursor:default; }
|
||||
.stat-card:hover { transform:translateY(-1px); box-shadow:0 3px 10px rgba(0,0,0,0.08); }
|
||||
.stat-card.blue { border-top-color:#409eff; }
|
||||
.stat-card.red { border-top-color:#f56c6c; }
|
||||
.stat-card.green { border-top-color:#67c23a; }
|
||||
.stat-card.yellow { border-top-color:#e6a23c; }
|
||||
.stat-card.gray { border-top-color:#909399; }
|
||||
.stat-val { font-size:28px; font-weight:700; color:#1a1a2e; letter-spacing:-1px; }
|
||||
.stat-label { font-size:13px; color:#888; margin-top:2px; }
|
||||
.stat-sub { font-size:11px; color:#bbb; margin-top:1px; }
|
||||
.click-hint { font-size:10px; color:#d0d0d0; }
|
||||
|
||||
/* ========== CEO双栏 ========== */
|
||||
.ceo-main { display:flex; gap:20px; align-items:flex-start; }
|
||||
.ceo-left { flex:2; min-width:0; }
|
||||
.ceo-right { flex:1; min-width:260px; max-width:340px; position:sticky; top:80px; }
|
||||
|
||||
/* 异常高亮区 */
|
||||
.alert-spotlight { background:linear-gradient(135deg, #fff5f5 0%, #fff 100%); border:1px solid #fde2e2; border-radius:10px; padding:16px; margin-bottom:20px; }
|
||||
.spotlight-title { font-size:15px; font-weight:600; margin-bottom:10px; color:#c0392b; }
|
||||
.spotlight-list { display:flex; flex-direction:column; gap:6px; }
|
||||
.spotlight-item { display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:8px; cursor:pointer; transition:background .15s; font-size:13px; }
|
||||
.spotlight-item:hover { background:rgba(245,108,108,0.08); }
|
||||
.spotlight-item.level-red { border-left:3px solid #f56c6c; }
|
||||
.spotlight-item.level-yellow { border-left:3px solid #e6a23c; }
|
||||
.spotlight-badge { font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; white-space:nowrap; }
|
||||
.spotlight-item.level-red .spotlight-badge { background:#fef0f0; color:#f56c6c; }
|
||||
.spotlight-item.level-yellow .spotlight-badge { background:#fdf6ec; color:#e6a23c; }
|
||||
.spotlight-msg { flex:1; color:#333; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.spotlight-item .el-icon { color:#ccc; flex-shrink:0; }
|
||||
|
||||
/* KPI矩阵 */
|
||||
.matrix-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:14px; }
|
||||
.dim-card { background:#fff; border-radius:10px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
|
||||
.dim-head { display:flex; align-items:center; gap:6px; padding:10px 14px; color:#fff; font-weight:600; font-size:13px; }
|
||||
.dim-icon { font-size:16px; }
|
||||
.dim-count { margin-left:auto; font-size:11px; opacity:.7; }
|
||||
.dim-body { padding:6px 0; }
|
||||
.kpi-row { padding:10px 14px; cursor:pointer; transition:background .12s; border-bottom:1px solid #f8f8f8; }
|
||||
.kpi-row:last-child { border-bottom:none; }
|
||||
.kpi-row:hover { background:#f8faff; }
|
||||
.kpi-row-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:4px; }
|
||||
.kpi-row-name { font-size:13px; color:#333; }
|
||||
.kpi-row-badge { font-size:12px; }
|
||||
.kpi-row-btm { display:flex; align-items:baseline; gap:4px; }
|
||||
.kpi-row-val { font-size:18px; font-weight:700; color:#1a1a2e; }
|
||||
.kpi-row-target { font-size:12px; color:#aaa; }
|
||||
|
||||
/* 右侧预测 */
|
||||
.right-card { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border:1px solid #f0f0f0; }
|
||||
.right-card-title { font-size:15px; font-weight:600; margin-bottom:14px; }
|
||||
.pred-list { display:flex; flex-direction:column; gap:12px; }
|
||||
.pred-row { padding:12px; border-radius:8px; border:1px solid #f0f0f0; transition:border-color .15s; }
|
||||
.pred-row:hover { border-color:#d0d0d0; }
|
||||
.pred-row.trend-down { border-left:3px solid #f56c6c; }
|
||||
.pred-row.trend-up { border-left:3px solid #67c23a; }
|
||||
.pred-row.trend-stable { border-left:3px solid #909399; }
|
||||
.pred-row-top { display:flex; justify-content:space-between; margin-bottom:6px; }
|
||||
.pred-name { font-size:13px; font-weight:500; color:#333; }
|
||||
.pred-arrow { font-size:14px; font-weight:700; color:#999; }
|
||||
.pred-row.trend-down .pred-arrow { color:#f56c6c; }
|
||||
.pred-row.trend-up .pred-arrow { color:#67c23a; }
|
||||
.pred-row-val { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
|
||||
.pred-cur { font-size:16px; font-weight:600; color:#555; }
|
||||
.pred-sep { color:#ccc; font-size:12px; }
|
||||
.pred-fut { font-size:18px; font-weight:700; }
|
||||
.pred-row-info { font-size:11px; }
|
||||
.pred-conf { padding:1px 6px; border-radius:3px; }
|
||||
.pred-conf.high { background:#f0f9eb; color:#67c23a; }
|
||||
.pred-conf.medium { background:#fdf6ec; color:#e6a23c; }
|
||||
.pred-conf.low { background:#f4f4f5; color:#909399; }
|
||||
|
||||
/* ========== 业务/财务KPI卡片 ========== */
|
||||
.kpi-card-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:14px; }
|
||||
.kpi-card-item { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); cursor:pointer; transition:all .15s; border:1px solid transparent; }
|
||||
.kpi-card-item:hover { border-color:#409eff; box-shadow:0 3px 12px rgba(64,158,255,0.12); }
|
||||
.kpi-card-item.wide { grid-column:span 2; }
|
||||
.card-item-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||
.item-name { font-size:14px; font-weight:500; color:#333; }
|
||||
.card-item-val { font-size:26px; font-weight:700; margin-bottom:4px; }
|
||||
.item-unit { font-size:13px; color:#999; font-weight:400; margin-left:4px; }
|
||||
.card-item-target { font-size:12px; color:#aaa; margin-bottom:6px; }
|
||||
.card-item-trend { display:flex; align-items:center; gap:4px; }
|
||||
.tdot { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||
.trend-hint { font-size:10px; color:#ccc; margin-left:2px; }
|
||||
|
||||
/* 财务趋势柱 */
|
||||
.trend-bars { display:flex; gap:6px; align-items:flex-end; height:40px; margin-top:8px; }
|
||||
.tbar-wrap { display:flex; flex-direction:column; align-items:center; flex:1; }
|
||||
.tbar { width:100%; background:#409eff; border-radius:2px 2px 0 0; min-height:3px; transition:height .3s; }
|
||||
.tbar-label { font-size:9px; color:#bbb; margin-top:3px; }
|
||||
|
||||
/* 系统卡片 */
|
||||
.sys-card { margin-top:16px; }
|
||||
|
||||
/* AI面板 */
|
||||
.ai-panel { position:fixed; top:80px; right:20px; width:340px; max-height:calc(100vh - 100px); background:#fff; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,0.12); display:flex; flex-direction:column; z-index:200; }
|
||||
.ai-head { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid #f0f0f0; font-weight:600; }
|
||||
.ai-body { flex:1; overflow-y:auto; padding:14px 16px; font-size:13px; line-height:1.6; }
|
||||
.ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; }
|
||||
.ai-loading { text-align:center; padding:30px 0; color:#999; }
|
||||
|
||||
/* KPI详情弹窗 */
|
||||
.dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
|
||||
.kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; }
|
||||
.bar-fill { height:100%; border-radius:5px; transition:width .5s; }
|
||||
.analysis-content { font-size:13px; line-height:1.7; }
|
||||
.analysis-content h4 { margin:12px 0 6px; color:#409eff; }
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state { text-align:center; padding:60px 0; color:#aaa; font-size:14px; }
|
||||
|
||||
/* 响应式 */
|
||||
@media screen and (max-width: 1024px) {
|
||||
.ceo-main { flex-direction:column; }
|
||||
.ceo-right { max-width:100%; position:static; }
|
||||
.matrix-grid { grid-template-columns:1fr; }
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.summary-row { grid-template-columns:repeat(2, 1fr); }
|
||||
.kpi-card-grid { grid-template-columns:1fr; }
|
||||
.kpi-card-item.wide { grid-column:span 1; }
|
||||
.dash-header { flex-direction:column; align-items:flex-start; gap:8px; }
|
||||
.ai-panel { width:calc(100vw - 40px); right:20px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>数据管理</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<el-tab-pane label="Excel导入" name="import">
|
||||
<el-card>
|
||||
<template #header>Excel导入</template>
|
||||
<el-upload drag :auto-upload="false" :on-change="handleFile" accept=".xlsx,.xls" :limit="1">
|
||||
<el-icon :size="32"><UploadFilled /></el-icon>
|
||||
<div>拖拽或点击上传Excel文件</div>
|
||||
<div style="color:#999;font-size:12px;">支持 .xlsx .xls,需包含 kpi_code, period, actual_value 三列</div>
|
||||
</el-upload>
|
||||
<el-button v-if="file" type="primary" style="margin-top:12px;" :loading="uploading" @click="doImport">导入数据</el-button>
|
||||
<div v-if="result" style="margin-top:12px;"><el-alert :title="result" type="success" show-icon /></div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="数据源管理" name="sources">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>数据源配置</span>
|
||||
<el-button type="primary" size="small" @click="openSourceForm()">新增数据源</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="sources" v-loading="sourcesLoading" style="width:100%">
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="source_type" label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="sourceTypeTag(row.source_type)" size="small">{{ row.source_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="api_endpoint" label="API地址" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="sync_type" label="同步方式" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.sync_type==='realtime'?'success':'info'">{{ row.sync_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status==='active'?'success':'danger'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_sync_at" label="上次同步" width="160" />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openSourceForm(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="doDeleteSource(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<MyDialog v-model="showSourceForm" :title="sourceForm.id ? '编辑数据源' : '新增数据源'" :width="550">
|
||||
<el-form :model="sourceForm" label-width="120px">
|
||||
<el-form-item label="名称"><el-input v-model="sourceForm.name" /></el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="sourceForm.source_type">
|
||||
<el-option label="ERP" value="erp" />
|
||||
<el-option label="业务系统" value="business" />
|
||||
<el-option label="Excel" value="excel" />
|
||||
<el-option label="手工" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="API地址"><el-input v-model="sourceForm.api_endpoint" placeholder="https://..." /></el-form-item>
|
||||
<el-form-item label="API Key"><el-input v-model="sourceForm.api_key" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="SQL查询"><el-input v-model="sourceForm.query_sql" type="textarea" :rows="3" placeholder="可选,用于查询型数据源" /></el-form-item>
|
||||
<el-form-item label="同步方式">
|
||||
<el-select v-model="sourceForm.sync_type">
|
||||
<el-option label="实时" value="realtime" />
|
||||
<el-option label="定时批量" value="batch" />
|
||||
<el-option label="手动" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showSourceForm=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSource" :loading="sourceSaving">保存</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import { dataApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const activeTab = ref('import')
|
||||
|
||||
// === Excel导入 ===
|
||||
const file = ref<any>(null)
|
||||
const uploading = ref(false)
|
||||
const result = ref('')
|
||||
function handleFile(f: any) { file.value = f.raw }
|
||||
async function doImport() {
|
||||
if (!file.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const r: any = await dataApi.importExcel(file.value)
|
||||
result.value = r.message || '导入成功'
|
||||
file.value = null
|
||||
} catch (e) { ElMessage.error('导入失败') }
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
// === 数据源管理 ===
|
||||
const sources = ref<any[]>([])
|
||||
const sourcesLoading = ref(false)
|
||||
const showSourceForm = ref(false)
|
||||
const sourceSaving = ref(false)
|
||||
const sourceForm = ref<any>({ source_type: 'manual', sync_type: 'manual' })
|
||||
|
||||
function sourceTypeTag(t: string) {
|
||||
return ({ erp: 'primary', business: 'warning', excel: 'success', manual: 'info' } as any)[t] || 'info'
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
sourcesLoading.value = true
|
||||
try {
|
||||
const r: any = await dataApi.listSources()
|
||||
sources.value = r.data || []
|
||||
} catch (e) { ElMessage.error('加载数据源失败') }
|
||||
sourcesLoading.value = false
|
||||
}
|
||||
|
||||
function openSourceForm(row?: any) {
|
||||
if (row) {
|
||||
sourceForm.value = { ...row }
|
||||
} else {
|
||||
sourceForm.value = { source_type: 'manual', sync_type: 'manual' }
|
||||
}
|
||||
showSourceForm.value = true
|
||||
}
|
||||
|
||||
async function saveSource() {
|
||||
sourceSaving.value = true
|
||||
try {
|
||||
if (sourceForm.value.id) {
|
||||
await dataApi.updateSource(sourceForm.value.id, sourceForm.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await dataApi.createSource(sourceForm.value)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
showSourceForm.value = false
|
||||
loadSources()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
sourceSaving.value = false
|
||||
}
|
||||
|
||||
async function doDeleteSource(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除数据源「${row.name}」?`, '确认')
|
||||
await dataApi.deleteSource(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadSources()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSources)
|
||||
</script>
|
||||
@@ -0,0 +1,317 @@
|
||||
<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="loadReport">
|
||||
<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="loadReport">
|
||||
<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_budget || 0 }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">超预算(红色)</div>
|
||||
<div class="stat-value red-text">{{ stats.red_count || 0 }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">接近阈值(黄色)</div>
|
||||
<div class="stat-value yellow-text">{{ stats.yellow_count || 0 }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">正常达标</div>
|
||||
<div class="stat-value green-text">{{ stats.normal_count || 0 }}</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>
|
||||
|
||||
<!-- 差异明细表格 -->
|
||||
<el-table :data="reportData" v-loading="loading" border stripe size="small" style="width:100%;" default-expand-all row-key="kpi_id" :tree-props="{ children: 'children' }">
|
||||
<el-table-column type="index" label="#" width="35" />
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="110" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
|
||||
<el-table-column prop="dimension" label="维度" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="dimTag(row.dimension)" style="font-size:11px;">{{ dimLabel(row.dimension) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="period" label="期间" width="80" />
|
||||
<el-table-column prop="budget_value" label="预算值" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatValue(row.budget_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际值" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatValue(row.actual_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviation_amount" label="差异额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: (row.deviation_amount || 0) > 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ row.deviation_amount !== undefined ? formatValue(row.deviation_amount) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviation_rate" label="差异率" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.deviation_rate !== undefined" :type="deviationTag(row.deviation_rate)" size="small">
|
||||
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(2) }}%
|
||||
</el-tag>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="yoy_rate" label="同比" width="85" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.yoy_rate !== undefined" :style="{ color: (row.yoy_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
|
||||
{{ row.yoy_rate > 0 ? '+' : '' }}{{ row.yoy_rate.toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else style="color:#999;">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mom_rate" label="环比" width="85" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.mom_rate !== undefined" :style="{ color: (row.mom_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
|
||||
{{ row.mom_rate > 0 ? '+' : '' }}{{ row.mom_rate.toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else style="color:#999;">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert_level" label="预警" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.alert_level === 'red'" type="danger" size="small">红色</el-tag>
|
||||
<el-tag v-else-if="row.alert_level === 'yellow'" type="warning" size="small">黄色</el-tag>
|
||||
<el-tag v-else-if="row.deviation_rate !== undefined" type="success" size="small">正常</el-tag>
|
||||
<span v-else style="color:#999;">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<el-empty v-if="!loading && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } 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 stats = ref<any>({})
|
||||
|
||||
// ── 维度标签 ──
|
||||
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 deviationTag(rate: number) {
|
||||
if (rate > 20) return 'danger'
|
||||
if (rate > 10) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function formatValue(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// ── 加载差异报告 ──
|
||||
async function loadReport() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
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)
|
||||
const data = r.data || r || []
|
||||
if (Array.isArray(data)) {
|
||||
reportData.value = data
|
||||
} else if (data.items) {
|
||||
reportData.value = data.items
|
||||
} else {
|
||||
reportData.value = []
|
||||
}
|
||||
// 统计
|
||||
stats.value = r.stats || r.statistics || {}
|
||||
// 如果后端没返回统计,自己算
|
||||
if (!stats.value.total_budget && reportData.value.length > 0) {
|
||||
const items = reportData.value
|
||||
stats.value.total_budget = items.length
|
||||
stats.value.red_count = items.filter((i: any) => (i.deviation_rate || 0) > 20).length
|
||||
stats.value.yellow_count = items.filter((i: any) => (i.deviation_rate || 0) > 10 && (i.deviation_rate || 0) <= 20).length
|
||||
stats.value.normal_count = items.filter((i: any) => (i.deviation_rate || 0) <= 10).length
|
||||
}
|
||||
} 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 rows = reportData.value
|
||||
if (rows.length === 0) { ElMessage.warning('无数据可导出'); return }
|
||||
const headers = ['KPI编码', 'KPI名称', '维度', '期间', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
|
||||
const csvRows = [headers.join(',')]
|
||||
for (const r of rows) {
|
||||
csvRows.push([
|
||||
r.kpi_code || '', `"${r.kpi_name || ''}"`, dimLabel(r.dimension || ''),
|
||||
r.period || '', r.budget_value ?? '', r.actual_value ?? '',
|
||||
r.deviation_amount ?? '', r.deviation_rate?.toFixed(2) ?? '',
|
||||
r.yoy_rate?.toFixed(1) ?? '', r.mom_rate?.toFixed(1) ?? '',
|
||||
r.alert_level || ''
|
||||
].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: 8px 0; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 28px; font-weight: bold; color: #303133; }
|
||||
.red-text { color: #f56c6c; }
|
||||
.yellow-text { color: #e6a23c; }
|
||||
.green-text { color: #67c23a; }
|
||||
.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,164 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-button text @click="$router.back()">< 返回KPI字典</el-button>
|
||||
<el-card v-if="kpi" style="margin-top:16px;">
|
||||
<template #header>
|
||||
<span style="font-size:16px;font-weight:600;">{{ kpi.kpi_name }}</span>
|
||||
<el-tag size="small" style="margin-left:8px;">{{ kpi.kpi_code }}</el-tag>
|
||||
<el-tag v-if="kpi.dimension" :type="dimTagType(kpi.dimension)" size="small" style="margin-left:4px;">{{ dimLabel(kpi.dimension) }}</el-tag>
|
||||
<el-tag v-if="kpi.category" type="info" size="small" style="margin-left:4px;">{{ catLabel(kpi.category) }}</el-tag>
|
||||
</template>
|
||||
<el-form :model="kpi" label-width="120px" size="small">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码">{{ kpi.kpi_code }}</el-form-item>
|
||||
<el-form-item label="KPI名称"><el-input v-model="kpi.kpi_name" /></el-form-item>
|
||||
<el-form-item label="维度">
|
||||
<el-select v-model="kpi.dimension" style="width:100%">
|
||||
<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-form-item label="二级类别">
|
||||
<el-select v-model="kpi.category" style="width:100%" :disabled="!kpi.dimension">
|
||||
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值"><el-input-number v-model="kpi.target_value" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="kpi.unit" /></el-form-item>
|
||||
<el-form-item label="频率">
|
||||
<el-select v-model="kpi.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-input v-model="kpi.responsible_dept" /></el-form-item>
|
||||
<el-form-item label="负责人"><el-input v-model="kpi.responsible_user" /></el-form-item>
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="kpi.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-form-item label="所属Epic">
|
||||
<el-select v-model="kpi.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-item label="绿灯阈值">
|
||||
<div style="display:flex;gap:8px;"><el-input v-model="kpi.threshold_green" /><el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="黄灯阈值"><el-input v-model="kpi.threshold_yellow" /></el-form-item>
|
||||
<el-form-item label="红灯阈值"><el-input v-model="kpi.threshold_red" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="计算公式"><el-input v-model="kpi.formula" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-top:16px;">
|
||||
<template #header>趋势图</template>
|
||||
<v-chart :option="chartOption" autoresize style="height:300px;width:100%;" />
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-top:16px;">
|
||||
<template #header>历史数据</template>
|
||||
<el-table :data="values" size="small" style="width:100%">
|
||||
<el-table-column prop="period" label="期间" width="120" />
|
||||
<el-table-column prop="actual_value" label="实际值" width="120" />
|
||||
<el-table-column prop="source_type" label="来源" width="80" />
|
||||
<el-table-column prop="data_status" label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.data_status==='verified'?'success':'warning'" size="small">{{ row.data_status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import VChart from 'vue-echarts'
|
||||
import 'echarts'
|
||||
import { kpiApi } from '../api/index'
|
||||
import api from '../api/index'
|
||||
|
||||
const route = useRoute()
|
||||
const kpi = ref<any>(null)
|
||||
const values = ref<any[]>([])
|
||||
|
||||
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 categoryOptions = computed(() => {
|
||||
const dim = kpi.value?.dimension
|
||||
if (!dim) return []
|
||||
return Object.entries(CAT_MAP)
|
||||
.filter(([key]) => {
|
||||
if (dim === 'finance') return ['revenue_growth','profitability','cost_control','asset_efficiency','cash_risk'].includes(key)
|
||||
if (dim === 'customer') return ['customer_scale','customer_concentration','customer_satisfaction'].includes(key)
|
||||
if (dim === 'process') return ['supply_chain','delivery_quality'].includes(key)
|
||||
if (dim === 'learning') return ['talent_pipeline','employee_engagement','innovation'].includes(key)
|
||||
return false
|
||||
})
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
})
|
||||
|
||||
function dimLabel(d: string) { return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d }
|
||||
function catLabel(c: string) { return CAT_MAP[c] || c }
|
||||
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
|
||||
|
||||
const chartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: values.value.map(v => v.period) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'line', data: values.value.map(v => v.actual_value), smooth: true, lineStyle: { width: 2 }, itemStyle: { color: '#409eff' }, areaStyle: { color: 'rgba(64,158,255,0.1)' } }],
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
}))
|
||||
|
||||
async function save() {
|
||||
try { await kpiApi.update(kpi.value.id, kpi.value); ElMessage.success('保存成功') }
|
||||
catch (e) { ElMessage.error('保存失败') }
|
||||
}
|
||||
|
||||
async function suggestThreshold() {
|
||||
try {
|
||||
const r: any = await api.get('/thresholds/suggest/' + kpi.value.id)
|
||||
const s = r.suggestion
|
||||
if (!s) { ElMessage.warning('暂无建议数据'); return }
|
||||
kpi.value.threshold_green = s.green?.label || ''
|
||||
kpi.value.threshold_yellow = s.yellow?.label || ''
|
||||
kpi.value.threshold_red = s.red?.label || ''
|
||||
ElMessage.success('已自动填入推荐阈值')
|
||||
} catch (e) {
|
||||
ElMessage.error('获取阈值建议失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await kpiApi.get(Number(route.params.id))
|
||||
kpi.value = r.data || r
|
||||
// 获取历史值
|
||||
try {
|
||||
const kv: any = await api.get('/kpis/' + kpi.value.id)
|
||||
values.value = kv.values || []
|
||||
} catch(e) {}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,565 @@
|
||||
<template>
|
||||
<div class="kpi-dict-page">
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<h3>KPI字典</h3>
|
||||
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
|
||||
</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.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" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="黄灯阈值"><el-input v-model="form.threshold_yellow" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="红灯阈值"><el-input v-model="form.threshold_red" /></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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { kpiApi, 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) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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,48 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h1>管理会计OS</h1>
|
||||
<p class="subtitle">博海网络科技</p>
|
||||
<el-form ref="formRef" :model="form" label-width="0" @keyup.enter="handleLogin">
|
||||
<el-form-item><el-input v-model="form.username" placeholder="用户名" size="large" /></el-form-item>
|
||||
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password /></el-form-item>
|
||||
<el-form-item><el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">登 录</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { authApi } from '../api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = reactive({ username: 'admin', password: 'admin123' })
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await authApi.login(form)
|
||||
localStorage.setItem('cma_token', res.token)
|
||||
localStorage.setItem('cma_user', JSON.stringify(res.user))
|
||||
router.push('/dashboard')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '登录失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page { display: flex; align-items: center; justify-content: center; height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.login-card { background: #fff; padding: 40px; border-radius: 12px; width: 380px; text-align: center; }
|
||||
.login-card h1 { margin: 0 0 4px; font-size: 24px; }
|
||||
.subtitle { color: #999; margin-bottom: 24px; }
|
||||
@media screen and (max-width: 768px) {
|
||||
.login-page { padding: 20px; }
|
||||
.login-card { width: 100%; max-width: 380px; padding: 30px 24px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,732 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连线模式提示 -->
|
||||
<div v-if="linkingFrom" class="linking-bar">
|
||||
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
|
||||
<el-button size="small" @click="cancelLink">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 画布主体 -->
|
||||
<div class="canvas-body" ref="bodyRef">
|
||||
<!-- SVG 连线层 -->
|
||||
<svg class="connection-svg" ref="svgRef">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
|
||||
</marker>
|
||||
<marker id="arrowhead-warn" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#e6a23c" />
|
||||
</marker>
|
||||
</defs>
|
||||
<line v-for="(line, idx) in connectionLines" :key="idx"
|
||||
:x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
|
||||
:class="['conn-line', { 'conn-selected': selectedConnIdx === idx }]"
|
||||
:style="{ '--from-color': line.fromColor, '--to-color': line.toColor }"
|
||||
marker-end="url(#arrowhead)"
|
||||
:stroke="selectedConnIdx === idx ? '#f56c6c' : '#409eff'"
|
||||
:stroke-width="selectedConnIdx === idx ? 3 : 2"
|
||||
@click.stop="selectConnection(idx)"
|
||||
/>
|
||||
<!-- 删除按钮(选中连线时) -->
|
||||
<g v-if="selectedConnIdx !== null && connectionLines[selectedConnIdx]">
|
||||
<circle
|
||||
:cx="connectionLines[selectedConnIdx].mx"
|
||||
:cy="connectionLines[selectedConnIdx].my"
|
||||
r="10" fill="#f56c6c" class="del-btn-circle"
|
||||
@click.stop="deleteConnection(selectedConnIdx)"
|
||||
/>
|
||||
<text
|
||||
:x="connectionLines[selectedConnIdx].mx"
|
||||
:y="connectionLines[selectedConnIdx].my + 4"
|
||||
text-anchor="middle" fill="#fff" font-size="12" font-weight="bold"
|
||||
class="del-btn-text"
|
||||
@click.stop="deleteConnection(selectedConnIdx)"
|
||||
>✕</text>
|
||||
</g>
|
||||
<!-- 绘制中的临时线 -->
|
||||
<line v-if="tempLine"
|
||||
:x1="tempLine.x1" :y1="tempLine.y1"
|
||||
:x2="tempLine.x2" :y2="tempLine.y2"
|
||||
stroke="#e6a23c" stroke-width="2" stroke-dasharray="6,3"
|
||||
marker-end="url(#arrowhead-warn)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 四维度列 -->
|
||||
<div class="dimension-col" v-for="(dim, di) in dimensions" :key="dim.key"
|
||||
:ref="el => setDimRef(dim.key, el)"
|
||||
:style="{ borderColor: dim.color }">
|
||||
<div class="dim-header" :style="{ background: dim.color }"
|
||||
@dblclick="startEditDim(di)"
|
||||
:title="'双击编辑维度'">
|
||||
<span class="dim-icon">{{ dim.icon }}</span>
|
||||
<span v-if="editingDimIdx !== di" class="dim-name">{{ dim.label }}</span>
|
||||
<el-input v-else v-model="dim.label" size="small" style="width:100px;"
|
||||
@blur="editingDimIdx = -1"
|
||||
@keyup.enter="editingDimIdx = -1" />
|
||||
<!-- 维度操作按钮 -->
|
||||
<el-dropdown trigger="click" @command="(cmd:string) => dimAction(cmd, di)">
|
||||
<el-button class="dim-more-btn" size="small" text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-item command="rename">重命名</el-dropdown-item>
|
||||
<el-dropdown-item command="color">更换颜色</el-dropdown-item>
|
||||
<el-dropdown-item command="icon">更换图标</el-dropdown-item>
|
||||
<el-dropdown-item v-if="dimensions.length > 1" command="delete" divided>删除此维度</el-dropdown-item>
|
||||
<el-dropdown-item command="add">左侧添加维度</el-dropdown-item>
|
||||
<el-dropdown-item command="addRight">右侧添加维度</el-dropdown-item>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="dim-body">
|
||||
<div
|
||||
v-for="(obj, oi) in dim.objectives" :key="oi"
|
||||
:ref="el => setObjRef(`${dim.key}-${oi}`, el)"
|
||||
class="objective-card"
|
||||
:class="{
|
||||
'linking-source': linkingFrom?.key === `${dim.key}-${oi}`,
|
||||
'linking-target': linkingFrom && linkingFrom?.key !== `${dim.key}-${oi}`,
|
||||
'obj-level-red': objectiveLevels[`${dim.key}-${oi}`] === 'red',
|
||||
'obj-level-yellow': objectiveLevels[`${dim.key}-${oi}`] === 'yellow',
|
||||
'obj-level-green': objectiveLevels[`${dim.key}-${oi}`] === 'green',
|
||||
}"
|
||||
@click="onObjClick(dim.key, oi, obj)"
|
||||
>
|
||||
<div class="obj-title">
|
||||
<span class="obj-level-dot" :class="'dot-' + (objectiveLevels[`${dim.key}-${oi}`] || 'gray')"></span>
|
||||
<el-icon v-if="obj.icon" style="margin-right:4px;"><component :is="iconMap[obj.icon]" /></el-icon>
|
||||
{{ obj.name }}
|
||||
</div>
|
||||
<div v-if="obj.description" class="obj-desc">{{ obj.description }}</div>
|
||||
<div class="obj-kpis">
|
||||
<el-tag v-for="kpi in (obj.kpis||[])" :key="kpi" size="small"
|
||||
style="margin:2px;cursor:pointer;"
|
||||
@click.stop="goKPI(kpi)">
|
||||
{{ kpi }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="obj-actions">
|
||||
<el-button text size="small" @click.stop="editObjective(dim.key, oi, obj)" title="编辑目标">✏️</el-button>
|
||||
<el-button text size="small" @click.stop="deleteObjective(dim.key, oi)" title="删除目标" style="color:#f56c6c;">🗑️</el-button>
|
||||
<span class="link-btn" @click.stop="startLink(dim.key, oi, obj)" title="从此目标创建因果连线">↗</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button text type="primary" size="small" style="margin-top:8px;width:100%;" @click="addObjective(dim.key)">
|
||||
+ 添加目标
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑目标弹窗 -->
|
||||
<el-dialog v-model="showDialog" :title="editingIndex >= 0 ? '编辑目标' : '添加目标'" width="500">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="目标名称"><el-input v-model="editForm.name" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="editForm.description" type="textarea" rows="3" /></el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-select v-model="editForm.icon" style="width:100%;">
|
||||
<el-option v-for="(ico, key) in iconOptions" :key="key" :label="ico" :value="key">
|
||||
<el-icon style="margin-right:6px;"><component :is="iconMap[key]" /></el-icon>{{ ico }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联KPI">
|
||||
<div class="kpi-select-wrap">
|
||||
<el-select v-model="editForm.kpis" multiple filterable style="flex:1;" :max="3"
|
||||
popper-class="dialog-select-popper">
|
||||
<el-option v-for="k in allKpis" :key="k.id" :label="k.kpi_name" :value="k.kpi_code" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="$router.push('/kpis')" style="margin-left:4px;">管理KPI</el-button>
|
||||
</div>
|
||||
<span v-if="editForm.kpis.length >= 3" style="color:#e6a23c;font-size:12px;">每个目标最多绑定3个KPI</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveObjective">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 版本历史 -->
|
||||
<el-dialog v-model="showVersions" title="版本历史" width="600">
|
||||
<el-table :data="versions" style="width:100%">
|
||||
<el-table-column prop="comment" label="说明" min-width="200" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="warning" @click="rollback(row.id)">回滚</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick, onUnmounted } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import { MoreFilled } from "@element-plus/icons-vue"
|
||||
import { mapApi, kpiApi } from "../api/index"
|
||||
import api from "../api/index"
|
||||
|
||||
// ── 维度配置(动态从后端加载) ──
|
||||
const DEFAULT_DIMENSIONS = [
|
||||
{ key: "finance", label: "财务维度", icon: "💰", color: "#409eff", objectives: [] as any[] },
|
||||
{ key: "customer", label: "客户维度", icon: "🤝", color: "#67c23a", objectives: [] as any[] },
|
||||
{ key: "process", label: "内部流程", icon: "⚙️", color: "#e6a23c", objectives: [] as any[] },
|
||||
{ key: "learning", label: "学习成长", icon: "📚", color: "#f56c6c", objectives: [] as any[] },
|
||||
]
|
||||
const dimensions = reactive<any[]>([])
|
||||
const editingDimIdx = ref(-1)
|
||||
|
||||
// 用默认值初始化
|
||||
DEFAULT_DIMENSIONS.forEach(d => dimensions.push({ ...d, objectives: [] }))
|
||||
|
||||
// ── 维度操作方法 ──
|
||||
function startEditDim(idx: number) {
|
||||
editingDimIdx.value = idx
|
||||
}
|
||||
|
||||
function dimAction(cmd: string, idx: number) {
|
||||
switch (cmd) {
|
||||
case "rename":
|
||||
editingDimIdx.value = idx
|
||||
break
|
||||
case "color": {
|
||||
const colors = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399", "#9b59b6", "#1abc9c", "#e74c3c"]
|
||||
const cur = dimensions[idx].color
|
||||
const nextIdx = (colors.indexOf(cur) + 1) % colors.length
|
||||
dimensions[idx].color = colors[nextIdx]
|
||||
break
|
||||
}
|
||||
case "icon": {
|
||||
const icons = ["💰", "🤝", "⚙️", "📚", "📊", "🎯", "🛡️", "⭐", "📈", "🔬"]
|
||||
const cur = dimensions[idx].icon
|
||||
const nextIdx = (icons.indexOf(cur) + 1) % icons.length
|
||||
dimensions[idx].icon = icons[nextIdx]
|
||||
break
|
||||
}
|
||||
case "delete":
|
||||
dimensions.splice(idx, 1)
|
||||
break
|
||||
case "add": {
|
||||
const key = "dim_" + Date.now()
|
||||
dimensions.splice(idx, 0, {
|
||||
key, label: "新维度", icon: "📊", color: "#909399", objectives: [],
|
||||
})
|
||||
editingDimIdx.value = idx
|
||||
break
|
||||
}
|
||||
case "addRight": {
|
||||
const key = "dim_" + Date.now()
|
||||
dimensions.splice(idx + 1, 0, {
|
||||
key, label: "新维度", icon: "📊", color: "#909399", objectives: [],
|
||||
})
|
||||
editingDimIdx.value = idx + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const iconOptions: Record<string, string> = {
|
||||
target: "目标", star: "星级", rocket: "火箭", chart: "图表", team: "团队",
|
||||
light: "灯泡", shield: "盾牌", gear: "齿轮", handshake: "握手", medal: "奖牌",
|
||||
}
|
||||
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",
|
||||
}
|
||||
|
||||
// ── 状态 ──
|
||||
const maps = ref<any[]>([])
|
||||
const selectedMap = ref<number | null>(null)
|
||||
const currentMap = ref<any>(null)
|
||||
const allKpis = ref<any[]>([])
|
||||
const showDialog = ref(false)
|
||||
const editingDim = ref("")
|
||||
const editingIndex = ref(-1)
|
||||
const editForm = reactive({ name: "", description: "", icon: "target", kpis: [] as string[] })
|
||||
const showVersions = ref(false)
|
||||
const versions = ref<any[]>([])
|
||||
const connections = ref<any[]>([]) // [{from, to, style}]
|
||||
// 目标红黄绿灯状态
|
||||
const objectiveLevels = reactive<Record<string, string>>({})
|
||||
|
||||
// 连线交互
|
||||
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
|
||||
const selectedConnIdx = ref<number | null>(null)
|
||||
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
|
||||
|
||||
// DOM 引用
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
const dimRefs: Record<string, HTMLElement> = {}
|
||||
const objRefs: Record<string, HTMLElement> = {}
|
||||
|
||||
function setDimRef(key: string, el: any) { if (el) dimRefs[key] = el }
|
||||
function setObjRef(key: string, el: any) { if (el) objRefs[key] = el }
|
||||
|
||||
// 计算连线在 SVG 中的坐标
|
||||
const connectionLines = ref<any[]>([])
|
||||
|
||||
function recalcLines() {
|
||||
if (!svgRef.value || !bodyRef.value) { connectionLines.value = []; return }
|
||||
const svgRect = svgRef.value.getBoundingClientRect()
|
||||
const bodyRect = bodyRef.value.getBoundingClientRect()
|
||||
|
||||
connectionLines.value = connections.value.map(c => {
|
||||
const fromEl = objRefs[c.from]
|
||||
const toEl = objRefs[c.to]
|
||||
if (!fromEl || !toEl) return { x1: 0, y1: 0, x2: 0, y2: 0, mx: 0, my: 0, fromColor: '#999', toColor: '#999' }
|
||||
|
||||
const fr = fromEl.getBoundingClientRect()
|
||||
const tr = toEl.getBoundingClientRect()
|
||||
const dimKey = c.from.split('-')[0]
|
||||
const toDimKey = c.to.split('-')[0]
|
||||
const fromDim = dimensions.find(d => d.key === dimKey)
|
||||
const toDim = dimensions.find(d => d.key === toDimKey)
|
||||
|
||||
// 从右侧中点 → 左侧中点
|
||||
const x1 = fr.right - bodyRect.left
|
||||
const y1 = fr.top + fr.height / 2 - bodyRect.top
|
||||
const x2 = tr.left - bodyRect.left
|
||||
const y2 = tr.top + tr.height / 2 - bodyRect.top
|
||||
|
||||
return {
|
||||
x1, y1, x2, y2,
|
||||
mx: (x1 + x2) / 2,
|
||||
my: (y1 + y2) / 2,
|
||||
fromColor: fromDim?.color || '#999',
|
||||
toColor: toDim?.color || '#999',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// ── 核心操作 ──
|
||||
function loadCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
for (const dim of dimensions) { dim.objectives = [] }
|
||||
connections.value = []
|
||||
linkingFrom.value = null
|
||||
selectedConnIdx.value = null
|
||||
|
||||
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) {
|
||||
const target = dimensions.find((d: any) => d.key === dim.key || d.label === dim.name)
|
||||
if (target) {
|
||||
target.objectives = dim.objectives || []
|
||||
target.icon = dim.icon || target.icon
|
||||
target.color = dim.color || target.color
|
||||
}
|
||||
}
|
||||
}
|
||||
connections.value = map?.canvas_data?.connections || []
|
||||
nextTick(recalcLines)
|
||||
|
||||
// 加载目标状态(红黄绿灯)
|
||||
loadObjectiveLevels()
|
||||
})
|
||||
}
|
||||
|
||||
// ── 连线交互 ──
|
||||
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
|
||||
}
|
||||
|
||||
function onObjClick(dimKey: string, oi: number, obj: any) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
|
||||
// 如果正在连线模式
|
||||
if (linkingFrom.value) {
|
||||
if (linkingFrom.value.key === key) { cancelLink(); return }
|
||||
|
||||
// 校验规则
|
||||
const fromDim = linkingFrom.value.key.split('-')[0]
|
||||
const toDim = key.split('-')[0]
|
||||
if (fromDim === toDim) { ElMessage.warning("同维度内不能连线"); return }
|
||||
|
||||
// 检查重复
|
||||
if (connections.value.some((c: any) => c.from === linkingFrom.value.key && c.to === key)) {
|
||||
ElMessage.warning("已存在相同连线"); cancelLink(); return
|
||||
}
|
||||
|
||||
// 调用后端API
|
||||
api.post(`/maps/${selectedMap.value}/connections`, {
|
||||
from: linkingFrom.value.key,
|
||||
to: key,
|
||||
}).then(() => {
|
||||
ElMessage.success("连线已添加")
|
||||
// 本地追加并保存全量到后端
|
||||
connections.value.push({ from: linkingFrom.value!.key, to: key, style: "solid" })
|
||||
cancelLink()
|
||||
nextTick(() => { recalcLines(); saveConnections() })
|
||||
}).catch((e: any) => {
|
||||
// 后端失败时本地加(降级)
|
||||
if (e?.response?.status !== 400) {
|
||||
connections.value.push({ from: linkingFrom.value!.key, to: key, style: "solid" })
|
||||
ElMessage.success("连线已添加(本地)")
|
||||
} else {
|
||||
ElMessage.warning(e?.response?.data?.detail || "连线失败")
|
||||
}
|
||||
cancelLink()
|
||||
nextTick(recalcLines)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 非连线模式:编辑目标
|
||||
editObjective(dimKey, oi, obj)
|
||||
}
|
||||
|
||||
// 跳转KPI详情
|
||||
function goKPI(kpiCode: string) {
|
||||
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
|
||||
if (found) {
|
||||
window.location.href = '/kpis/' + found.id
|
||||
} else {
|
||||
window.location.href = '/kpis'
|
||||
}
|
||||
}
|
||||
|
||||
// 删除目标
|
||||
async function deleteObjective(dimKey: string, oi: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要删除这个目标吗?", "确认删除")
|
||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||
if (dim) {
|
||||
dim.objectives.splice(oi, 1)
|
||||
ElMessage.success("目标已删除")
|
||||
}
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
function selectConnection(idx: number) {
|
||||
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
|
||||
}
|
||||
|
||||
function deleteConnection(idx: number) {
|
||||
if (selectedConnIdx.value !== idx) return
|
||||
const conn = connections.value[idx]
|
||||
if (!conn) return
|
||||
|
||||
// 通过 from/to 标识删除,不用索引
|
||||
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(recalcLines)
|
||||
}).catch(() => {
|
||||
// 后端失败时本地直接删
|
||||
connections.value.splice(idx, 1)
|
||||
selectedConnIdx.value = null
|
||||
nextTick(recalcLines)
|
||||
})
|
||||
}
|
||||
|
||||
// 鼠标移动跟踪(临时连线)
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!linkingFrom.value || !bodyRef.value) { tempLine.value = null; return }
|
||||
const fromEl = objRefs[linkingFrom.value.key]
|
||||
if (!fromEl) return
|
||||
const bodyRect = bodyRef.value.getBoundingClientRect()
|
||||
const fr = fromEl.getBoundingClientRect()
|
||||
tempLine.value = {
|
||||
x1: fr.right - bodyRect.left,
|
||||
y1: fr.top + fr.height / 2 - bodyRect.top,
|
||||
x2: e.clientX - bodyRect.left,
|
||||
y2: e.clientY - bodyRect.top,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 目标 CRUD ──
|
||||
function addObjective(dimKey: string) {
|
||||
editingDim.value = dimKey
|
||||
editingIndex.value = -1
|
||||
editForm.name = ""
|
||||
editForm.description = ""
|
||||
editForm.icon = "target"
|
||||
editForm.kpis = []
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function editObjective(dimKey: string, idx: number, obj: any) {
|
||||
editingDim.value = dimKey
|
||||
editingIndex.value = idx
|
||||
editForm.name = obj.name
|
||||
editForm.description = obj.description || ""
|
||||
editForm.icon = obj.icon || "target"
|
||||
editForm.kpis = obj.kpis || []
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function saveObjective() {
|
||||
if (!editForm.name) { ElMessage.warning("请输入目标名称"); return }
|
||||
const dim = dimensions.find((d: any) => d.key === editingDim.value)
|
||||
if (!dim) return
|
||||
if (editingIndex.value >= 0) {
|
||||
dim.objectives[editingIndex.value] = { name: editForm.name, description: editForm.description, icon: editForm.icon, kpis: [...editForm.kpis] }
|
||||
} else {
|
||||
dim.objectives.push({ name: editForm.name, description: editForm.description, icon: editForm.icon, kpis: [...editForm.kpis] })
|
||||
}
|
||||
showDialog.value = false
|
||||
saveCanvas()
|
||||
// 强制刷新目标卡片DOM引用
|
||||
nextTick(() => {
|
||||
recalcLines()
|
||||
})
|
||||
}
|
||||
|
||||
async function saveConnections() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
canvas_data: { connections: connections.value },
|
||||
})
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function saveCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
const mapData = dimensions.map((d: any) => ({
|
||||
key: d.key, name: d.label, icon: d.icon, color: d.color, objectives: d.objectives,
|
||||
}))
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
})
|
||||
// 保存后刷新objRefs
|
||||
nextTick(() => {
|
||||
for (const dim of dimensions) {
|
||||
for (let i = 0; i < dim.objectives.length; i++) {
|
||||
const key = `${dim.key}-${i}`
|
||||
if (!objRefs[key]) {
|
||||
// 新添加的目标可能还没被ref捕获,再等一帧
|
||||
setTimeout(() => { recalcLines() }, 100)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
recalcLines()
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error("saveCanvas error:", e)
|
||||
ElMessage.error("保存失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 发布/版本 ──
|
||||
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 (e) {
|
||||
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]
|
||||
}
|
||||
// 从review接口的维度数据中提取每个目标的状态
|
||||
const dims = r.dimensions || []
|
||||
for (const dim of dims) {
|
||||
const dimKey = dim.key
|
||||
dim.objectives.forEach((obj: any, idx: number) => {
|
||||
const key = `${dimKey}-${idx}`
|
||||
objectiveLevels[key] = obj.level || 'gray'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await mapApi.list()
|
||||
maps.value = r.data || []
|
||||
if (maps.value.length > 0) {
|
||||
selectedMap.value = maps.value[0].id
|
||||
loadCanvas()
|
||||
}
|
||||
const k: any = await kpiApi.list()
|
||||
allKpis.value = k.data || []
|
||||
|
||||
// 监听鼠标移动画临时线
|
||||
document.addEventListener("mousemove", onMouseMove)
|
||||
|
||||
// 监听窗口/列大小变化重新计算连线
|
||||
resizeObserver = new ResizeObserver(() => nextTick(recalcLines))
|
||||
if (bodyRef.value) resizeObserver.observe(bodyRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousemove", onMouseMove)
|
||||
if (resizeObserver) resizeObserver.disconnect()
|
||||
})
|
||||
</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; }
|
||||
.toolbar h3 { margin: 0; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.canvas-body {
|
||||
display: flex; gap: 12px; flex: 1; min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* SVG 连线层 */
|
||||
.connection-svg {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 5; pointer-events: none;
|
||||
}
|
||||
.conn-line {
|
||||
cursor: pointer; transition: stroke .2s;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
.conn-line:hover { stroke: #e6a23c !important; stroke-width: 3; }
|
||||
.conn-selected { stroke: #f56c6c !important; stroke-width: 3; }
|
||||
.del-btn-circle { cursor: pointer; pointer-events: all; }
|
||||
.del-btn-text { cursor: pointer; pointer-events: all; user-select: none; }
|
||||
|
||||
/* 四列 */
|
||||
.dimension-col {
|
||||
flex: 1; min-width: 0;
|
||||
border: 2px solid #e8e8e8; border-radius: 8px;
|
||||
display: flex; flex-direction: column; background: #fafafa;
|
||||
overflow-y: auto; position: relative; z-index: 1;
|
||||
}
|
||||
.dim-header {
|
||||
padding: 10px; color: #fff; border-radius: 6px 6px 0 0;
|
||||
text-align: center; font-weight: bold; display: flex;
|
||||
align-items: center; justify-content: center; gap: 6px; font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dim-body { padding: 10px; flex: 1; }
|
||||
|
||||
.objective-card {
|
||||
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
padding: 10px; margin-bottom: 8px; cursor: pointer;
|
||||
transition: all 0.2s; position: relative;
|
||||
}
|
||||
.objective-card:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
|
||||
.objective-card.linking-source {
|
||||
border-color: #e6a23c; box-shadow: 0 0 0 2px rgba(230,162,60,0.3);
|
||||
}
|
||||
.objective-card.linking-target:hover {
|
||||
border-color: #67c23a; box-shadow: 0 0 0 2px rgba(103,194,58,0.3);
|
||||
}
|
||||
|
||||
.obj-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; }
|
||||
.obj-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
|
||||
.obj-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
|
||||
/* 操作按钮组 */
|
||||
.obj-actions { display: flex; align-items: center; gap: 2px; margin-top: 6px; justify-content: flex-end; }
|
||||
.obj-actions .el-button { padding: 2px 4px !important; font-size: 12px; min-height: auto; }
|
||||
.obj-actions .link-btn {
|
||||
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
|
||||
background: #ecf5ff; color: #409eff; border-radius: 50%;
|
||||
font-size: 14px; font-weight: bold; cursor: pointer; transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.obj-actions .link-btn:hover { transform: scale(1.2); background: #409eff; color: #fff; }
|
||||
|
||||
/* 连线入口按钮(旧,保留兼容) */
|
||||
.link-btn {
|
||||
position: absolute; bottom: 4px; right: 6px;
|
||||
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
|
||||
background: #ecf5ff; color: #409eff; border-radius: 50%;
|
||||
font-size: 14px; font-weight: bold; cursor: pointer; opacity: 0;
|
||||
transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.objective-card:hover .link-btn { opacity: 1; }
|
||||
.link-btn:hover { transform: scale(1.2); background: #409eff; color: #fff; }
|
||||
|
||||
/* 目标红黄绿灯指示器 */
|
||||
.obj-level-dot {
|
||||
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
||||
margin-right: 4px; flex-shrink: 0;
|
||||
}
|
||||
.dot-green { background: #67c23a; }
|
||||
.dot-yellow { background: #e6a23c; }
|
||||
.dot-red { background: #f56c6c; }
|
||||
.dot-gray { background: #dcdfe6; }
|
||||
.obj-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; }
|
||||
.obj-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
|
||||
.obj-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
|
||||
|
||||
/* 维度列头部操作按钮 */
|
||||
.dim-more-btn { color: rgba(255,255,255,0.7) !important; margin-left: auto; padding: 2px !important; }
|
||||
.dim-more-btn:hover { color: #fff !important; }
|
||||
.dim-header { position: relative; }
|
||||
|
||||
/* KPI选择区 */
|
||||
.kpi-select-wrap { display: flex; align-items: center; width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3>战略地图</h3>
|
||||
<el-button type="primary" @click="showForm=true;form={}">新建地图</el-button>
|
||||
</div>
|
||||
<el-table :data="maps" style="width:100%">
|
||||
<el-table-column prop="title" label="地图名称" min-width="200" />
|
||||
<el-table-column prop="version" label="版本" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">{{ row.status === "published" ? "已发布" : "草稿" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="250">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click='goCanvas(row.id)'>画布</el-button>
|
||||
<el-button size="small" @click='editMap(row)'>编辑</el-button>
|
||||
<el-button size="small" @click='goVersion(row.id)'>版本</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteMap(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="showForm" :title="editingMap ? '编辑地图' : '新建战略地图'" width="500">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="地图名称"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="版本号"><el-input v-model="form.version" placeholder="v1.0" /></el-form-item>
|
||||
<el-form-item label="创建方式" v-if="!editingMap">
|
||||
<el-radio-group v-model="createMode">
|
||||
<el-radio value="blank">空白地图</el-radio>
|
||||
<el-radio value="template">快速模板(4维度+预设目标)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showForm=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveMap">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import { mapApi } from "../api/index"
|
||||
|
||||
const router = useRouter()
|
||||
const maps = ref<any[]>([])
|
||||
const showForm = ref(false)
|
||||
const editingMap = ref<any>(null)
|
||||
const form = ref<any>({})
|
||||
const createMode = ref("blank")
|
||||
|
||||
async function load() {
|
||||
try { const r: any = await mapApi.list(); maps.value = r.data || [] } catch (e) {}
|
||||
}
|
||||
|
||||
function goCanvas(id: number) { router.push('/maps/canvas/' + id) }
|
||||
function goVersion(id: number) { router.push('/maps/versions/' + id) }
|
||||
|
||||
async function saveMap() {
|
||||
try {
|
||||
if (editingMap.value) {
|
||||
await mapApi.update(editingMap.value.id, form.value)
|
||||
ElMessage.success("已更新")
|
||||
} else if (createMode.value === "template") {
|
||||
await mapApi.createWithTemplate ? await mapApi.createWithTemplate(form.value) : await mapApi.create(form.value)
|
||||
ElMessage.success("模板地图已创建")
|
||||
} else {
|
||||
await mapApi.create(form.value)
|
||||
ElMessage.success("创建成功")
|
||||
}
|
||||
showForm.value = false
|
||||
editingMap.value = null
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error("操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
function editMap(map: any) {
|
||||
editingMap.value = map
|
||||
form.value = { title: map.title, version: map.version }
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function handleClick(path: string) { window.location.href = path }
|
||||
async function deleteMap(map: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${map.title}」?`)
|
||||
await mapApi.delete ? await mapApi.delete(map.id) : ElMessage.warning("当前API不支持删除")
|
||||
ElMessage.success("已删除")
|
||||
load()
|
||||
} catch (e: any) {
|
||||
if (e !== "cancel") ElMessage.error("删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<div class="review-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="review-header">
|
||||
<div class="header-left">
|
||||
<el-button text @click="$router.push('/maps')">← 返回战略地图</el-button>
|
||||
<h3>{{ mapTitle }} · 战略回顾</h3>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadReview">
|
||||
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<el-radio-group v-model="period" size="small">
|
||||
<el-radio-button value="month">本月</el-radio-button>
|
||||
<el-radio-button value="quarter">本季</el-radio-button>
|
||||
<el-radio-button value="year">本年</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-wrap">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<template v-if="!loading && summary">
|
||||
<!-- 总体得分卡片 -->
|
||||
<div class="summary-cards">
|
||||
<div class="s-card overall">
|
||||
<div class="s-card-top">
|
||||
<span class="score">{{ summary.health_score }}%</span>
|
||||
<span class="score-label">整体健康度</span>
|
||||
</div>
|
||||
<div class="s-card-btm">
|
||||
<span class="stat-item green">🟢 {{ summary.green }}个达成</span>
|
||||
<span class="stat-item yellow">🟡 {{ summary.yellow }}个预警</span>
|
||||
<span class="stat-item red">🔴 {{ summary.red }}个异常</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 四维度矩阵 -->
|
||||
<div class="dim-matrix">
|
||||
<div v-for="dim in dimensions" :key="dim.key" class="dim-col" :style="{ borderLeft: '4px solid ' + dim.color }">
|
||||
<div class="dim-head">
|
||||
<span class="dim-icon">{{ dim.icon }}</span>
|
||||
<span class="dim-name">{{ dim.name }}</span>
|
||||
</div>
|
||||
<div class="dim-body">
|
||||
<div v-for="obj in dim.objectives" :key="obj.name"
|
||||
class="obj-row"
|
||||
:class="'obj-' + obj.level"
|
||||
@click="showObjKpis(obj)">
|
||||
<span class="obj-badge">{{ badgeIcon(obj.level) }}</span>
|
||||
<div class="obj-info">
|
||||
<span class="obj-name">{{ obj.name }}</span>
|
||||
<span v-if="obj.has_data" class="obj-ratio">
|
||||
<template v-for="k in obj.kpis.slice(0,2)" :key="k.kpi_code">
|
||||
{{ fmtValue(k.actual_value, k.kpi_code) }}/{{ k.target_value ?? '-' }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🔴 需重点关注 -->
|
||||
<div v-if="focusItems.length > 0" class="focus-section">
|
||||
<h4 class="focus-title">🔴 需重点关注</h4>
|
||||
<div v-for="item in focusItems" :key="item.name" class="focus-card" :class="'focus-' + item.level" @click="showObjKpis(item)">
|
||||
<div class="focus-head">
|
||||
<span class="focus-level">{{ item.level === 'red' ? '紧急' : '预警' }}</span>
|
||||
<span class="focus-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="focus-kpis">
|
||||
<div v-for="k in item.kpis" :key="k.kpi_code" class="fkpi-row">
|
||||
<span class="fkpi-name">{{ k.kpi_name }}</span>
|
||||
<span class="fkpi-val" :style="{ color: k.level === 'red' ? '#f56c6c' : '#e6a23c' }">
|
||||
{{ fmtValue(k.actual_value, k.kpi_code) }} / {{ k.target_value ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 关联的改善行动 -->
|
||||
<div v-if="getPlansByKpi(item.kpis)" class="focus-plans">
|
||||
<div v-for="p in getPlansByKpi(item.kpis)" :key="p.id" class="fplan-row" :class="'plan-' + planStatus(p)">
|
||||
<span class="fplan-status">{{ planIcon(p) }}</span>
|
||||
<span class="fplan-title">{{ p.title }}</span>
|
||||
<span class="fplan-assignee">{{ p.assignee }}</span>
|
||||
<span v-if="p.due_date && p.status !== 'completed'" class="fplan-due">{{ dueText(p) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📋 改善行动进度 -->
|
||||
<div v-if="actionPlans.length > 0" class="plans-section">
|
||||
<h4 class="plans-title">📋 改善行动进度</h4>
|
||||
<div class="plans-stats">
|
||||
<el-tag>{{ pendingCount }} 待处理</el-tag>
|
||||
<el-tag type="warning">{{ inProgressCount }} 进行中</el-tag>
|
||||
<el-tag type="success">{{ doneCount }} 已完成</el-tag>
|
||||
<el-tag type="danger">{{ overdueCount }} 逾期</el-tag>
|
||||
</div>
|
||||
<el-table :data="actionPlans" size="small" style="width:100%" @row-click="(r:any)=>$router.push('/action-plans?kpi='+r.kpi_id)">
|
||||
<el-table-column prop="title" label="行动标题" min-width="200" />
|
||||
<el-table-column prop="assignee" label="负责人" width="120" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="planTagType(row)" size="small">{{ planLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress || 0" :status="row.status==='completed'?'success':undefined" :stroke-width="12" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截止" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: isOverdue(row) ? '#f56c6c' : '#666' }">{{ row.due_date ? row.due_date.slice(0,10) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- KPI详情弹出 -->
|
||||
<el-dialog v-model="showKpiDialog" :title="selectedObjName" width="600">
|
||||
<div v-for="k in selectedKpis" :key="k.kpi_code" class="kpi-detail-row" :class="'kpi-' + k.level">
|
||||
<div class="kpi-detail-head">
|
||||
<span class="kpi-detail-badge">{{ badgeIcon(k.level) }}</span>
|
||||
<span class="kpi-detail-name">{{ k.kpi_name }} ({{ k.kpi_code }})</span>
|
||||
</div>
|
||||
<div class="kpi-detail-body">
|
||||
<div class="kpi-detail-item">
|
||||
<span class="label">实际值</span>
|
||||
<span class="val">{{ fmtValue(k.actual_value, k.kpi_code) }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-item">
|
||||
<span class="label">目标值</span>
|
||||
<span class="val">{{ k.target_value ?? '-' }} {{ k.unit }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-item" v-if="k.actual_value != null && k.target_value">
|
||||
<span class="label">达成率</span>
|
||||
<span class="val" :style="{ color: k.level === 'red' ? '#f56c6c' : 'inherit' }">
|
||||
{{ (k.actual_value / k.target_value * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<el-button size="small" @click="$router.push('/kpis/'+k.kpi_id)" style="margin-top:8px;">查看KPI详情 →</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { ElMessage } from "element-plus"
|
||||
import api from "../api/index"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// ── 状态 ──
|
||||
const maps = ref<any[]>([])
|
||||
const selectedMap = ref<number | null>(null)
|
||||
const mapTitle = ref("")
|
||||
const loading = ref(false)
|
||||
const period = ref("month")
|
||||
const summary = ref<any>(null)
|
||||
const dimensions = ref<any[]>([])
|
||||
const focusItems = ref<any[]>([])
|
||||
const actionPlans = ref<any[]>([])
|
||||
const showKpiDialog = ref(false)
|
||||
const selectedObjName = ref("")
|
||||
const selectedKpis = ref<any[]>([])
|
||||
|
||||
// ── 计算属性 ──
|
||||
const pendingCount = () => actionPlans.value.filter((p:any) => p.status === 'pending').length
|
||||
const inProgressCount = () => actionPlans.value.filter((p:any) => p.status === 'in_progress').length
|
||||
const doneCount = () => actionPlans.value.filter((p:any) => p.status === 'completed').length
|
||||
const overdueCount = () => actionPlans.value.filter((p:any) => p.status !== 'completed' && isOverdue(p)).length
|
||||
|
||||
// ── 加载 ──
|
||||
async function loadReview() {
|
||||
if (!selectedMap.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
|
||||
mapTitle.value = r.title
|
||||
summary.value = r.summary
|
||||
dimensions.value = r.dimensions || []
|
||||
focusItems.value = r.focus_items || []
|
||||
actionPlans.value = r.action_plans || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error("加载战略回顾数据失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具函数 ──
|
||||
function badgeIcon(level: string) {
|
||||
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
|
||||
}
|
||||
|
||||
function fmtValue(val: any, code: string) {
|
||||
if (val == null) return '-'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
function showObjKpis(obj: any) {
|
||||
selectedObjName.value = obj.name
|
||||
selectedKpis.value = obj.kpis || []
|
||||
showKpiDialog.value = true
|
||||
}
|
||||
|
||||
function getPlansByKpi(kpis: any[]) {
|
||||
if (!kpis || !actionPlans.value.length) return null
|
||||
const kpiIds = kpis.map((k:any) => k.kpi_id)
|
||||
return actionPlans.value.filter((p:any) => kpiIds.includes(p.kpi_id))
|
||||
}
|
||||
|
||||
function planStatus(p: any) {
|
||||
if (p.status === 'completed') return 'done'
|
||||
if (isOverdue(p)) return 'overdue'
|
||||
if (p.status === 'in_progress') return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function planIcon(p: any) {
|
||||
if (p.status === 'completed') return '✅'
|
||||
if (isOverdue(p)) return '🔴'
|
||||
if (p.status === 'in_progress') return '🔄'
|
||||
return '⏳'
|
||||
}
|
||||
|
||||
function planLabel(p: any) {
|
||||
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return labels[p.status] || p.status
|
||||
}
|
||||
|
||||
function planTagType(p: any) {
|
||||
if (p.status === 'completed') return 'success'
|
||||
if (isOverdue(p)) return 'danger'
|
||||
if (p.status === 'in_progress') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function isOverdue(p: any) {
|
||||
if (p.status === 'completed' || !p.due_date) return false
|
||||
return new Date(p.due_date) < new Date()
|
||||
}
|
||||
|
||||
function dueText(p: any) {
|
||||
if (!p.due_date) return ''
|
||||
const due = new Date(p.due_date)
|
||||
const now = new Date()
|
||||
const diff = Math.ceil((due.getTime() - now.getTime()) / 86400000)
|
||||
if (diff < 0) return `逾期${Math.abs(diff)}天`
|
||||
if (diff === 0) return '今日截止'
|
||||
return `剩余${diff}天`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await api.get('/maps')
|
||||
maps.value = r.data || []
|
||||
const id = route.params.id ? Number(route.params.id) : (maps.value[0]?.id || null)
|
||||
if (id) {
|
||||
selectedMap.value = id
|
||||
await loadReview()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-shrink: 0; }
|
||||
.review-header .header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.review-header h3 { margin: 0; font-size: 18px; }
|
||||
.review-header .header-right { display: flex; align-items: center; }
|
||||
.loading-wrap { padding: 40px; }
|
||||
.summary-cards { display: flex; gap: 16px; margin-bottom: 20px; }
|
||||
.s-card { padding: 20px; border-radius: 12px; flex: 1; }
|
||||
.s-card.overall { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
||||
.s-card-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
|
||||
.score { font-size: 36px; font-weight: 700; }
|
||||
.score-label { font-size: 14px; opacity: 0.8; }
|
||||
.s-card-btm { display: flex; gap: 16px; font-size: 13px; }
|
||||
.stat-item { opacity: 0.9; }
|
||||
.dim-matrix { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
|
||||
.dim-col { background: #fff; border-radius: 8px; padding: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.dim-head { font-size: 14px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.dim-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.obj-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; cursor: pointer; transition: background 0.2s; font-size: 13px; }
|
||||
.obj-row:hover { background: #f5f7fa; }
|
||||
.obj-red { background: #fef0f0; }
|
||||
.obj-yellow { background: #fdf6ec; }
|
||||
.obj-green { background: #f0f9eb; }
|
||||
.obj-gray { background: #fafafa; }
|
||||
.obj-badge { font-size: 12px; }
|
||||
.obj-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.obj-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.obj-ratio { font-size: 11px; color: #999; }
|
||||
.focus-section { margin-bottom: 24px; }
|
||||
.focus-title { font-size: 16px; margin: 0 0 12px 0; }
|
||||
.focus-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; }
|
||||
.focus-red { border-left: 4px solid #f56c6c; }
|
||||
.focus-yellow { border-left: 4px solid #e6a23c; }
|
||||
.focus-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.focus-level { font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
|
||||
.focus-red .focus-level { background: #fef0f0; color: #f56c6c; }
|
||||
.focus-yellow .focus-level { background: #fdf6ec; color: #e6a23c; }
|
||||
.focus-name { font-weight: 600; font-size: 14px; }
|
||||
.focus-kpis { padding-left: 20px; margin-bottom: 6px; }
|
||||
.fkpi-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
|
||||
.fkpi-name { color: #666; }
|
||||
.fkpi-val { font-weight: 500; }
|
||||
.focus-plans { padding-left: 20px; margin-top: 4px; border-top: 1px solid #f0f0f0; padding-top: 6px; }
|
||||
.fplan-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; }
|
||||
.fplan-status { flex-shrink: 0; }
|
||||
.fplan-title { flex: 1; }
|
||||
.fplan-assignee { color: #999; }
|
||||
.fplan-due { color: #f56c6c; font-size: 11px; }
|
||||
.plans-section { margin-bottom: 24px; }
|
||||
.plans-title { font-size: 16px; margin: 0 0 12px 0; }
|
||||
.plans-stats { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.kpi-detail-row { border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
|
||||
.kpi-red { border-left: 4px solid #f56c6c; }
|
||||
.kpi-yellow { border-left: 4px solid #e6a23c; }
|
||||
.kpi-green { border-left: 4px solid #67c23a; }
|
||||
.kpi-gray { border-left: 4px solid #ccc; }
|
||||
.kpi-detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.kpi-detail-name { font-weight: 600; font-size: 14px; }
|
||||
.kpi-detail-body { padding-left: 24px; }
|
||||
.kpi-detail-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
|
||||
.kpi-detail-item .label { color: #999; width: 60px; }
|
||||
.kpi-detail-item .val { font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="my-dashboard-page">
|
||||
<!-- 顶部问候 -->
|
||||
<div class="dash-header">
|
||||
<div class="header-left">
|
||||
<h3>你好,{{ userName }}</h3>
|
||||
<span class="header-role">{{ roleLabel }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="date-text">📅 {{ todayStr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrap">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
|
||||
<template v-if="!loading">
|
||||
<!-- 待办提醒 -->
|
||||
<div v-if="reminders.length > 0" class="reminder-section">
|
||||
<h4 class="section-title">📌 待办提醒</h4>
|
||||
<div v-for="r in reminders" :key="r.type + r.related_id" class="reminder-item" :class="'sev-' + r.severity">
|
||||
<span class="reminder-icon">{{ r.severity === 'danger' ? '🔴' : '🟡' }}</span>
|
||||
<span class="reminder-msg">{{ r.message }}</span>
|
||||
<el-button size="small" text @click="goToDetail(r)">查看详情 →</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的KPI -->
|
||||
<div class="kpi-section">
|
||||
<h4 class="section-title">📊 我的KPI</h4>
|
||||
<div v-if="kpis.length === 0" class="empty-state">暂无关联到你的KPI,请管理员在KPI字典中设置负责人</div>
|
||||
<div v-else class="kpi-list">
|
||||
<div v-for="k in kpis" :key="k.id" class="kpi-card" :class="'kpi-' + k.level" @click="$router.push('/kpis/' + k.id)">
|
||||
<div class="kpi-card-left">
|
||||
<span class="kpi-dot" :class="'dot-' + k.level"></span>
|
||||
</div>
|
||||
<div class="kpi-card-body">
|
||||
<div class="kpi-card-top">
|
||||
<span class="kpi-name">{{ k.kpi_name }}</span>
|
||||
<span class="kpi-code">{{ k.kpi_code }}</span>
|
||||
</div>
|
||||
<div class="kpi-card-bar">
|
||||
<el-progress
|
||||
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0"
|
||||
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
|
||||
:stroke-width="16"
|
||||
:text-inside="true"
|
||||
:format="() => fmtValue(k.actual_value, k.kpi_code) + ' / ' + (k.target_value ?? '-')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的改善行动 -->
|
||||
<div class="plans-section">
|
||||
<h4 class="section-title">📋 我的改善行动</h4>
|
||||
<div v-if="actionPlans.length === 0" class="empty-state">暂无分配给你的改善行动计划</div>
|
||||
<div v-else class="plans-list">
|
||||
<div v-for="p in actionPlans" :key="p.id" class="plan-card" :class="{ 'plan-overdue': p.overdue }">
|
||||
<div class="plan-card-left">
|
||||
<span class="plan-status-icon">{{ planIcon(p) }}</span>
|
||||
</div>
|
||||
<div class="plan-card-body">
|
||||
<div class="plan-title">{{ p.title }}</div>
|
||||
<div class="plan-meta">
|
||||
<span class="plan-kpi" v-if="p.kpi_name">{{ p.kpi_name }}</span>
|
||||
<el-tag size="small" :type="planTagType(p)" class="plan-status-tag">{{ planLabel(p) }}</el-tag>
|
||||
<span class="plan-due" v-if="p.due_date" :class="{ 'overdue-text': p.overdue }">
|
||||
{{ p.overdue ? '逾期' : '截止' }} {{ p.due_date.slice(0,10) }}
|
||||
</span>
|
||||
</div>
|
||||
<el-progress v-if="p.status !== 'completed'" :percentage="p.progress || 0" :stroke-width="8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import api from "../api/index"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ── 用户信息 ──
|
||||
const userStr = localStorage.getItem('cma_user') || '{}'
|
||||
const user = JSON.parse(userStr)
|
||||
const userName = ref(user.name || user.username || '用户')
|
||||
const roleLabel = ref({
|
||||
ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部'
|
||||
}[user.role] || user.role)
|
||||
|
||||
// ── 状态 ──
|
||||
const loading = ref(true)
|
||||
const kpis = ref<any[]>([])
|
||||
const actionPlans = ref<any[]>([])
|
||||
const reminders = ref<any[]>([])
|
||||
|
||||
const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
|
||||
|
||||
// ── 工具函数 ──
|
||||
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 planIcon(p: any) {
|
||||
const overdue = p.overdue
|
||||
if (p.status === 'completed') return '✅'
|
||||
if (overdue) return '🔴'
|
||||
if (p.status === 'in_progress') return '🔄'
|
||||
return '⏳'
|
||||
}
|
||||
|
||||
function planLabel(p: any) {
|
||||
if (p.overdue) return '逾期'
|
||||
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return labels[p.status] || p.status
|
||||
}
|
||||
|
||||
function planTagType(p: any) {
|
||||
if (p.overdue) return 'danger'
|
||||
if (p.status === 'completed') return 'success'
|
||||
if (p.status === 'in_progress') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function goToDetail(r: any) {
|
||||
if (r.related_type === 'kpi') router.push('/kpis/' + r.related_id)
|
||||
else if (r.related_type === 'action_plan') router.push('/action-plans')
|
||||
}
|
||||
|
||||
// ── 加载数据 ──
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r: any = await api.get('/dashboard/my-dashboard')
|
||||
kpis.value = r.kpis || []
|
||||
actionPlans.value = r.action_plans || []
|
||||
reminders.value = r.reminders || []
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load my dashboard', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-dashboard-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
|
||||
.dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.header-left h3 { margin: 0; font-size: 20px; }
|
||||
.header-role { color: #999; font-size: 14px; }
|
||||
.header-right .date-text { color: #666; font-size: 14px; }
|
||||
.loading-wrap { padding: 40px; }
|
||||
.section-title { font-size: 16px; margin: 0 0 12px 0; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||
.empty-state { text-align: center; color: #999; padding: 30px; }
|
||||
.reminder-section { margin-bottom: 24px; }
|
||||
.reminder-item { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; margin-bottom: 6px; font-size: 14px; }
|
||||
.reminder-item.sev-danger { background: #fef0f0; border: 1px solid #fde2e2; }
|
||||
.reminder-item.sev-warning { background: #fdf6ec; border: 1px solid #f5dab1; }
|
||||
.reminder-icon { flex-shrink: 0; }
|
||||
.reminder-msg { flex: 1; }
|
||||
.kpi-section { margin-bottom: 24px; }
|
||||
.kpi-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.kpi-card { display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 10px; cursor: pointer; transition: box-shadow 0.2s; }
|
||||
.kpi-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
|
||||
.kpi-green { background: #f0f9eb; border: 1px solid #e1f3d8; }
|
||||
.kpi-yellow { background: #fdf6ec; border: 1px solid #f5dab1; }
|
||||
.kpi-red { background: #fef0f0; border: 1px solid #fde2e2; }
|
||||
.kpi-gray { background: #fafafa; border: 1px solid #eee; }
|
||||
.kpi-card-left { flex-shrink: 0; }
|
||||
.kpi-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
|
||||
.dot-green { background: #67c23a; }
|
||||
.dot-yellow { background: #e6a23c; }
|
||||
.dot-red { background: #f56c6c; }
|
||||
.dot-gray { background: #dcdfe6; }
|
||||
.kpi-card-body { flex: 1; min-width: 0; }
|
||||
.kpi-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.kpi-name { font-weight: 600; font-size: 14px; }
|
||||
.kpi-code { font-size: 11px; color: #999; }
|
||||
.kpi-card-bar { margin-top: 4px; }
|
||||
.plans-section { margin-bottom: 24px; }
|
||||
.plans-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.plan-card { display: flex; gap: 12px; padding: 14px; border-radius: 10px; background: #fff; border: 1px solid #eee; }
|
||||
.plan-card.plan-overdue { border-left: 4px solid #f56c6c; background: #fef0f0; }
|
||||
.plan-card-left { flex-shrink: 0; font-size: 18px; padding-top: 2px; }
|
||||
.plan-card-body { flex: 1; min-width: 0; }
|
||||
.plan-title { font-weight: 500; font-size: 14px; margin-bottom: 6px; }
|
||||
.plan-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-bottom: 6px; }
|
||||
.plan-kpi { color: #409eff; }
|
||||
.plan-status-tag { font-size: 11px; }
|
||||
.plan-due { color: #999; }
|
||||
.plan-due.overdue-text { color: #f56c6c; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="guide-page">
|
||||
<div class="guide-header">
|
||||
<h3>🚀 新手引导</h3>
|
||||
<p class="guide-subtitle">5分钟快速上手管理会计OS,开始你的数据驱动管理之旅</p>
|
||||
</div>
|
||||
|
||||
<!-- 快速开始引导 -->
|
||||
<div class="quick-start-card" @click="startOnboarding">
|
||||
<div class="qs-icon">🚀</div>
|
||||
<div class="qs-body">
|
||||
<div class="qs-title">快速开始引导</div>
|
||||
<div class="qs-desc">按步骤带你完成系统配置和首次使用</div>
|
||||
<el-tag size="small" type="danger">推荐新手</el-tag>
|
||||
</div>
|
||||
<el-button type="primary" size="large" class="qs-btn">开始引导</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 流程概览 -->
|
||||
<div class="flow-section">
|
||||
<h4>使用流程</h4>
|
||||
<div class="flow-steps">
|
||||
<div v-for="(step, i) in steps" :key="i" class="flow-step">
|
||||
<div class="flow-num">{{ i + 1 }}</div>
|
||||
<div class="flow-icon">{{ step.icon }}</div>
|
||||
<div class="flow-title">{{ step.title }}</div>
|
||||
<div class="flow-desc">{{ step.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频教程 -->
|
||||
<div class="video-section">
|
||||
<h4>📹 视频教程</h4>
|
||||
<div class="video-grid">
|
||||
<div v-for="(v, i) in videos" :key="i" class="video-card" @click="openVideo(v)">
|
||||
<div class="video-thumb">
|
||||
<span class="play-icon">▶</span>
|
||||
<span class="video-duration">{{ v.duration }}</span>
|
||||
</div>
|
||||
<div class="video-title">{{ v.title }}</div>
|
||||
<div class="video-desc">{{ v.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引导遮罩弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="showWalkthrough" class="walkthrough-overlay" @click="skipWalkthrough">
|
||||
<div class="walkthrough-box" @click.stop>
|
||||
<div class="wt-header">
|
||||
<span class="wt-step">{{ currentStep + 1 }} / {{ walkthroughSteps.length }}</span>
|
||||
<el-button text size="small" @click="skipWalkthrough" style="color:#999;">跳过</el-button>
|
||||
</div>
|
||||
<div class="wt-body">
|
||||
<div class="wt-icon">{{ walkthroughSteps[currentStep].icon }}</div>
|
||||
<div class="wt-title">{{ walkthroughSteps[currentStep].title }}</div>
|
||||
<div class="wt-desc">{{ walkthroughSteps[currentStep].desc }}</div>
|
||||
<div class="wt-action" v-if="walkthroughSteps[currentStep].action">
|
||||
<el-button type="primary" @click="goAction(walkthroughSteps[currentStep].action!)">前往{{ walkthroughSteps[currentStep].actionLabel }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wt-footer">
|
||||
<div class="wt-dots">
|
||||
<span v-for="(_, i) in walkthroughSteps" :key="i" class="wt-dot" :class="{ active: i === currentStep }"></span>
|
||||
</div>
|
||||
<div class="wt-btns">
|
||||
<el-button v-if="currentStep > 0" @click="prevStep">上一步</el-button>
|
||||
<el-button v-if="currentStep < walkthroughSteps.length - 1" type="primary" @click="nextStep">下一步</el-button>
|
||||
<el-button v-else type="success" @click="finishWalkthrough">🎉 完成引导</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { ElMessage } from "element-plus"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ── 流程步骤 ──
|
||||
const steps = [
|
||||
{ icon: "📊", title: "查看驾驶舱", desc: "总览企业KPI整体状况" },
|
||||
{ icon: "🎯", title: "配置KPI", desc: "在KPI字典中设置关键指标" },
|
||||
{ icon: "🗺️", title: "绘制战略地图", desc: "把你的战略目标画出来" },
|
||||
{ icon: "⚡", title: "设定预警", desc: "配置红黄绿灯阈值" },
|
||||
{ icon: "📋", title: "日常管理", desc: "通过工作台持续跟进" },
|
||||
]
|
||||
|
||||
// ── 视频教程 ──
|
||||
const videos = [
|
||||
{ title: "如何设置KPI", desc: "KPI字典的创建、编辑和配置", duration: "3:20", url: "" },
|
||||
{ title: "如何绘制战略地图", desc: "创建目标、关联KPI、画因果连线", duration: "4:15", url: "" },
|
||||
{ title: "如何配置预警", desc: "设置红黄绿灯阈值和通知渠道", duration: "2:50", url: "" },
|
||||
{ title: "如何进行差异分析", desc: "对比实际与预算,找出偏差原因", duration: "3:45", url: "" },
|
||||
{ title: "如何使用战略回顾会", desc: "周会月会的复盘操作方法", duration: "3:00", url: "" },
|
||||
{ title: "如何提交改善行动", desc: "从预警到行动计划的全流程", duration: "2:30", url: "" },
|
||||
]
|
||||
|
||||
// ── 引导步骤 ──
|
||||
const walkthroughSteps = [
|
||||
{
|
||||
icon: "👋",
|
||||
title: "欢迎使用管理会计OS",
|
||||
desc: "这是你的企业管理驾驶舱。我们将带你快速完成首次配置,让你在5分钟内上手。",
|
||||
},
|
||||
{
|
||||
icon: "📊",
|
||||
title: "Step 1: 先看驾驶舱",
|
||||
desc: "驾驶舱是你了解企业整体状况的第一站。这里展示了所有KPI的红黄绿灯状态,以及你的个人KPI。",
|
||||
action: "/dashboard",
|
||||
actionLabel: "驾驶舱",
|
||||
},
|
||||
{
|
||||
icon: "🎯",
|
||||
title: "Step 2: 配置你的KPI",
|
||||
desc: "在KPI字典中管理和配置你的关键绩效指标。你可以新建KPI、设置目标值和预警阈值。",
|
||||
action: "/kpis",
|
||||
actionLabel: "KPI字典",
|
||||
},
|
||||
{
|
||||
icon: "🗺️",
|
||||
title: "Step 3: 绘制战略地图",
|
||||
desc: "使用BSC四维框架,把你的战略目标可视化。在画布上添加目标、关联KPI、画因果连线。",
|
||||
action: "/maps",
|
||||
actionLabel: "战略地图",
|
||||
},
|
||||
{
|
||||
icon: "📋",
|
||||
title: "Step 4: 开始日常管理",
|
||||
desc: "通过「我的工作台」查看个人KPI和改善行动,通过「战略回顾会」进行周月复盘。",
|
||||
action: "/my-dashboard",
|
||||
actionLabel: "我的工作台",
|
||||
},
|
||||
]
|
||||
|
||||
const showWalkthrough = ref(false)
|
||||
const currentStep = ref(0)
|
||||
const FIRST_LOGIN_KEY = "cma_first_login_done"
|
||||
|
||||
// ── 方法 ──
|
||||
function startOnboarding() {
|
||||
currentStep.value = 0
|
||||
showWalkthrough.value = true
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStep.value < walkthroughSteps.length - 1) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
function skipWalkthrough() {
|
||||
showWalkthrough.value = false
|
||||
}
|
||||
|
||||
function finishWalkthrough() {
|
||||
localStorage.setItem(FIRST_LOGIN_KEY, "true")
|
||||
showWalkthrough.value = false
|
||||
ElMessage.success("🎉 引导完成,开始你的管理会计之旅吧!")
|
||||
}
|
||||
|
||||
function goAction(path: string) {
|
||||
router.push(path)
|
||||
showWalkthrough.value = false
|
||||
}
|
||||
|
||||
function openVideo(v: any) {
|
||||
if (v.url) {
|
||||
window.open(v.url, "_blank")
|
||||
} else {
|
||||
ElMessage.info("视频教程正在制作中,敬请期待")
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 检查是否首次登录
|
||||
const done = localStorage.getItem(FIRST_LOGIN_KEY)
|
||||
if (!done) {
|
||||
// 延迟1秒后再弹出,让页面先加载完
|
||||
setTimeout(() => {
|
||||
showWalkthrough.value = true
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guide-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
|
||||
.guide-header { margin-bottom: 24px; }
|
||||
.guide-header h3 { margin: 0; font-size: 20px; }
|
||||
.guide-subtitle { color: #999; margin: 4px 0 0; font-size: 14px; }
|
||||
.quick-start-card { display: flex; align-items: center; gap: 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; cursor: pointer; }
|
||||
.qs-icon { font-size: 40px; }
|
||||
.qs-body { flex: 1; }
|
||||
.qs-title { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
|
||||
.qs-desc { font-size: 13px; opacity: 0.8; margin-bottom: 6px; }
|
||||
.qs-btn { flex-shrink: 0; }
|
||||
.flow-section { margin-bottom: 24px; }
|
||||
.flow-section h4 { margin: 0 0 16px; font-size: 16px; }
|
||||
.flow-steps { display: flex; gap: 12px; }
|
||||
.flow-step { flex: 1; text-align: center; padding: 16px; background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.flow-num { width: 28px; height: 28px; border-radius: 50%; background: #409eff; color: #fff; display: flex; align-items: center; justify-content: center; margin: 0 auto 8px; font-size: 13px; font-weight: 600; }
|
||||
.flow-icon { font-size: 28px; margin-bottom: 6px; }
|
||||
.flow-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||
.flow-desc { font-size: 12px; color: #999; }
|
||||
.video-section { margin-bottom: 24px; }
|
||||
.video-section h4 { margin: 0 0 16px; font-size: 16px; }
|
||||
.video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.video-card { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: transform 0.2s; }
|
||||
.video-card:hover { transform: translateY(-2px); }
|
||||
.video-thumb { position: relative; background: #304156; height: 120px; display: flex; align-items: center; justify-content: center; }
|
||||
.play-icon { font-size: 40px; color: #fff; opacity: 0.8; }
|
||||
.video-duration { position: absolute; bottom: 6px; right: 8px; background: rgba(0,0,0,0.6); color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
||||
.video-title { padding: 10px 12px 2px; font-size: 14px; font-weight: 500; }
|
||||
.video-desc { padding: 0 12px 10px; font-size: 12px; color: #999; }
|
||||
|
||||
/* 引导遮罩 */
|
||||
.walkthrough-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center; }
|
||||
.walkthrough-box { background: #fff; border-radius: 16px; width: 480px; max-width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.2); overflow: hidden; }
|
||||
.wt-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-bottom: 1px solid #f0f0f0; }
|
||||
.wt-step { font-size: 12px; color: #999; }
|
||||
.wt-body { padding: 32px 20px; text-align: center; }
|
||||
.wt-icon { font-size: 56px; margin-bottom: 16px; }
|
||||
.wt-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; }
|
||||
.wt-desc { font-size: 14px; color: #666; line-height: 1.6; margin-bottom: 20px; }
|
||||
.wt-action { margin-top: 8px; }
|
||||
.wt-footer { padding: 12px 20px; border-top: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.wt-dots { display: flex; gap: 6px; }
|
||||
.wt-dot { width: 8px; height: 8px; border-radius: 50%; background: #ddd; transition: background 0.3s; }
|
||||
.wt-dot.active { background: #409eff; }
|
||||
.wt-btns { display: flex; gap: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3>通知配置</h3>
|
||||
<el-button type="primary" @click="showAddDialog = true">添加渠道</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 渠道列表 -->
|
||||
<el-table :data="channels" v-loading="loading" style="width:100%;margin-top:16px;">
|
||||
<el-table-column prop="name" label="名称" width="150" />
|
||||
<el-table-column prop="channel_type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.channel_type === 'wecom' ? 'success' : row.channel_type === 'wecom_app' ? 'warning' : 'primary'">
|
||||
{{ row.channel_type === 'wecom' ? '企业微信' : row.channel_type === 'wecom_app' ? '企微应用' : '邮件' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配置摘要" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.channel_type === 'wecom'" style="font-size:12px;color:#999;">
|
||||
Webhook: {{ (row.config?.webhook_url || '').substring(0, 40) }}...
|
||||
</span>
|
||||
<span v-else-if="row.channel_type === 'mail'" style="font-size:12px;color:#999;">
|
||||
收件人: {{ (row.config?.to || []).join(', ') }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.enabled" @change="toggleChannel(row)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="testChannel(row)">测试</el-button>
|
||||
<el-button size="small" type="danger" plain @click="deleteChannel(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && channels.length === 0" description="暂无通知渠道,请添加" />
|
||||
|
||||
<!-- 通知日志 -->
|
||||
<h3 style="margin-top:30px;">发送记录</h3>
|
||||
<el-table :data="logs" style="width:100%;margin-top:8px;" size="small">
|
||||
<el-table-column prop="created_at" label="时间" width="160" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" />
|
||||
<el-table-column prop="channel" label="渠道" width="80" />
|
||||
<el-table-column prop="recipient" label="接收方" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'sent' ? 'success' : 'danger'" size="small">{{ row.status === 'sent' ? '成功' : '失败' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 添加渠道对话框 -->
|
||||
<MyDialog v-model="showAddDialog" title="添加通知渠道" :width="500">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" placeholder="如:管理群、财务部邮箱" /></el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.channel_type" style="width:100%;">
|
||||
<el-option value="wecom" label="企业微信群机器人" />
|
||||
<el-option value="wecom_app" label="企微应用消息" />
|
||||
<el-option value="mail" label="邮件" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.channel_type === 'wecom'" label="Webhook地址">
|
||||
<el-input v-model="form.webhook_url" type="textarea" rows="2" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" />
|
||||
</el-form-item>
|
||||
<template v-if="form.channel_type === 'wecom_app'">
|
||||
<el-form-item label="CorpID"><el-input v-model="form.corp_id" placeholder="企业ID,如 wx..." /></el-form-item>
|
||||
<el-form-item label="CorpSecret"><el-input v-model="form.corp_secret" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="AgentID"><el-input v-model="form.agent_id" placeholder="应用AgentID,如 1000020" /></el-form-item>
|
||||
<el-form-item label="接收人UserID"><el-input v-model="form.touser" placeholder="企微成员UserID,多个用 | 分隔" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="form.channel_type === 'mail'">
|
||||
<el-form-item label="SMTP地址"><el-input v-model="form.smtp_host" placeholder="smtp.qq.com" /></el-form-item>
|
||||
<el-form-item label="端口"><el-input v-model="form.smtp_port" placeholder="465" /></el-form-item>
|
||||
<el-form-item label="账号"><el-input v-model="form.smtp_user" placeholder="xxx@qq.com" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.smtp_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="收件人"><el-input v-model="form.mail_to" placeholder="用逗号分隔多个邮箱" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveChannel" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { notificationApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const channels = ref<any[]>([])
|
||||
const logs = ref<any[]>([])
|
||||
const showAddDialog = ref(false)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
channel_type: 'wecom',
|
||||
webhook_url: '',
|
||||
corp_id: '',
|
||||
corp_secret: '',
|
||||
agent_id: '',
|
||||
touser: '',
|
||||
smtp_host: 'smtp.qq.com',
|
||||
smtp_port: '465',
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
mail_to: '',
|
||||
})
|
||||
|
||||
async function loadChannels() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await notificationApi.list()
|
||||
channels.value = r.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const r: any = await notificationApi.logs()
|
||||
logs.value = r.data || []
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function saveChannel() {
|
||||
saving.value = true
|
||||
try {
|
||||
const config: any = {}
|
||||
if (form.channel_type === 'wecom') {
|
||||
config.webhook_url = form.webhook_url
|
||||
} else if (form.channel_type === 'wecom_app') {
|
||||
config.corp_id = form.corp_id
|
||||
config.corp_secret = form.corp_secret
|
||||
config.agent_id = form.agent_id
|
||||
config.touser = form.touser
|
||||
} else {
|
||||
config.host = form.smtp_host
|
||||
config.port = form.smtp_port
|
||||
config.user = form.smtp_user
|
||||
config.password = form.smtp_password
|
||||
config.from_addr = form.smtp_user
|
||||
config.to = form.mail_to.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
}
|
||||
await notificationApi.create({
|
||||
name: form.name,
|
||||
channel_type: form.channel_type,
|
||||
config,
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
showAddDialog.value = false
|
||||
loadChannels()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function toggleChannel(row: any) {
|
||||
try {
|
||||
await notificationApi.update(row.id, { enabled: !row.enabled })
|
||||
row.enabled = !row.enabled
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function testChannel(row: any) {
|
||||
try {
|
||||
const r: any = await notificationApi.test(row.id)
|
||||
const result = r.results?.[0]
|
||||
if (result?.success) {
|
||||
ElMessage.success(result.message || '测试推送成功')
|
||||
} else {
|
||||
ElMessage.warning(result?.message || '推送失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error('测试异常')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChannel(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此通知渠道?', '确认')
|
||||
await notificationApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadChannels()
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
onMounted(() => { loadChannels(); loadLogs() })
|
||||
</script>
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="org-page">
|
||||
<!-- 顶部统计栏 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card"><el-icon><Collection /></el-icon><div><span class="num">{{ stats.total }}</span><span class="lbl">总节点</span></div></div>
|
||||
<div class="stat-card"><el-icon><TrendCharts /></el-icon><div><span class="num">{{ stats.levels }}</span><span class="lbl">层级深度</span></div></div>
|
||||
<div class="stat-card" style="color:#67c23a;"><el-icon><CircleCheckFilled /></el-icon><div><span class="num">{{ stats.enabled }}</span><span class="lbl">已启用</span></div></div>
|
||||
<div class="stat-card" style="color:#909399;"><el-icon><RemoveFilled /></el-icon><div><span class="num">{{ stats.disabled }}</span><span class="lbl">已禁用</span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="org-body">
|
||||
<!-- 左侧树 -->
|
||||
<div class="tree-panel">
|
||||
<div class="panel-head">
|
||||
<span>组织架构树</span>
|
||||
<el-button size="small" type="primary" @click="addRoot">+ 根节点</el-button>
|
||||
</div>
|
||||
<el-input v-model="searchText" placeholder="搜索节点..." prefix-icon="Search" size="small" class="search-box" clearable />
|
||||
<div class="tree-wrap">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:highlight-current="true"
|
||||
:filter-node-method="filterNode"
|
||||
draggable
|
||||
@node-click="onNodeClick"
|
||||
@node-drag-end="onDragEnd"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node">
|
||||
<el-icon v-if="data.level === 1" :size="16" color="#e6a23c"><HomeFilled /></el-icon>
|
||||
<el-icon v-else-if="data.level === 2" :size="16" color="#409eff"><Connection /></el-icon>
|
||||
<el-icon v-else :size="16" color="#67c23a"><FolderOpened /></el-icon>
|
||||
<span class="node-label">{{ data.label }}</span>
|
||||
<el-tag v-if="!data.enabled" size="small" type="info" effect="dark" class="node-tag">停用</el-tag>
|
||||
<span class="level-tag">L{{ data.level }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情 -->
|
||||
<div class="detail-panel">
|
||||
<template v-if="selectedNode">
|
||||
<div class="detail-head">
|
||||
<h3>
|
||||
<el-icon v-if="selectedNode.level === 1" color="#e6a23c"><HomeFilled /></el-icon>
|
||||
<el-icon v-else-if="selectedNode.level === 2" color="#409eff"><Connection /></el-icon>
|
||||
<el-icon v-else color="#67c23a"><FolderOpened /></el-icon>
|
||||
{{ selectedNode.label }}
|
||||
</h3>
|
||||
<el-tag :type="selectedNode.enabled ? 'success' : 'info'" effect="dark" size="small">
|
||||
{{ selectedNode.enabled ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border size="small" class="node-info">
|
||||
<el-descriptions-item label="层级">
|
||||
<el-tag size="small" round>{{ levelLabels[selectedNode.level] || '未知' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="编码">{{ selectedNode.code || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="排序">{{ selectedNode.sort_order || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ selectedNode.remark || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="子节点数">
|
||||
<el-tag size="small" type="primary" round>{{ childCount }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="detail-actions">
|
||||
<el-button type="primary" @click="addChild(selectedNode)"><el-icon><Plus /></el-icon>新增子节点</el-button>
|
||||
<el-button @click="editNode(selectedNode)"><el-icon><Edit /></el-icon>编辑</el-button>
|
||||
<el-button :type="selectedNode.enabled ? 'warning' : 'success'" @click="toggleNode(selectedNode)">
|
||||
<el-icon>{{ selectedNode.enabled ? 'VideoPause' : 'VideoPlay' }}</el-icon>
|
||||
{{ selectedNode.enabled ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button type="danger" :disabled="hasChildren" @click="deleteNode(selectedNode)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="请选择左侧组织节点" :image-size="120" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="showDialog" :title="isEdit ? '编辑节点' : '新增节点'" width="480" destroy-on-close>
|
||||
<el-form :model="form" label-width="90px" size="small">
|
||||
<el-form-item label="节点名称">
|
||||
<el-input v-model="form.name" placeholder="输入节点名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码">
|
||||
<el-input v-model="form.code" placeholder="可选编码,如 HQ-FIN" />
|
||||
</el-form-item>
|
||||
<el-form-item label="层级" v-if="!isEdit">
|
||||
<el-select v-model="form.level" :disabled="!!form.parent_id" style="width:100%">
|
||||
<el-option :value="1" label="1-集团" />
|
||||
<el-option :value="2" label="2-事业部" />
|
||||
<el-option :value="3" label="3-区域" />
|
||||
<el-option :value="4" label="4-部门" />
|
||||
<el-option :value="5" label="5-班组" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" :min="0" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveNode" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import {
|
||||
Collection, TrendCharts, CircleCheckFilled, RemoveFilled,
|
||||
HomeFilled, Connection, FolderOpened,
|
||||
Search, Plus, Edit, Delete,
|
||||
} from "@element-plus/icons-vue"
|
||||
import api from "../api/index"
|
||||
|
||||
const treeData = ref<any[]>([])
|
||||
const treeRef = ref<any>(null)
|
||||
const searchText = ref("")
|
||||
const selectedNode = ref<any>(null)
|
||||
const showDialog = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
const form = ref<any>({ name: "", code: "", level: 1, sort_order: 0, parent_id: null, remark: "" })
|
||||
|
||||
const levelLabels: Record<number, string> = { 1: "集团", 2: "事业部", 3: "区域", 4: "部门", 5: "班组" }
|
||||
|
||||
// 统计
|
||||
const stats = computed(() => {
|
||||
let total = 0, levels = 0, enabled = 0, disabled = 0
|
||||
function walk(nodes: any[]) {
|
||||
for (const n of nodes) {
|
||||
total++
|
||||
levels = Math.max(levels, n.level || 1)
|
||||
if (n.enabled) enabled++; else disabled++
|
||||
if (n.children) walk(n.children)
|
||||
}
|
||||
}
|
||||
walk(treeData.value)
|
||||
return { total, levels, enabled, disabled }
|
||||
})
|
||||
|
||||
const childCount = computed(() => {
|
||||
if (!selectedNode.value) return 0
|
||||
return selectedNode.value.children?.length || 0
|
||||
})
|
||||
|
||||
const hasChildren = computed(() => childCount.value > 0)
|
||||
|
||||
// 搜索过滤
|
||||
watch(searchText, (val) => {
|
||||
treeRef.value?.filter(val)
|
||||
})
|
||||
|
||||
function filterNode(value: string, data: any) {
|
||||
if (!value) return true
|
||||
return data.label.toLowerCase().includes(value.toLowerCase())
|
||||
}
|
||||
|
||||
// API
|
||||
async function loadTree() {
|
||||
try {
|
||||
const r: any = await api.get("/org/tree")
|
||||
treeData.value = r.data || []
|
||||
} catch { ElMessage.error("加载组织树失败") }
|
||||
}
|
||||
|
||||
function onNodeClick(data: any) {
|
||||
selectedNode.value = data
|
||||
}
|
||||
|
||||
function addRoot() {
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
form.value = { name: "", code: "", level: 1, sort_order: 0, parent_id: null, remark: "" }
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function addChild(data: any) {
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
const newLevel = Math.min(data.level + 1, 5)
|
||||
form.value = { name: "", code: "", level: newLevel, sort_order: 0, parent_id: data.id, remark: "" }
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function editNode(data: any) {
|
||||
isEdit.value = true
|
||||
editingId.value = data.id
|
||||
form.value = {
|
||||
name: data.label,
|
||||
code: data.code || "",
|
||||
level: data.level,
|
||||
sort_order: data.sort_order || 0,
|
||||
parent_id: null,
|
||||
remark: data.remark || "",
|
||||
}
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function saveNode() {
|
||||
if (!form.value.name) { ElMessage.warning("请输入名称"); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value && editingId.value) {
|
||||
await api.put(`/org/nodes/${editingId.value}`, form.value)
|
||||
ElMessage.success("已更新")
|
||||
} else {
|
||||
await api.post("/org/nodes", form.value)
|
||||
ElMessage.success("已创建")
|
||||
}
|
||||
showDialog.value = false
|
||||
await loadTree()
|
||||
// 如果编辑的是当前选中节点,刷新选中
|
||||
if (isEdit.value && editingId.value === selectedNode.value?.id) {
|
||||
const r: any = await api.get("/org/nodes")
|
||||
const found = r.data?.find((n: any) => n.id === editingId.value)
|
||||
if (found) selectedNode.value = { ...found, label: found.name, children: selectedNode.value?.children || [] }
|
||||
}
|
||||
} catch { ElMessage.error("操作失败") }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function toggleNode(data: any) {
|
||||
try {
|
||||
await api.put(`/org/nodes/${data.id}/toggle`)
|
||||
ElMessage.success(data.enabled ? "已禁用" : "已启用")
|
||||
await loadTree()
|
||||
// 刷新选中(节点启用状态变了)
|
||||
if (selectedNode.value?.id === data.id) {
|
||||
selectedNode.value.enabled = !data.enabled
|
||||
}
|
||||
} catch { ElMessage.error("操作失败") }
|
||||
}
|
||||
|
||||
async function deleteNode(data: any) {
|
||||
if (hasChildren.value) { ElMessage.warning("请先删除子节点"); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${data.label}」?`)
|
||||
await api.delete(`/org/nodes/${data.id}`)
|
||||
ElMessage.success("已删除")
|
||||
selectedNode.value = null
|
||||
await loadTree()
|
||||
} catch (e: any) {
|
||||
if (e !== "cancel") ElMessage.error("删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() { loadTree() }
|
||||
|
||||
onMounted(loadTree)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.org-page { padding: 16px; height: calc(100vh - 80px); display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* 统计栏 */
|
||||
.stats-row { display: flex; gap: 12px; }
|
||||
.stat-card {
|
||||
flex: 1; display: flex; align-items: center; gap: 8px;
|
||||
background: #fff; border-radius: 8px; padding: 14px 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
}
|
||||
.stat-card .el-icon { font-size: 28px; }
|
||||
.stat-card .num { display: block; font-size: 22px; font-weight: 700; line-height: 1.2; }
|
||||
.stat-card .lbl { font-size: 12px; color: #909399; }
|
||||
|
||||
/* 主体 */
|
||||
.org-body { display: flex; gap: 12px; flex: 1; min-height: 0; }
|
||||
|
||||
.tree-panel {
|
||||
width: 320px; background: #fff; border-radius: 8px;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 14px; border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.search-box { margin: 8px 10px; }
|
||||
.tree-wrap { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
|
||||
.detail-panel {
|
||||
flex: 1; background: #fff; border-radius: 8px;
|
||||
padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.detail-head h3 { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 16px; }
|
||||
.detail-head h3 .el-icon { font-size: 20px; }
|
||||
.node-info { margin-bottom: 16px; }
|
||||
|
||||
.detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 16px; }
|
||||
|
||||
/* 树节点样式 */
|
||||
.custom-tree-node { flex: 1; display: flex; align-items: center; gap: 4px; font-size: 13px; overflow: hidden; }
|
||||
.custom-tree-node .node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.custom-tree-node .node-tag { transform: scale(0.85); margin-left: 4px; }
|
||||
.level-tag {
|
||||
font-size: 10px; color: #909399; background: #f5f7fa;
|
||||
border-radius: 4px; padding: 0 4px; margin-left: 4px;
|
||||
line-height: 16px;
|
||||
}
|
||||
</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,328 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>系统设置</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- Tab 1: 角色权限 -->
|
||||
<el-tab-pane label="角色权限" name="permissions">
|
||||
<p style="color:#999;font-size:13px;margin-top:4px;">勾选每个角色可访问的模块和可执行的操作,修改后实时生效。</p>
|
||||
|
||||
<el-tabs v-model="permTab">
|
||||
<el-tab-pane label="模块权限" name="routes">
|
||||
<el-table :data="routeTable" border style="width:100%;margin-top:12px;">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="moduleName" label="模块" width="150" />
|
||||
<el-table-column v-for="role in roles" :key="role.code" :label="role.name" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox :model-value="row[role.code]" @change="(v: boolean) => toggleRoute(role.code, row.moduleKey, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="操作权限" name="actions">
|
||||
<el-table :data="actionTable" border style="width:100%;margin-top:12px;">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="actionName" label="操作" width="150" />
|
||||
<el-table-column v-for="role in roles" :key="role.code" :label="role.name" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox :model-value="row[role.code]" @change="(v: boolean) => toggleAction(role.code, row.actionKey, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div style="margin-top:20px;display:flex;gap:12px;">
|
||||
<el-button type="primary" @click="savePermConfig" :loading="saving">保存配置</el-button>
|
||||
<el-button @click="resetPermConfig">恢复默认</el-button>
|
||||
<span v-if="permSaved" style="color:#67c23a;font-size:13px;line-height:32px;">✓ 已保存</span>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: KPI目标对齐 -->
|
||||
<el-tab-pane label="目标对齐" name="alignment">
|
||||
<p style="color:#999;font-size:13px;margin-top:4px;">
|
||||
{{ showSelector ? '选择一个对齐模式' : '当前模式:' + (currentMode?.name || '') }}
|
||||
<el-button v-if="!showSelector" text type="primary" size="small" @click="showSelector = true" style="margin-left:12px;">[ 重新选择 ]</el-button>
|
||||
</p>
|
||||
|
||||
<!-- 选择器卡片 -->
|
||||
<div v-if="showSelector" style="margin-top:16px;">
|
||||
<div v-if="!alignConfigured" style="margin-bottom:16px;">
|
||||
<el-alert title="系统尚未配置KPI目标对齐模式" type="warning" :closable="false" show-icon />
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
|
||||
<el-card v-for="mode in alignmentModes" :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" style="margin-top:8px;">示例</el-tag>
|
||||
<p style="font-size:12px;color:#999;margin-top:4px;background:#f5f7fa;padding:8px;border-radius:4px;">{{ mode.example }}</p>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;display:flex;gap:12px;">
|
||||
<el-button type="primary" size="large" @click="saveAlignmentConfig" :loading="savingAlign" :disabled="!selectedMode">
|
||||
确定并启用「{{ selectedModeName }}」模式
|
||||
</el-button>
|
||||
<el-button v-if="alignConfigured" @click="showSelector = false; selectedMode = ''">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已配置详情 -->
|
||||
<div v-else style="margin-top:20px;">
|
||||
<el-alert
|
||||
:title="'当前模式:' + (currentMode?.name || '')"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom:20px;"
|
||||
/>
|
||||
|
||||
<div style="background:#f0f9eb;border-radius:8px;padding:20px;margin-bottom:20px;">
|
||||
<h4 style="margin:0 0 12px 0;">模式说明</h4>
|
||||
<p style="font-size:14px;color:#555;line-height:1.8;">{{ currentMode?.description || '' }}</p>
|
||||
</div>
|
||||
|
||||
<el-button @click="previewTree" :loading="loadingTree" style="margin-bottom:12px;">
|
||||
预览KPI对齐关系图
|
||||
</el-button>
|
||||
|
||||
<div v-if="treeData" style="border:1px solid #e4e7ed;border-radius:8px;padding:16px;max-height:500px;overflow-y:auto;">
|
||||
<h4 style="margin:0 0 12px 0;">KPI对齐关系({{ treeData.mode_name }})</h4>
|
||||
<el-tree
|
||||
:data="treeData.tree || []"
|
||||
:props="{ label: 'kpi_name', children: 'children' }"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span style="display:flex;align-items:center;gap:6px;">
|
||||
<template v-if="data.is_dimension_group">
|
||||
<el-tag type="primary" size="small">{{ data.kpi_name }}</el-tag>
|
||||
<span style="font-size:12px;color:#999;">{{ data.child_count }}个KPI</span>
|
||||
<span v-if="data.description" style="font-size:11px;color:#909399;">— {{ data.description }}</span>
|
||||
</template>
|
||||
<template v-else-if="data.is_category_group">
|
||||
<el-tag type="success" size="small">{{ data.category_label }}</el-tag>
|
||||
<span style="font-size:12px;color:#999;">{{ data.child_count }}个KPI</span>
|
||||
<span v-if="data.feeds" style="font-size:11px;color:#909399;">→ 支撑 {{ feedsLabel(data.feeds) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="font-size:13px;">{{ data.kpi_code }}</span>
|
||||
<span>{{ data.kpi_name }}</span>
|
||||
<span v-if="data.alignment_type === 'vertical_split'" style="font-size:11px;color:#e6a23c;">
|
||||
承接自 {{ data.parent_code }}
|
||||
</span>
|
||||
<span v-if="data.drives" style="font-size:11px;color:#909399;">
|
||||
→ 驱动: {{ data.drives === 'internal_process' ? '内部流程' : data.drives === 'customer' ? '客户' : data.drives === 'finance' ? '财务' : data.drives }}
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// ── Tab 控制 ──
|
||||
const activeTab = ref('permissions')
|
||||
const permTab = ref('routes')
|
||||
|
||||
// Tab 切换时主动关闭可能残留的弹窗遮罩
|
||||
function onTabChange() {
|
||||
// 关闭所有弹窗遮罩
|
||||
document.querySelectorAll('.el-overlay').forEach(el => {
|
||||
if (el && el.parentNode) {
|
||||
el.parentNode.removeChild(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// ── 权限配置 ──
|
||||
const roles = ref<any[]>([])
|
||||
const modules = ref<any[]>([])
|
||||
const actions = ref<any[]>([])
|
||||
const routePerms = ref<Record<string, string[]>>({})
|
||||
const actionPerms = ref<Record<string, string[]>>({})
|
||||
const saving = ref(false)
|
||||
const permSaved = ref(false)
|
||||
const routeTable = ref<any[]>([])
|
||||
const actionTable = ref<any[]>([])
|
||||
|
||||
function buildTables() {
|
||||
routeTable.value = modules.value.map(mod => {
|
||||
const row: any = { moduleKey: mod.key, moduleName: mod.name }
|
||||
roles.value.forEach(r => { row[r.code] = (routePerms.value[r.code] || []).includes(mod.key) })
|
||||
return row
|
||||
})
|
||||
actionTable.value = actions.value.map(act => {
|
||||
const row: any = { actionKey: act.key, actionName: act.name }
|
||||
roles.value.forEach(r => { row[r.code] = (actionPerms.value[r.code] || []).includes(act.key) })
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
function toggleRoute(role: string, moduleKey: string, value: boolean) {
|
||||
const list = routePerms.value[role] || []
|
||||
if (value) { if (!list.includes(moduleKey)) list.push(moduleKey) }
|
||||
else { routePerms.value[role] = list.filter(k => k !== moduleKey) }
|
||||
permSaved.value = false
|
||||
buildTables()
|
||||
}
|
||||
|
||||
function toggleAction(role: string, actionKey: string, value: boolean) {
|
||||
const list = actionPerms.value[role] || []
|
||||
if (value) { if (!list.includes(actionKey)) list.push(actionKey) }
|
||||
else { actionPerms.value[role] = list.filter(k => k !== actionKey) }
|
||||
permSaved.value = false
|
||||
buildTables()
|
||||
}
|
||||
|
||||
async function savePermConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
await fetch('/api/cma/permissions/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
route_permissions: routePerms.value,
|
||||
action_permissions: actionPerms.value,
|
||||
}),
|
||||
})
|
||||
ElMessage.success('权限配置已保存')
|
||||
permSaved.value = true
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function resetPermConfig() {
|
||||
try { await ElMessageBox.confirm('确定恢复默认权限配置?', '确认', { center: true }) } catch { return }
|
||||
const token = localStorage.getItem('cma_token')
|
||||
try {
|
||||
await fetch('/api/cma/permissions/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
ElMessage.success('已恢复默认')
|
||||
loadPermData()
|
||||
} catch { ElMessage.error('恢复失败') }
|
||||
}
|
||||
|
||||
async function loadPermData() {
|
||||
try {
|
||||
const [modRes, cfgRes] = await Promise.all([
|
||||
fetch('/api/cma/permissions/modules').then(r => r.json()),
|
||||
fetch('/api/cma/permissions/config').then(r => r.json()),
|
||||
])
|
||||
modules.value = modRes.modules || []
|
||||
actions.value = modRes.actions || []
|
||||
roles.value = modRes.roles || []
|
||||
routePerms.value = cfgRes.route_permissions || {}
|
||||
actionPerms.value = cfgRes.action_permissions || {}
|
||||
buildTables()
|
||||
} catch { ElMessage.error('加载权限配置失败') }
|
||||
}
|
||||
|
||||
// ── KPI目标对齐 ──
|
||||
const alignmentModes = ref<any[]>([])
|
||||
const alignConfigured = ref(false)
|
||||
const currentMode = ref<any>(null)
|
||||
const selectedMode = ref('')
|
||||
const savingAlign = ref(false)
|
||||
const loadingTree = ref(false)
|
||||
const treeData = ref<any>(null)
|
||||
const showSelector = ref(false) // 是否显示选择器卡片
|
||||
|
||||
const selectedModeName = computed(() => {
|
||||
const m = alignmentModes.value.find(m => m.key === selectedMode.value)
|
||||
return m ? m.name : ''
|
||||
})
|
||||
|
||||
function feedsLabel(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('、')
|
||||
}
|
||||
|
||||
async function loadAlignmentConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/cma/alignment/config').then(r => r.json())
|
||||
alignmentModes.value = r.modes || []
|
||||
alignConfigured.value = r.configured || false
|
||||
currentMode.value = null
|
||||
if (r.configured && r.mode) {
|
||||
currentMode.value = alignmentModes.value.find(m => m.key === r.mode.mode) || null
|
||||
}
|
||||
// 未配置时,直接显示选择器
|
||||
showSelector.value = !r.configured
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function saveAlignmentConfig() {
|
||||
if (!selectedMode.value) return
|
||||
savingAlign.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const r = 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(r.message || '对齐模式已设置')
|
||||
loadAlignmentConfig()
|
||||
showSelector.value = false
|
||||
} catch { ElMessage.error('设置失败') }
|
||||
savingAlign.value = false
|
||||
}
|
||||
|
||||
async function previewTree() {
|
||||
loadingTree.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const r = await fetch('/api/cma/alignment/tree', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}).then(r => r.json())
|
||||
treeData.value = r
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '加载失败')
|
||||
}
|
||||
loadingTree.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPermData()
|
||||
loadAlignmentConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3>用户管理</h3>
|
||||
<el-button type="primary" @click="openForm(null)">新建用户</el-button>
|
||||
</div>
|
||||
<el-table :data="users" v-loading="loading" style="width:100%" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="roleType(row.role)" size="small">{{ roleLabel(row.role) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openForm(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<MyDialog v-model="showForm" :title="isEdit ? '编辑用户' : '新建用户'" :width="480">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="isEdit ? '新密码' : '密码'">
|
||||
<el-input v-model="form.password" type="password" show-password
|
||||
:placeholder="isEdit ? '留空则不修改' : '请输入密码'" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width:100%">
|
||||
<el-option label="CEO" value="ceo" />
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="业务" value="business" />
|
||||
<el-option label="IT" value="it" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showForm=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveUser">保存</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const users = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showForm = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const form = ref<any>({})
|
||||
|
||||
function roleLabel(role: string) {
|
||||
const map: Record<string, string> = { ceo: 'CEO', finance: '财务', business: '业务', it: 'IT管理' }
|
||||
return map[role] || role
|
||||
}
|
||||
function roleType(role: string) {
|
||||
const map: Record<string, string> = { ceo: 'danger', finance: 'success', business: 'warning', it: 'info' }
|
||||
return map[role] || ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { const r: any = await userApi.list(); users.value = r.data || [] } catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function openForm(row: any) {
|
||||
if (row) {
|
||||
isEdit.value = true
|
||||
form.value = { ...row, password: '' }
|
||||
} else {
|
||||
isEdit.value = false
|
||||
form.value = { username: '', name: '', password: '', role: 'business', phone: '' }
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const payload: any = {}
|
||||
if (form.value.name) payload.name = form.value.name
|
||||
if (form.value.password) payload.password = form.value.password
|
||||
if (form.value.role) payload.role = form.value.role
|
||||
if (form.value.phone !== undefined) payload.phone = form.value.phone
|
||||
await userApi.update(form.value.id, payload)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
ElMessage.warning('用户名和密码必填')
|
||||
return
|
||||
}
|
||||
await userApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
showForm.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除用户"${row.name}"?`, '确认')
|
||||
await userApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user