feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<div class="kpi-list-view">
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<h3>
|
||||
<span class="dim-icon">{{ icon }}</span>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="header-actions">
|
||||
<el-radio-group v-model="periodType" size="small" @change="loadData">
|
||||
<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>
|
||||
<el-dropdown v-if="templates && templates.length > 0" trigger="click" @command="selectTemplate">
|
||||
<el-button size="small" type="success">
|
||||
<el-icon><Plus /></el-icon> 从模板创建
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="(t, i) in templates" :key="t.name"
|
||||
:command="t"
|
||||
:divided="i > 0 && t.category !== templates[i-1]?.category"
|
||||
>
|
||||
<span class="template-dropdown-item">
|
||||
<span
|
||||
v-if="t.category"
|
||||
class="template-cat-tag"
|
||||
:style="{ background: categoryColor(t.category) }"
|
||||
>{{ t.category }}</span>
|
||||
<span>{{ t.name }}</span>
|
||||
</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button type="primary" size="small" @click="openCreate">
|
||||
+ 新建指标
|
||||
</el-button>
|
||||
<el-button size="small" @click="loadData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 摘要卡片行 -->
|
||||
<div class="summary-row">
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-val">{{ kpis.length }}</div>
|
||||
<div class="stat-label">{{ statLabels.total }}</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="stat-val">{{ stats.green }}</div>
|
||||
<div class="stat-label">{{ statLabels.green }}</div>
|
||||
</div>
|
||||
<div class="stat-card yellow">
|
||||
<div class="stat-val">{{ stats.yellow }}</div>
|
||||
<div class="stat-label">{{ statLabels.yellow }}</div>
|
||||
</div>
|
||||
<div class="stat-card red">
|
||||
<div class="stat-val">{{ stats.red }}</div>
|
||||
<div class="stat-label">{{ statLabels.red }}</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background:#f0f5ff;">
|
||||
<div class="stat-val">{{ stats.noData }}</div>
|
||||
<div class="stat-label">{{ statLabels.noData }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="kpis.length === 0" class="empty-state">
|
||||
<el-empty :description="'暂无' + statLabels.total + ',' + (templates && templates.length > 0 ? '点击「从模板创建」快速添加预设指标' : '点击「+ 新建指标」创建')">
|
||||
<el-button type="primary" @click="openCreate">新建指标</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 分类卡片网格 -->
|
||||
<template v-else>
|
||||
<template v-if="hasCategories">
|
||||
<div v-for="cat in categories" :key="cat.key" class="capital-section">
|
||||
<div class="capital-title" :style="{ color: cat.color || '#409EFF' }">
|
||||
{{ cat.icon || '📋' }} {{ cat.label }}
|
||||
<span class="capital-subtitle" v-if="cat.subtitle">{{ cat.subtitle }}</span>
|
||||
</div>
|
||||
<div class="kpi-card-grid">
|
||||
<div
|
||||
v-for="k in kpisByCategory(cat.key)" :key="k.id"
|
||||
class="kpi-card"
|
||||
:class="'card-level-' + (k.alert_level || 'none')"
|
||||
@click="showKPIDetail(k)"
|
||||
>
|
||||
<div class="card-head">
|
||||
<span class="card-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="alertTagType(k.alert_level)" effect="dark">
|
||||
{{ alertLevelIcon(k.alert_level) }} {{ alertLevelLabel(k.alert_level) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-value" :style="{ color: alertColor(k.alert_level) }">
|
||||
<span class="val-num">{{ fmtValue(k.actual_value) }}</span>
|
||||
<span class="val-unit">{{ k.unit }}</span>
|
||||
</div>
|
||||
<div class="card-target">目标值:<strong>{{ k.target_value ?? '—' }}</strong></div>
|
||||
<div class="card-progress" v-if="k.target_value && k.actual_value != null">
|
||||
<el-progress
|
||||
:percentage="Math.min(Math.round((k.actual_value / k.target_value) * 100), 100)"
|
||||
:stroke-width="6" :color="alertColor(k.alert_level)" size="small"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span v-if="k.responsible_user">👤 {{ k.responsible_user }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="kpi-card-grid">
|
||||
<div
|
||||
v-for="k in kpis" :key="k.id"
|
||||
class="kpi-card"
|
||||
:class="'card-level-' + (k.alert_level || 'none')"
|
||||
@click="showKPIDetail(k)"
|
||||
>
|
||||
<div class="card-head">
|
||||
<span class="card-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="alertTagType(k.alert_level)" effect="dark">
|
||||
{{ alertLevelIcon(k.alert_level) }} {{ alertLevelLabel(k.alert_level) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-value" :style="{ color: alertColor(k.alert_level) }">
|
||||
<span class="val-num">{{ fmtValue(k.actual_value) }}</span>
|
||||
<span class="val-unit">{{ k.unit }}</span>
|
||||
</div>
|
||||
<div class="card-target">目标值:<strong>{{ k.target_value ?? '—' }}</strong></div>
|
||||
<div class="card-progress" v-if="k.target_value && k.actual_value != null">
|
||||
<el-progress
|
||||
:percentage="Math.min(Math.round((k.actual_value / k.target_value) * 100), 100)"
|
||||
:stroke-width="6" :color="alertColor(k.alert_level)" size="small"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span v-if="k.responsible_user">👤 {{ k.responsible_user }}</span>
|
||||
<span v-if="k.frequency">{{ freqLabel(k.frequency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header-flex">
|
||||
<span>📋 {{ title }}明细</span>
|
||||
<el-button size="small" @click="exportCSV">导出CSV</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="kpis" stripe border size="small" style="width:100%">
|
||||
<el-table-column v-if="hasCategories" label="类别" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :color="categoryColor(row.category || categories[0]?.key)" style="color:#fff;border:0;" size="small">
|
||||
{{ row.category || categories[0]?.label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="kpi_name" label="指标名称" min-width="150" />
|
||||
<el-table-column label="当前值" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: alertColor(row.alert_level), fontWeight: 600 }">
|
||||
{{ row.actual_value != null ? row.actual_value : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标值" width="100">
|
||||
<template #default="{ row }">{{ row.target_value ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="60" prop="unit" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="alertTagType(row.alert_level)" size="small" effect="plain">
|
||||
{{ alertLevelLabel(row.alert_level) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="100" prop="responsible_user" />
|
||||
<el-table-column label="频率" width="70" v-if="showFrequency">
|
||||
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click.stop="editKPI(row)">编辑</el-button>
|
||||
<el-button size="small" text type="danger" @click.stop="deleteKPI(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<MyDialog v-model="showForm" :title="formTitle" :width="520">
|
||||
<el-form :model="form" label-width="90px" size="small">
|
||||
<el-form-item label="指标名称">
|
||||
<el-input v-model="form.kpi_name" :placeholder="'如:' + (namePlaceholder || 'KPI名称')" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<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-select v-model="form.unit" style="width:100%">
|
||||
<el-option v-for="u in unitOptions" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="实际值">
|
||||
<el-input-number v-model="form.actual_value" :min="0" style="width:100%" />
|
||||
</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 v-if="hasCategories" label="分类">
|
||||
<el-select v-model="form.category" style="width:100%">
|
||||
<el-option v-for="c in categories" :key="c.key" :label="c.label" :value="c.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 频率字段 -->
|
||||
<el-form-item v-if="showFrequency" 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-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" :placeholder="'数据来源和说明'" />
|
||||
</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, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api/index'
|
||||
|
||||
interface Category {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
subtitle?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface StatLabels {
|
||||
total: string
|
||||
green: string
|
||||
yellow: string
|
||||
red: string
|
||||
noData: string
|
||||
}
|
||||
|
||||
interface Template {
|
||||
name: string
|
||||
category?: string
|
||||
unit?: string
|
||||
targetValue?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
dimension: string
|
||||
title: string
|
||||
icon?: string
|
||||
statLabels?: StatLabels
|
||||
templates?: Template[]
|
||||
categories?: Category[]
|
||||
hasCategories?: boolean
|
||||
showFrequency?: boolean
|
||||
namePlaceholder?: string
|
||||
unitOptions?: string[]
|
||||
formTitleCreate?: string
|
||||
formTitleEdit?: string
|
||||
}>(), {
|
||||
icon: '📊',
|
||||
statLabels: () => ({ total: '指标总数', green: '达标', yellow: '预警', red: '未达标', noData: '无数据' }),
|
||||
templates: () => [],
|
||||
categories: () => [],
|
||||
hasCategories: false,
|
||||
showFrequency: false,
|
||||
namePlaceholder: '',
|
||||
unitOptions: () => ['%', '元', '家', '分', '小时', '次', '个'],
|
||||
formTitleCreate: '',
|
||||
formTitleEdit: '',
|
||||
})
|
||||
|
||||
const formTitle = computed(() => {
|
||||
if (editMode.value) return props.formTitleEdit || `编辑${props.title}`
|
||||
return props.formTitleCreate || `新建${props.title}`
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const kpis = ref<any[]>([])
|
||||
const periodType = ref('month')
|
||||
const showForm = ref(false)
|
||||
const editMode = ref(false)
|
||||
const form = reactive<any>({})
|
||||
|
||||
const stats = computed(() => {
|
||||
const s = { green: 0, yellow: 0, red: 0, noData: 0 }
|
||||
for (const k of kpis.value) {
|
||||
const level = k.alert_level || 'none'
|
||||
if (level === 'green') s.green++
|
||||
else if (level === 'yellow') s.yellow++
|
||||
else if (level === 'red') s.red++
|
||||
else s.noData++
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
function kpisByCategory(catKey: string) {
|
||||
return kpis.value.filter((k: any) => (k.category || props.categories[0]?.key) === catKey)
|
||||
}
|
||||
|
||||
function categoryColor(catKey: string): string {
|
||||
const cat = props.categories.find(c => c.key === catKey)
|
||||
return cat?.color || '#909399'
|
||||
}
|
||||
|
||||
function selectTemplate(t: Template) {
|
||||
editMode.value = false
|
||||
const extra: any = {}
|
||||
if (props.hasCategories && t.category) extra.category = t.category
|
||||
Object.assign(form, {
|
||||
kpi_name: t.name,
|
||||
target_value: t.targetValue,
|
||||
unit: t.unit || '%',
|
||||
actual_value: null,
|
||||
responsible_user: '',
|
||||
description: '',
|
||||
frequency: 'monthly',
|
||||
...extra,
|
||||
})
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editMode.value = false
|
||||
const defaults: any = { kpi_name: '', target_value: null, actual_value: null, unit: '%', responsible_user: '', description: '', frequency: 'monthly' }
|
||||
if (props.hasCategories && props.categories.length > 0) defaults.category = props.categories[0].key
|
||||
Object.assign(form, defaults)
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
loading.value = true
|
||||
kpis.value = []
|
||||
api.get('/kpis', { params: { dimension: props.dimension, page_size: 100 } })
|
||||
.then((r: any) => {
|
||||
kpis.value = (r.data || []).map((k: any) => ({
|
||||
...k,
|
||||
actual_value: k.actual_value ?? k.actualValue,
|
||||
target_value: k.target_value ?? k.targetValue,
|
||||
}))
|
||||
loading.value = false
|
||||
})
|
||||
.catch(() => { loading.value = false })
|
||||
}
|
||||
|
||||
function saveKPI() {
|
||||
if (!form.kpi_name?.trim()) { ElMessage.warning('请输入指标名称'); return }
|
||||
form.dimension = props.dimension
|
||||
if (editMode.value) {
|
||||
api.put(`/kpis/${form.id}`, form).then(() => {
|
||||
ElMessage.success('已更新'); showForm.value = false; loadData()
|
||||
}).catch((e: any) => ElMessage.error(e?.response?.data?.detail || '更新失败'))
|
||||
} else {
|
||||
api.post('/kpis', form).then(() => {
|
||||
ElMessage.success('已创建'); showForm.value = false; loadData()
|
||||
}).catch((e: any) => ElMessage.error(e?.response?.data?.detail || '创建失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function editKPI(row: any) {
|
||||
editMode.value = true
|
||||
const base: any = {
|
||||
id: row.id,
|
||||
kpi_name: row.kpi_name,
|
||||
kpi_code: row.kpi_code,
|
||||
target_value: row.target_value ?? row.targetValue,
|
||||
actual_value: row.actual_value ?? row.actualValue,
|
||||
unit: row.unit || '%',
|
||||
responsible_user: row.responsible_user || '',
|
||||
frequency: row.frequency || 'monthly',
|
||||
description: row.description || '',
|
||||
dimension: props.dimension,
|
||||
}
|
||||
if (props.hasCategories) base.category = row.category || (props.categories[0]?.key || '')
|
||||
Object.assign(form, base)
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function deleteKPI(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此KPI?', '确认')
|
||||
await api.delete(`/kpis/${id}`)
|
||||
ElMessage.success('已删除'); loadData()
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
function showKPIDetail(k: any) {
|
||||
window.location.href = `/kpis/${k.id}`
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const headers = ['指标名称', '当前值', '目标值', '单位', '状态', '负责人']
|
||||
if (props.hasCategories) headers.unshift('类别')
|
||||
const rows = kpis.value.map(k => {
|
||||
const row = [k.kpi_name, k.actual_value ?? '', k.target_value ?? '', k.unit || '', alertLevelLabel(k.alert_level), k.responsible_user || '']
|
||||
if (props.hasCategories) row.unshift(k.category || '')
|
||||
return row
|
||||
})
|
||||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
|
||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = `${props.title}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click(); URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── 预警等级帮助函数 ──
|
||||
function alertLevelLabel(level: string): string {
|
||||
return { red: '未达标', yellow: '预警', green: '达标', none: '无数据' }[level] || '—'
|
||||
}
|
||||
function alertLevelIcon(level: string): string {
|
||||
return { red: '🔴', yellow: '🟡', green: '🟢', none: '⚪' }[level] || '⚪'
|
||||
}
|
||||
function alertColor(level: string): string {
|
||||
return { red: '#f56c6c', yellow: '#e6a23c', green: '#67c23a' }[level] || '#909399'
|
||||
}
|
||||
function alertTagType(level: string): string {
|
||||
return { red: 'danger', yellow: 'warning', green: 'success' }[level] || 'info'
|
||||
}
|
||||
function freqLabel(freq: string): string {
|
||||
return { monthly: '月度', quarterly: '季度', yearly: '年度' }[freq] || freq
|
||||
}
|
||||
function fmtValue(val: any): string {
|
||||
if (val == null) return '—'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-list-view { padding: 16px; }
|
||||
.page-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.page-header h3 { margin: 0; font-size: 18px; display: flex; align-items: center; gap: 6px; }
|
||||
.dim-icon { font-size: 20px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.loading-state { padding: 40px; }
|
||||
.empty-state { padding: 60px 0; }
|
||||
.summary-row { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.stat-card {
|
||||
flex: 1; min-width: 100px; padding: 16px; border-radius: 10px;
|
||||
text-align: center; background: #f8f9fa; border: 1px solid #eee;
|
||||
}
|
||||
.stat-card.blue { background: #ecf5ff; }
|
||||
.stat-card.green { background: #f0f9eb; }
|
||||
.stat-card.yellow { background: #fdf6ec; }
|
||||
.stat-card.red { background: #fef0f0; }
|
||||
.stat-val { font-size: 28px; font-weight: 700; color: #333; line-height: 1.2; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
|
||||
.capital-section { margin-bottom: 24px; }
|
||||
.capital-title {
|
||||
font-size: 16px; font-weight: 600; margin-bottom: 12px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.capital-subtitle { font-size: 12px; font-weight: 400; color: #999; }
|
||||
|
||||
.kpi-card-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px; margin-bottom: 24px;
|
||||
}
|
||||
.kpi-card {
|
||||
background: #fff; border: 1px solid #e8e8e8; border-radius: 10px;
|
||||
padding: 16px; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.kpi-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); transform: translateY(-1px); }
|
||||
.kpi-card.card-level-red { border-left: 4px solid #f56c6c; }
|
||||
.kpi-card.card-level-yellow { border-left: 4px solid #e6a23c; }
|
||||
.kpi-card.card-level-green { border-left: 4px solid #67c23a; }
|
||||
.kpi-card.card-level-none { border-left: 4px solid #dcdfe6; }
|
||||
.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
|
||||
.card-name { font-weight: 600; font-size: 14px; color: #333; }
|
||||
.card-value { font-size: 24px; font-weight: 700; line-height: 1.3; }
|
||||
.val-unit { font-size: 13px; font-weight: 400; margin-left: 4px; color: #999; }
|
||||
.card-target { font-size: 12px; color: #888; margin: 4px 0; }
|
||||
.card-progress { margin: 6px 0; }
|
||||
.card-meta { display: flex; gap: 10px; font-size: 11px; color: #aaa; margin-top: 6px; }
|
||||
|
||||
.section-card { margin-bottom: 16px; }
|
||||
.card-header-flex { display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
.template-dropdown-item { display: flex; align-items: center; gap: 8px; }
|
||||
.template-cat-tag {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 3px;
|
||||
font-size: 11px; color: #fff; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="kp-panel" :class="{ 'kp-collapsed': collapsed }">
|
||||
<div class="kp-header" @click="toggleCollapse">
|
||||
<span class="kp-icon">{{ icon }}</span>
|
||||
<span class="kp-title">📖 CMA知识点</span>
|
||||
<span class="kp-toggle">{{ collapsed ? '展开' : '收起' }}</span>
|
||||
</div>
|
||||
<div v-if="!collapsed" class="kp-body">
|
||||
<div v-if="loading" style="padding:12px;text-align:center;color:#999;">加载中...</div>
|
||||
<div v-else-if="articles.length === 0" style="padding:12px;text-align:center;color:#ccc;">暂无关联知识</div>
|
||||
<template v-else>
|
||||
<div v-for="(article, idx) in articles" :key="article.id" class="kp-article" :class="{ 'kp-first': idx === 0 }">
|
||||
<div class="kp-article-head" @click="toggleArticle(idx)">
|
||||
<span class="kp-article-icon">{{ article.icon || '📖' }}</span>
|
||||
<span class="kp-article-title">{{ article.title }}</span>
|
||||
<span class="kp-article-arrow">{{ expandedArticle === idx ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div v-if="expandedArticle === idx" class="kp-article-body" v-html="renderedContent(article.content)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { knowledgeArticleApi } from '../api/index'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps<{
|
||||
relatedPage: string
|
||||
icon?: string
|
||||
defaultCollapsed?: boolean
|
||||
}>()
|
||||
|
||||
const collapsed = ref(props.defaultCollapsed !== false)
|
||||
const loading = ref(false)
|
||||
const articles = ref<any[]>([])
|
||||
const expandedArticle = ref<number | null>(0)
|
||||
|
||||
// 首次使用tooltip引导
|
||||
const hasSeenTooltip = ref(localStorage.getItem('cma_kb_tooltip_seen') === '1')
|
||||
|
||||
onMounted(() => {
|
||||
if (!hasSeenTooltip.value) {
|
||||
collapsed.value = false
|
||||
localStorage.setItem('cma_kb_tooltip_seen', '1')
|
||||
ElMessage.info('💡 右侧面板展示了关联的CMA知识点,可收起不干扰操作')
|
||||
}
|
||||
loadArticles()
|
||||
})
|
||||
|
||||
watch(() => props.relatedPage, () => {
|
||||
loadArticles()
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
function toggleArticle(idx: number) {
|
||||
expandedArticle.value = expandedArticle.value === idx ? null : idx
|
||||
}
|
||||
|
||||
function renderedContent(content: string) {
|
||||
if (!content) return ''
|
||||
try {
|
||||
return marked(content)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
if (!props.relatedPage) return
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await knowledgeArticleApi.list({ related_page: props.relatedPage })
|
||||
articles.value = r.data || []
|
||||
} catch {
|
||||
articles.value = []
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kp-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
width: 320px;
|
||||
max-height: 480px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
.kp-panel.kp-collapsed {
|
||||
max-height: 36px;
|
||||
width: auto;
|
||||
}
|
||||
.kp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f5 100%);
|
||||
user-select: none;
|
||||
}
|
||||
.kp-icon { font-size: 16px; }
|
||||
.kp-title { flex: 1; font-weight: 600; font-size: 13px; color: #555; }
|
||||
.kp-toggle { font-size: 11px; color: #999; }
|
||||
.kp-body { max-height: 400px; overflow-y: auto; }
|
||||
.kp-article { border-top: 1px solid #f0f0f0; }
|
||||
.kp-article.kp-first { border-top: none; }
|
||||
.kp-article-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.kp-article-head:hover { background: #f5f8ff; }
|
||||
.kp-article-icon { font-size: 14px; }
|
||||
.kp-article-title { flex: 1; font-size: 13px; color: #409eff; }
|
||||
.kp-article-arrow { font-size: 10px; color: #999; }
|
||||
.kp-article-body {
|
||||
padding: 4px 12px 12px;
|
||||
line-height: 1.7;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kp-article-body :deep(h4) { margin: 8px 0 4px; font-size: 13px; color: #333; }
|
||||
.kp-article-body :deep(h5) { margin: 6px 0 3px; font-size: 12px; color: #666; }
|
||||
.kp-article-body :deep(ul),
|
||||
.kp-article-body :deep(ol) { padding-left: 16px; }
|
||||
.kp-article-body :deep(li) { margin-bottom: 2px; }
|
||||
.kp-article-body :deep(code) { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.kp-article-body :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
||||
.kp-article-body :deep(th),
|
||||
.kp-article-body :deep(td) { border: 1px solid #e0e0e0; padding: 4px 6px; text-align: left; font-size: 11px; }
|
||||
.kp-article-body :deep(th) { background: #f5f5f5; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="kp-panel" :class="{ 'kp-collapsed': collapsed }">
|
||||
<div class="kp-header" @click="toggleCollapse">
|
||||
<span class="kp-icon">{{ icon }}</span>
|
||||
<span class="kp-title">📖 CMA知识点</span>
|
||||
<span class="kp-toggle">{{ collapsed ? '展开' : '收起' }}</span>
|
||||
</div>
|
||||
<div v-if="!collapsed" class="kp-body">
|
||||
<div v-if="loading" style="padding:12px;text-align:center;color:#999;">加载中...</div>
|
||||
<div v-else-if="articles.length === 0" style="padding:12px;text-align:center;color:#ccc;">暂无关联知识</div>
|
||||
<template v-else>
|
||||
<div v-for="(article, idx) in articles" :key="article.id" class="kp-article" :class="{ 'kp-first': idx === 0 }">
|
||||
<div class="kp-article-head" @click="toggleArticle(idx)">
|
||||
<span class="kp-article-icon">{{ article.icon || '📖' }}</span>
|
||||
<span class="kp-article-title">{{ article.title }}</span>
|
||||
<span class="kp-article-arrow">{{ expandedArticle === idx ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div v-if="expandedArticle === idx" class="kp-article-body" v-html="renderedContent(article.content)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { knowledgeArticleApi } from '../api/index'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps<{
|
||||
relatedPage: string
|
||||
icon?: string
|
||||
defaultCollapsed?: boolean
|
||||
}>()
|
||||
|
||||
const collapsed = ref(props.defaultCollapsed !== false)
|
||||
const loading = ref(false)
|
||||
const articles = ref<any[]>([])
|
||||
const expandedArticle = ref<number | null>(0)
|
||||
|
||||
// 首次使用tooltip引导
|
||||
const hasSeenTooltip = ref(localStorage.getItem('cma_kb_tooltip_seen') === '1')
|
||||
|
||||
onMounted(() => {
|
||||
if (!hasSeenTooltip.value) {
|
||||
collapsed.value = false
|
||||
localStorage.setItem('cma_kb_tooltip_seen', '1')
|
||||
ElMessage.info('💡 右侧面板展示了关联的CMA知识点,可收起不干扰操作')
|
||||
}
|
||||
loadArticles()
|
||||
})
|
||||
|
||||
watch(() => props.relatedPage, () => {
|
||||
loadArticles()
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
function toggleArticle(idx: number) {
|
||||
expandedArticle.value = expandedArticle.value === idx ? null : idx
|
||||
}
|
||||
|
||||
function renderedContent(content: string) {
|
||||
if (!content) return ''
|
||||
try {
|
||||
return marked(content)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
if (!props.relatedPage) return
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await knowledgeArticleApi.list({ related_page: props.relatedPage })
|
||||
articles.value = r.data || []
|
||||
} catch {
|
||||
articles.value = []
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kp-panel {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.kp-panel.kp-collapsed {
|
||||
max-height: 36px;
|
||||
}
|
||||
.kp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f5 100%);
|
||||
user-select: none;
|
||||
}
|
||||
.kp-icon { font-size: 16px; }
|
||||
.kp-title { flex: 1; font-weight: 600; font-size: 13px; color: #555; }
|
||||
.kp-toggle { font-size: 11px; color: #999; }
|
||||
.kp-body { max-height: 400px; overflow-y: auto; }
|
||||
.kp-article { border-top: 1px solid #f0f0f0; }
|
||||
.kp-article.kp-first { border-top: none; }
|
||||
.kp-article-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.kp-article-head:hover { background: #f5f8ff; }
|
||||
.kp-article-icon { font-size: 14px; }
|
||||
.kp-article-title { flex: 1; font-size: 13px; color: #409eff; }
|
||||
.kp-article-arrow { font-size: 10px; color: #999; }
|
||||
.kp-article-body {
|
||||
padding: 4px 12px 12px;
|
||||
line-height: 1.7;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kp-article-body :deep(h4) { margin: 8px 0 4px; font-size: 13px; color: #333; }
|
||||
.kp-article-body :deep(h5) { margin: 6px 0 3px; font-size: 12px; color: #666; }
|
||||
.kp-article-body :deep(ul),
|
||||
.kp-article-body :deep(ol) { padding-left: 16px; }
|
||||
.kp-article-body :deep(li) { margin-bottom: 2px; }
|
||||
.kp-article-body :deep(code) { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.kp-article-body :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
||||
.kp-article-body :deep(th),
|
||||
.kp-article-body :deep(td) { border: 1px solid #e0e0e0; padding: 4px 6px; text-align: left; font-size: 11px; }
|
||||
.kp-article-body :deep(th) { background: #f5f5f5; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="" :width="560" :show-close="true" :close-on-click-modal="false" class="cma-welcome-dialog">
|
||||
<div class="welcome-body">
|
||||
<div class="welcome-header">
|
||||
<div class="welcome-icon">👋</div>
|
||||
<h2>欢迎使用管理会计OS</h2>
|
||||
<p class="welcome-sub">三步开启从战略到执行的管理闭环</p>
|
||||
</div>
|
||||
|
||||
<div class="steps-wrap">
|
||||
<div class="step-item" :class="{ active: currentStep === 0, done: currentStep > 0 }">
|
||||
<div class="step-num">
|
||||
<span v-if="currentStep > 0">✅</span>
|
||||
<span v-else>1</span>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h4>① 绘制战略地图</h4>
|
||||
<p>在战略画布上定义财务、客户、内部流程、学习成长四个维度的战略主题和因果关系链,让战略"可视化"。</p>
|
||||
<div class="step-preview">🗺️ 战略地图 → KPI目标对齐 → 预算配置</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-connector" :class="{ active: currentStep >= 1 }">↓</div>
|
||||
|
||||
<div class="step-item" :class="{ active: currentStep === 1, done: currentStep > 1 }">
|
||||
<div class="step-num">
|
||||
<span v-if="currentStep > 1">✅</span>
|
||||
<span v-else>2</span>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h4>② 对齐KPI目标</h4>
|
||||
<p>将战略地图上的战略主题分解为可衡量的KPI,支持纵向分解(公司→部门)、横向支撑(因果传导)和BSC瀑布链三种对齐模式。</p>
|
||||
<div class="step-preview">🎯 KPI目标对齐 → 资源按目标分配</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-connector" :class="{ active: currentStep >= 2 }">↓</div>
|
||||
|
||||
<div class="step-item" :class="{ active: currentStep === 2, done: currentStep > 2 }">
|
||||
<div class="step-num">
|
||||
<span v-if="currentStep > 2">✅</span>
|
||||
<span v-else>3</span>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h4>③ 编制全面预算</h4>
|
||||
<p>按对齐后的目标分配资源,编制收入/成本/费用/投资预算。从此预算不再是财务部闭门造,而是从战略自上而下对齐出来的。</p>
|
||||
<div class="step-preview">💰 预算管理 → 驾驶舱监控执行 → 差异分析 → 改善行动</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 完整闭环说明 -->
|
||||
<div class="pdca-hint" v-if="currentStep >= 2">
|
||||
<div class="hint-title">三步走完后,系统进入持续管理闭环</div>
|
||||
<div class="hint-flow">
|
||||
<span>🎯 P 战略规划</span>
|
||||
<span>→</span>
|
||||
<span>📊 D 执行监控</span>
|
||||
<span>→</span>
|
||||
<span>🔍 C 复盘评估</span>
|
||||
<span>→</span>
|
||||
<span>🔄 A 优化调整</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="welcome-footer">
|
||||
<el-button v-if="currentStep < 2" type="primary" size="large" @click="currentStep++">继续了解 →</el-button>
|
||||
<el-button v-else type="primary" size="large" @click="startUsing">开始使用 →</el-button>
|
||||
<el-button text @click="skip">跳过引导</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const visible = ref(false)
|
||||
const currentStep = ref(0)
|
||||
|
||||
const GUIDE_SHOWN_KEY = 'cma_guide_shown'
|
||||
|
||||
function show() {
|
||||
visible.value = true
|
||||
currentStep.value = 0
|
||||
}
|
||||
|
||||
function skip() {
|
||||
visible.value = false
|
||||
localStorage.setItem(GUIDE_SHOWN_KEY, '1')
|
||||
}
|
||||
|
||||
function startUsing() {
|
||||
visible.value = false
|
||||
localStorage.setItem(GUIDE_SHOWN_KEY, '1')
|
||||
router.push('/maps')
|
||||
}
|
||||
|
||||
// 暴露show方法供父组件调用
|
||||
defineExpose({ show })
|
||||
|
||||
onMounted(() => {
|
||||
// 自动检测是否显示过引导
|
||||
const shown = localStorage.getItem(GUIDE_SHOWN_KEY)
|
||||
if (!shown) {
|
||||
// 延迟弹窗让页面先渲染
|
||||
setTimeout(() => { visible.value = true }, 500)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome-body { text-align: center; }
|
||||
.welcome-header { margin-bottom: 24px; }
|
||||
.welcome-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.welcome-header h2 { margin: 0 0 6px; font-size: 22px; }
|
||||
.welcome-sub { color: #666; font-size: 14px; margin: 0; }
|
||||
|
||||
.steps-wrap { text-align: left; margin-bottom: 20px; }
|
||||
.step-item {
|
||||
display: flex; gap: 14px; padding: 14px 16px;
|
||||
border: 1px solid #eee; border-radius: 10px;
|
||||
margin-bottom: 4px; transition: all 0.3s;
|
||||
}
|
||||
.step-item.active { border-color: #409eff; background: #f0f7ff; }
|
||||
.step-item.done { border-color: #b7eb8f; background: #f6ffed; }
|
||||
.step-num {
|
||||
flex-shrink: 0; width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; font-weight: 700; font-size: 14px;
|
||||
background: #eee; color: #999;
|
||||
}
|
||||
.step-item.active .step-num { background: #409eff; color: #fff; }
|
||||
.step-item.done .step-num { background: transparent; }
|
||||
.step-content { flex: 1; }
|
||||
.step-content h4 { margin: 0 0 4px; font-size: 15px; }
|
||||
.step-content p { margin: 0 0 6px; font-size: 13px; color: #666; line-height: 1.5; }
|
||||
.step-preview {
|
||||
font-size: 12px; color: #909399;
|
||||
background: #f5f7fa; padding: 4px 8px; border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.step-connector { text-align: center; color: #ddd; font-size: 18px; padding: 2px 0; }
|
||||
.step-connector.active { color: #409eff; }
|
||||
|
||||
.pdca-hint {
|
||||
background: #f0f7ff; border: 1px solid #d9ecff; border-radius: 10px;
|
||||
padding: 14px; margin-bottom: 20px;
|
||||
}
|
||||
.hint-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
|
||||
.hint-flow {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 8px; font-size: 13px; flex-wrap: wrap;
|
||||
}
|
||||
.hint-flow span { white-space: nowrap; }
|
||||
|
||||
.welcome-footer { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:80px;"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
actual: number
|
||||
target: number
|
||||
label?: string
|
||||
unit?: string
|
||||
}>(), { label: '', unit: '' })
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function render() {
|
||||
if (!containerRef.value) return
|
||||
if (!chart) chart = echarts.init(containerRef.value)
|
||||
|
||||
const maxVal = Math.max(props.actual, props.target, 1) * 1.3
|
||||
const pct = props.target > 0 ? (props.actual / props.target * 100) : 0
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: () =>
|
||||
`${props.label}<br/>实际: <b>${props.actual?.toLocaleString()}${props.unit}</b><br/>目标: ${props.target?.toLocaleString()}${props.unit}<br/>完成率: <b>${pct.toFixed(1)}%</b>`,
|
||||
},
|
||||
grid: { left: 50, right: 50, top: 10, bottom: 5 },
|
||||
xAxis: { type: 'category', data: [props.label], axisLabel: { show: false }, splitLine: { show: false } },
|
||||
yAxis: { type: 'value', max: maxVal, splitLine: { show: false }, axisLabel: { fontSize: 10, color: '#bbb' } },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: [props.actual],
|
||||
barWidth: 16,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: pct >= 100 ? '#52c41a' : pct >= 80 ? '#faad14' : '#ff4d4f' },
|
||||
{ offset: 1, color: pct >= 100 ? '#73d13d' : pct >= 80 ? '#ffc53d' : '#ff7875' },
|
||||
]),
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: `${props.actual?.toLocaleString()}${props.unit}`,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: pct >= 100 ? '#52c41a' : '#ff4d4f',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
data: [props.target],
|
||||
barWidth: 16,
|
||||
barGap: '-100%',
|
||||
itemStyle: { color: 'rgba(0,0,0,0.06)', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'bottom',
|
||||
formatter: `目标: ${props.target?.toLocaleString()}${props.unit}`,
|
||||
fontSize: 10,
|
||||
color: '#999',
|
||||
},
|
||||
},
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
watch(() => [props.actual, props.target], render)
|
||||
onMounted(render)
|
||||
onUnmounted(() => { chart?.dispose(); chart = null })
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:150px;"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
actual: number
|
||||
target: number
|
||||
label?: string
|
||||
unit?: string
|
||||
}>(), { label: '', unit: '' })
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function render() {
|
||||
if (!containerRef.value) return
|
||||
if (!chart) chart = echarts.init(containerRef.value)
|
||||
|
||||
const pct = props.target > 0 ? Math.min(props.actual / props.target * 100, 100) : 0
|
||||
const isGood = props.actual >= props.target
|
||||
|
||||
chart.setOption({
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 220,
|
||||
endAngle: -40,
|
||||
min: 0,
|
||||
max: props.target * 1.3 || 1,
|
||||
progress: { show: true, width: 18, itemStyle: { color: isGood ? '#52c41a' : '#ff4d4f' } },
|
||||
axisLine: { lineStyle: { width: 18, color: [[0.3, '#ff4d4f'], [0.7, '#faad14'], [1, '#52c41a']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
pointer: { show: false },
|
||||
anchor: { show: false },
|
||||
title: { show: true, offsetCenter: [0, '30%'], fontSize: 12, color: '#999' },
|
||||
detail: {
|
||||
offsetCenter: [0, '-10%'],
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: isGood ? '#52c41a' : '#ff4d4f',
|
||||
formatter: (v: number) => `${props.actual?.toLocaleString()}${props.unit}`,
|
||||
},
|
||||
data: [{ value: props.actual, name: props.label }],
|
||||
}],
|
||||
grid: { top: 5, bottom: 5, left: 5, right: 5 },
|
||||
}, true)
|
||||
}
|
||||
|
||||
watch(() => [props.actual, props.target], render)
|
||||
onMounted(render)
|
||||
onUnmounted(() => { chart?.dispose(); chart = null })
|
||||
</script>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="profit-breakdown">
|
||||
<!-- 第1行:营收 -->
|
||||
<div class="pb-row pb-row-top">
|
||||
<div class="pb-card pb-card-revenue">
|
||||
<div class="pb-label">营收</div>
|
||||
<div class="pb-value">{{ fmt(revenue) }}</div>
|
||||
</div>
|
||||
<div class="pb-arrow">−</div>
|
||||
<div class="pb-card pb-card-expense">
|
||||
<div class="pb-label">成本费用</div>
|
||||
<div class="pb-value pb-neg">{{ fmt(cost) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等号 + 结果 -->
|
||||
<div class="pb-eq-row">
|
||||
<div class="pb-eq-line"></div>
|
||||
<div class="pb-eq-sign">=</div>
|
||||
<div class="pb-eq-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 第2行:毛利额 -->
|
||||
<div class="pb-row">
|
||||
<div class="pb-card pb-card-gross">
|
||||
<div class="pb-label">毛利润</div>
|
||||
<div class="pb-value" :class="gross >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(gross) }}</div>
|
||||
<div class="pb-sub">毛利率 {{ grossRate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 费用拆解 -->
|
||||
<div v-if="hasDetail" class="pb-detail">
|
||||
<div class="pb-detail-title">费用构成</div>
|
||||
<div class="pb-detail-grid">
|
||||
<div v-for="item in detailItems" :key="item.key" class="pb-detail-item">
|
||||
<span class="pb-dot" :style="{ background: item.color }"></span>
|
||||
<span class="pb-detail-label">{{ item.label }}</span>
|
||||
<span class="pb-detail-val" :class="item.val >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(item.val) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="pb-eq-row">
|
||||
<div class="pb-eq-line"></div>
|
||||
<div class="pb-eq-sign">=</div>
|
||||
<div class="pb-eq-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 最终结果:净利润 -->
|
||||
<div class="pb-row">
|
||||
<div class="pb-card pb-card-net" :class="profit >= 0 ? 'pb-card-pos' : 'pb-card-neg'">
|
||||
<div class="pb-label">净利润</div>
|
||||
<div class="pb-value pb-big" :class="profit >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(profit) }}</div>
|
||||
<div class="pb-sub">净利率 {{ netRate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同期对比 -->
|
||||
<div v-if="prevRevenue" class="pb-compare">
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">营收较上期</span>
|
||||
<span :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ revChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(revChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">净利润较上期</span>
|
||||
<span :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ profitChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(profitChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">净利率较上期</span>
|
||||
<span :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ rateChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(rateChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 小结 -->
|
||||
<div class="pb-summary">
|
||||
<div class="pb-bar-track">
|
||||
<div class="pb-bar-label">每100元营收中</div>
|
||||
<div class="pb-bar-wrap">
|
||||
<div class="pb-bar-seg" :style="barStyle.revenue">营收 100</div>
|
||||
</div>
|
||||
<div class="pb-bar-wrap">
|
||||
<div class="pb-bar-seg pb-bar-cost" :style="barStyle.cost">成本 {{ costRate }}</div>
|
||||
</div>
|
||||
<div class="pb-bar-wrap">
|
||||
<div class="pb-bar-seg" :class="profit >= 0 ? 'pb-bar-profit' : 'pb-bar-loss'" :style="barStyle.profit">
|
||||
利润 {{ netRate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
revenue: number
|
||||
cost: number // 成本费用合计
|
||||
profit: number // 净利润
|
||||
// 可选:费用明细
|
||||
detail?: { key: string; label: string; val: number; color?: string }[]
|
||||
// 可选:上期数据
|
||||
prevRevenue?: number
|
||||
prevProfit?: number
|
||||
}>()
|
||||
|
||||
const hasDetail = computed(() => props.detail && props.detail.length > 0)
|
||||
|
||||
const gross = computed(() => props.revenue - props.cost)
|
||||
const grossRate = computed(() => {
|
||||
if (!props.revenue) return '—'
|
||||
return ((props.revenue - props.cost) / props.revenue * 100).toFixed(1) + '%'
|
||||
})
|
||||
const netRate = computed(() => {
|
||||
if (!props.revenue) return '—'
|
||||
return (props.profit / props.revenue * 100).toFixed(1) + '%'
|
||||
})
|
||||
const costRate = computed(() => {
|
||||
if (!props.revenue) return '—'
|
||||
return (props.cost / props.revenue * 100).toFixed(1) + '%'
|
||||
})
|
||||
|
||||
const revChg = computed(() => {
|
||||
if (!props.prevRevenue) return 0
|
||||
return (props.revenue - props.prevRevenue) / props.prevRevenue
|
||||
})
|
||||
const profitChg = computed(() => {
|
||||
if (!props.prevProfit) return 0
|
||||
return (props.profit - props.prevProfit) / Math.abs(props.prevProfit)
|
||||
})
|
||||
const rateChg = computed(() => {
|
||||
if (!props.prevRevenue || !props.prevProfit) return 0
|
||||
const cur = props.profit / props.revenue
|
||||
const prev = props.prevProfit / props.prevRevenue
|
||||
return (cur - prev) / Math.abs(prev || 0.01)
|
||||
})
|
||||
|
||||
const detailItems = computed(() => {
|
||||
const colors = ['#f56c6c', '#e6a23c', '#909399', '#f8983b', '#d48265']
|
||||
return (props.detail || []).map((d, i) => ({
|
||||
...d,
|
||||
color: d.color || colors[i % colors.length],
|
||||
}))
|
||||
})
|
||||
|
||||
const barStyle = computed(() => {
|
||||
const max = props.revenue || 1
|
||||
const costPct = Math.min(props.cost / max * 100, 100)
|
||||
const profitPct = Math.max(Math.min(props.profit / max * 100, 100), 0)
|
||||
return {
|
||||
revenue: { width: '100%' },
|
||||
cost: { width: costPct + '%' },
|
||||
profit: { width: profitPct + '%' },
|
||||
}
|
||||
})
|
||||
|
||||
function fmt(v: number | undefined | null): string {
|
||||
if (v == null) return '—'
|
||||
return '¥' + Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profit-breakdown {
|
||||
padding: 12px 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 卡片行(横向) */
|
||||
.pb-row { display: flex; align-items: stretch; gap: 12px; justify-content: center; flex-wrap: wrap; margin: 8px 0; }
|
||||
.pb-row-top { }
|
||||
|
||||
.pb-card {
|
||||
padding: 14px 22px;
|
||||
border-radius: 10px;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e8eaed;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.pb-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
|
||||
.pb-card-revenue { background: #e6f7ff; border-color: #91d5ff; }
|
||||
.pb-card-expense { background: #fff2f0; border-color: #ffccc7; }
|
||||
.pb-card-gross { background: #f6ffed; border-color: #b7eb8f; min-width: 180px; }
|
||||
.pb-card-net { min-width: 180px; }
|
||||
.pb-card-pos { background: #f0f5ff; border-color: #adc6ff; }
|
||||
.pb-card-neg { background: #fff1f0; border-color: #ffa39e; }
|
||||
|
||||
.pb-arrow { display: flex; align-items: center; font-size: 24px; color: #999; font-weight: 300; }
|
||||
|
||||
.pb-label { font-size: 12px; color: #666; margin-bottom: 4px; }
|
||||
.pb-value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.pb-value.pb-big { font-size: 22px; }
|
||||
.pb-pos { color: #389e0d; }
|
||||
.pb-neg { color: #cf1322; }
|
||||
.pb-sub { font-size: 11px; color: #999; margin-top: 2px; }
|
||||
|
||||
/* 等号行 */
|
||||
.pb-eq-row { display: flex; align-items: center; gap: 10px; margin: 4px 0; justify-content: center; }
|
||||
.pb-eq-line { flex: 0 1 80px; height: 1px; background: #e0e0e0; }
|
||||
.pb-eq-sign { font-size: 18px; color: #999; font-weight: 300; }
|
||||
|
||||
/* 费用明细 */
|
||||
.pb-detail { margin: 12px auto; max-width: 400px; background: #fafafa; border-radius: 8px; padding: 12px 16px; border: 1px solid #f0f0f0; }
|
||||
.pb-detail-title { font-size: 12px; color: #999; margin-bottom: 8px; }
|
||||
.pb-detail-grid { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pb-detail-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.pb-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.pb-detail-label { flex: 1; color: #555; }
|
||||
.pb-detail-val { font-weight: 600; font-variant-numeric: tabular-nums; min-width: 80px; text-align: right; }
|
||||
|
||||
/* 同期对比 */
|
||||
.pb-compare { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; margin: 16px 0 0; }
|
||||
.pb-compare-item { font-size: 12px; color: #666; background: #fafafa; padding: 6px 14px; border-radius: 20px; border: 1px solid #f0f0f0; }
|
||||
.pb-compare-label { margin-right: 6px; }
|
||||
|
||||
/* 底部 100元拆解条 */
|
||||
.pb-summary { margin: 16px auto 0; max-width: 380px; }
|
||||
.pb-bar-track { }
|
||||
.pb-bar-label { font-size: 11px; color: #999; margin-bottom: 6px; }
|
||||
.pb-bar-wrap { height: 22px; margin-bottom: 3px; border-radius: 4px; overflow: hidden; background: #f5f5f5; }
|
||||
.pb-bar-seg { height: 100%; border-radius: 4px; font-size: 11px; color: #fff; display: flex; align-items: center; padding-left: 8px; font-weight: 600; }
|
||||
.pb-bar-cost { background: #ff7875; }
|
||||
.pb-bar-profit { background: #69b1ff; }
|
||||
.pb-bar-loss { background: #ff4d4f; }
|
||||
|
||||
/* 杜邦拆分树 */</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:200px;"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 数据:{ name, value }[] */
|
||||
data: { name: string; value: number }[]
|
||||
totalLabel?: string
|
||||
totalUnit?: string
|
||||
}>(), {
|
||||
totalLabel: '总计',
|
||||
totalUnit: '',
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function render() {
|
||||
if (!containerRef.value) return
|
||||
if (!chart) chart = echarts.init(containerRef.value)
|
||||
|
||||
const total = props.data.reduce((s, d) => s + Math.abs(d.value), 0)
|
||||
// 构建瀑布图数据
|
||||
let accumulate = 0
|
||||
const seriesData = props.data.map((d, i) => {
|
||||
const isLast = i === props.data.length - 1
|
||||
const isPos = d.value >= 0
|
||||
const item: any = {
|
||||
value: isPos ? d.value : -d.value,
|
||||
itemStyle: {
|
||||
color: isPos ? '#52c41a' : '#ff4d4f',
|
||||
borderColor: isPos ? '#52c41a' : '#ff4d4f',
|
||||
},
|
||||
}
|
||||
if (!isLast) {
|
||||
if (isPos) {
|
||||
// 正数:从accumulate开始
|
||||
item.offset = accumulate
|
||||
accumulate += d.value
|
||||
} else {
|
||||
// 负数:向下
|
||||
accumulate += d.value
|
||||
item.offset = Math.max(0, accumulate)
|
||||
}
|
||||
} else {
|
||||
// 最后一项是总计
|
||||
item.offset = 0
|
||||
item.value = total
|
||||
item.itemStyle = { color: '#409eff' }
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
// 简化瀑布图:用堆积柱状图模拟
|
||||
const posData = props.data.map(d => d.value > 0 ? d.value : 0)
|
||||
const negData = props.data.map(d => d.value < 0 ? -d.value : 0)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any[]) => {
|
||||
const idx = params[0]?.dataIndex
|
||||
const d = props.data[idx]
|
||||
if (!d) return ''
|
||||
return `${d.name}: <b>${d.value?.toLocaleString()}${props.totalUnit}</b>`
|
||||
},
|
||||
},
|
||||
grid: { left: 60, right: 30, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: [...props.data.map(d => d.name), props.totalLabel],
|
||||
axisLabel: { rotate: 0, fontSize: 11, color: '#666', interval: 0 },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: props.totalUnit,
|
||||
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
|
||||
axisLabel: { fontSize: 10, color: '#999' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: posData,
|
||||
barWidth: 24,
|
||||
itemStyle: { color: '#52c41a', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: (p: any) => {
|
||||
const v = props.data[p.dataIndex]?.value
|
||||
return v && v > 0 ? v?.toLocaleString() : ''
|
||||
},
|
||||
fontSize: 10, color: '#52c41a',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: negData,
|
||||
barWidth: 24,
|
||||
itemStyle: { color: '#ff4d4f', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'bottom',
|
||||
formatter: (p: any) => {
|
||||
const v = props.data[p.dataIndex]?.value
|
||||
return v && v < 0 ? v?.toLocaleString() : ''
|
||||
},
|
||||
fontSize: 10, color: '#ff4d4f',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: props.data.map(() => null).concat(total),
|
||||
barWidth: 24,
|
||||
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: total?.toLocaleString(),
|
||||
fontSize: 13, fontWeight: 700, color: '#409eff',
|
||||
},
|
||||
},
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
watch(() => props.data, render, { deep: true })
|
||||
onMounted(render)
|
||||
onUnmounted(() => { chart?.dispose(); chart = null })
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<!-- 固定跨层箭头:从下层泳道到上层泳道(因果连线) -->
|
||||
<path
|
||||
v-for="(arrow, idx) in fixedLayerArrows"
|
||||
:key="'fixed-' + idx"
|
||||
:d="arrow.path"
|
||||
fill="none"
|
||||
stroke="#c0c4cc"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="6,4"
|
||||
marker-end="url(#arrowhead)"
|
||||
class="fixed-layer-arrow"
|
||||
>
|
||||
<title>{{ arrow.from }} → {{ arrow.to }}</title>
|
||||
</path>
|
||||
|
||||
<!-- 用户自定义连线 -->
|
||||
<line
|
||||
v-for="(line, idx) in connectionLines"
|
||||
:key="'conn-' + 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="$emit('select-connection', idx)"
|
||||
@mouseenter="hoverConnIdx = idx"
|
||||
@mouseleave="hoverConnIdx = null"
|
||||
/>
|
||||
|
||||
<!-- 连线提示浮层 -->
|
||||
<g v-if="hoverConnIdx !== null && connectionLines[hoverConnIdx] && hoverConnIdx !== selectedConnIdx">
|
||||
<rect
|
||||
:x="connectionLines[hoverConnIdx].mx - 60"
|
||||
:y="connectionLines[hoverConnIdx].my - 36"
|
||||
width="120" height="22" rx="4" fill="rgba(0,0,0,0.65)"
|
||||
/>
|
||||
<text
|
||||
:x="connectionLines[hoverConnIdx].mx"
|
||||
:y="connectionLines[hoverConnIdx].my - 21"
|
||||
text-anchor="middle" fill="#fff" font-size="11"
|
||||
>点击选中连线</text>
|
||||
</g>
|
||||
|
||||
<!-- 删除按钮(选中连线时) -->
|
||||
<g v-if="selectedConnIdx !== null && connectionLines[selectedConnIdx]">
|
||||
<rect
|
||||
:x="connectionLines[selectedConnIdx].mx - 18"
|
||||
:y="connectionLines[selectedConnIdx].my - 24"
|
||||
width="88" height="24" rx="12" fill="#f56c6c" class="del-btn-bg"
|
||||
@click.stop="$emit('delete-connection', selectedConnIdx)"
|
||||
/>
|
||||
<text
|
||||
:x="connectionLines[selectedConnIdx].mx"
|
||||
:y="connectionLines[selectedConnIdx].my - 8"
|
||||
text-anchor="middle" fill="#fff" font-size="13" font-weight="bold"
|
||||
class="del-btn-text"
|
||||
@click.stop="$emit('delete-connection', 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
connectionLines: any[]
|
||||
selectedConnIdx: number | null
|
||||
tempLine: any
|
||||
fixedLayerArrows: { from: string; to: string; path: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-connection', idx: number): void
|
||||
(e: 'delete-connection', idx: number): void
|
||||
}>()
|
||||
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
const hoverConnIdx = ref<number | null>(null)
|
||||
|
||||
defineExpose({ svgRef })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connection-svg {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 5; pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.conn-line {
|
||||
cursor: pointer; transition: stroke .15s, stroke-width .15s;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
.conn-line:hover { stroke: #e6a23c !important; stroke-width: 4 !important; cursor: pointer; }
|
||||
.conn-selected { stroke: #f56c6c !important; stroke-width: 3; }
|
||||
.del-btn-bg { cursor: pointer; pointer-events: all; }
|
||||
.del-btn-bg:hover { fill: #e74c3c !important; }
|
||||
.del-btn-text { cursor: pointer; pointer-events: all; user-select: none; }
|
||||
.fixed-layer-arrow {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div
|
||||
class="map-node"
|
||||
:class="{
|
||||
'linking-source': isLinkingSource,
|
||||
'linking-target': isLinkingTarget,
|
||||
'drag-over-connect': isDragConnectTarget,
|
||||
'drag-over': isDragOver,
|
||||
'node-level-red': level === 'red',
|
||||
'node-level-yellow': level === 'yellow',
|
||||
'node-level-green': level === 'green',
|
||||
}"
|
||||
:draggable="draggable"
|
||||
@dragstart="onDragStart"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
@click="onClick"
|
||||
>
|
||||
<div class="node-title">
|
||||
<span class="node-level-dot" :class="'dot-' + (level || 'gray')"></span>
|
||||
<el-icon v-if="objIcon" style="margin-right:4px;"><component :is="objIcon" /></el-icon>
|
||||
{{ objective.name }}
|
||||
</div>
|
||||
<div v-if="objective.description" class="node-desc">{{ objective.description }}</div>
|
||||
<div class="node-kpis">
|
||||
<el-tag
|
||||
v-for="kpi in (objective.kpis||[])" :key="kpi"
|
||||
size="small" style="margin:2px;cursor:pointer;"
|
||||
:title="kpi"
|
||||
@click.stop="$emit('kpi-click', kpi)"
|
||||
>{{ getKpiName(kpi) }}</el-tag>
|
||||
</div>
|
||||
<!-- KPI达成率迷你进度条 -->
|
||||
<div v-if="progressData" class="node-progress-bar">
|
||||
<div class="node-progress-fill"
|
||||
:style="{
|
||||
width: Math.max(progressData.ratio, 4) + '%',
|
||||
background: progressColor(progressData.level)
|
||||
}"
|
||||
></div>
|
||||
<span class="node-progress-text">
|
||||
{{ progressData.ratio > 0 ? progressData.ratio + '%' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- KPI实际值快照 -->
|
||||
<div v-if="progressData?.kpiList?.length" class="node-kpi-snapshots">
|
||||
<div
|
||||
v-for="item in progressData.kpiList.slice(0, 2)" :key="item.code"
|
||||
class="kpi-snapshot-row"
|
||||
:class="'snap-' + getKpiLevel(item)"
|
||||
>
|
||||
<span class="snap-name" :title="item.name">{{ item.name }}</span>
|
||||
<span class="snap-value">
|
||||
{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}
|
||||
<span class="snap-target">/ {{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="progressData.kpiList.length > 2" class="kpi-snapshot-more">
|
||||
+{{ progressData.kpiList.length - 2 }} 更多
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="progressData?.kpiList?.length" class="node-click-hint">点击查看KPI详情</div>
|
||||
<!-- 行动方案摘要 -->
|
||||
<div v-if="progressData?.planSummary" class="node-plan-summary" @click.stop="$emit('plan-click')">
|
||||
<span class="plan-summary-icon">📋</span>
|
||||
<span class="plan-summary-text">{{ progressData.planSummary.total }} 项行动方案</span>
|
||||
<span class="plan-summary-badges">
|
||||
<span v-if="progressData.planSummary.overdue > 0" class="ps-badge ps-overdue">{{ progressData.planSummary.overdue }}逾期</span>
|
||||
<span v-if="progressData.planSummary.in_progress > 0" class="ps-badge ps-in-progress">{{ progressData.planSummary.in_progress }}进行中</span>
|
||||
<span v-if="progressData.planSummary.completed === progressData.planSummary.total && progressData.planSummary.total > 0" class="ps-badge ps-done">全部完成 ✅</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 右上角连线入口 -->
|
||||
<span class="link-btn-corner"
|
||||
@mousedown.prevent="$emit('link-drag-start', $event)"
|
||||
@click.stop="$emit('link-click')"
|
||||
title="拖拽到另一个目标创建因果连线">↗</span>
|
||||
<div class="node-actions">
|
||||
<el-button text size="small" @click.stop="$emit('edit')" title="编辑目标">✏️</el-button>
|
||||
<el-button text size="small" @click.stop="$emit('delete')" title="删除目标" style="color:#f56c6c;">🗑️</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
objective: any
|
||||
nodeKey: string
|
||||
level?: string
|
||||
progressData?: any
|
||||
index: number
|
||||
draggable?: boolean
|
||||
isLinkingSource?: boolean
|
||||
isLinkingTarget?: boolean
|
||||
isDragConnectTarget?: boolean
|
||||
isDragOver?: boolean
|
||||
iconMap?: Record<string, string>
|
||||
kpiNameMap?: Record<string, string>
|
||||
allKpis?: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'drag-start', payload: { index: number; event: DragEvent }): void
|
||||
(e: 'drag-over'): void
|
||||
(e: 'drag-leave'): void
|
||||
(e: 'drop'): void
|
||||
(e: 'click'): void
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'link-click'): void
|
||||
(e: 'link-drag-start', event: MouseEvent): void
|
||||
(e: 'kpi-click', kpiCode: string): void
|
||||
(e: 'plan-click'): void
|
||||
}>()
|
||||
|
||||
const objIcon = computed(() => {
|
||||
if (props.objective.icon && props.iconMap) {
|
||||
return (props.iconMap as Record<string, string>)[props.objective.icon]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
emit('drag-start', { index: props.index, event: e })
|
||||
}
|
||||
function onDragOver() { emit('drag-over') }
|
||||
function onDragLeave() { emit('drag-leave') }
|
||||
function onDrop() { emit('drop') }
|
||||
function onClick() { emit('click') }
|
||||
|
||||
function getKpiName(code: string): string {
|
||||
if (props.kpiNameMap && props.kpiNameMap[code]) return props.kpiNameMap[code]
|
||||
const k = props.allKpis?.find((k: any) => k.kpi_code === code)
|
||||
return k?.kpi_name || code
|
||||
}
|
||||
|
||||
function getKpiLevel(item: any): string {
|
||||
if (item.actual != null && item.target) {
|
||||
const ratio = item.actual / item.target
|
||||
if (ratio >= 0.9) return 'green'
|
||||
if (ratio >= 0.7) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function fmtKpiVal(val: any): string {
|
||||
if (val == null) return '—'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function progressColor(level: string): string {
|
||||
if (level === 'green') return '#67c23a'
|
||||
if (level === 'yellow') return '#e6a23c'
|
||||
if (level === 'red') return '#f56c6c'
|
||||
return '#dcdfe6'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-node {
|
||||
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
padding: 10px; margin-bottom: 8px; cursor: pointer;
|
||||
transition: all 0.2s; position: relative;
|
||||
}
|
||||
.map-node:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
|
||||
.map-node.linking-source {
|
||||
border-color: #e6a23c; box-shadow: 0 0 0 2px rgba(230,162,60,0.3);
|
||||
}
|
||||
.map-node.linking-target:hover {
|
||||
border-color: #67c23a; box-shadow: 0 0 0 2px rgba(103,194,58,0.3);
|
||||
}
|
||||
.map-node.drag-over-connect {
|
||||
border-color: #67c23a !important; box-shadow: 0 0 0 3px rgba(103,194,58,0.5) !important;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.map-node.drag-over {
|
||||
border-color: #409eff !important; box-shadow: 0 0 0 2px rgba(64,158,255,0.3) !important;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.node-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; }
|
||||
.node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
|
||||
.node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
|
||||
.node-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; }
|
||||
.node-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
|
||||
.node-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
|
||||
.node-kpi-snapshots { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.kpi-snapshot-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 11px; padding: 1px 4px; border-radius: 3px;
|
||||
}
|
||||
.snap-green { background: #f0f9eb; }
|
||||
.snap-yellow { background: #fdf6ec; }
|
||||
.snap-red { background: #fef0f0; }
|
||||
.snap-gray { background: #f5f5f5; }
|
||||
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100px; color: #666; }
|
||||
.snap-value { font-weight: 600; color: #333; white-space: nowrap; }
|
||||
.snap-target { font-weight: 400; color: #999; font-size: 10px; }
|
||||
.kpi-snapshot-more { font-size: 10px; color: #409eff; text-align: center; cursor: pointer; padding: 1px; }
|
||||
.node-click-hint { font-size: 10px; color: #bbb; text-align: center; margin-top: 2px; }
|
||||
|
||||
.node-plan-summary {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-top: 4px; padding: 4px 6px;
|
||||
background: #f8f9fa; border-radius: 4px;
|
||||
font-size: 11px; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.node-plan-summary:hover { background: #ecf5ff; }
|
||||
.plan-summary-icon { font-size: 12px; }
|
||||
.plan-summary-text { color: #409eff; font-weight: 500; flex-shrink: 0; }
|
||||
.plan-summary-badges { display: flex; gap: 4px; margin-left: auto; }
|
||||
.ps-badge { font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 500; }
|
||||
.ps-overdue { background: #fef0f0; color: #f56c6c; }
|
||||
.ps-in-progress { background: #fdf6ec; color: #e6a23c; }
|
||||
.ps-done { background: #f0f9eb; color: #67c23a; }
|
||||
|
||||
.node-progress-bar {
|
||||
display: flex; align-items: center; gap: 6px; margin-top: 4px;
|
||||
height: 10px; position: relative;
|
||||
}
|
||||
.node-progress-fill {
|
||||
height: 6px; border-radius: 3px; transition: width 0.4s ease;
|
||||
min-width: 4px;
|
||||
}
|
||||
.node-progress-text {
|
||||
font-size: 10px; font-weight: 600; color: #666;
|
||||
font-variant-numeric: tabular-nums; line-height: 1;
|
||||
}
|
||||
|
||||
.node-actions { display: flex; align-items: center; gap: 2px; margin-top: 6px; justify-content: flex-end; position: relative; z-index: 20; }
|
||||
.node-actions .el-button { padding: 2px 4px !important; font-size: 12px; min-height: auto; }
|
||||
|
||||
.link-btn-corner {
|
||||
position: absolute; top: 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;
|
||||
}
|
||||
.map-node:hover .link-btn-corner { opacity: 1; }
|
||||
.link-btn-corner:hover { transform: scale(1.2); background: #409eff; color: #fff; }
|
||||
|
||||
.node-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; }
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div v-if="visible" class="mc-dialog-overlay" @click.self="emit('update:visible', false)">
|
||||
<div class="mc-dialog-box">
|
||||
<div class="mc-dialog-header">
|
||||
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
|
||||
<button class="mc-dialog-close" @click="emit('update:visible', false)">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body">
|
||||
<!-- 目标名称(必填) -->
|
||||
<div class="mc-form-item">
|
||||
<label>目标名称 <span class="mc-required">*</span></label>
|
||||
<input v-model="localForm.name" class="mc-input" placeholder="请输入目标名称" ref="nameInputRef" />
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="mc-form-item">
|
||||
<label>描述</label>
|
||||
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="2" placeholder="目标描述(可选)"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 目标值 -->
|
||||
<div class="mc-form-item">
|
||||
<label>目标值</label>
|
||||
<input v-model.number="localForm.targetValue" class="mc-input" type="number" placeholder="预期达成值" />
|
||||
</div>
|
||||
|
||||
<!-- 当前值 -->
|
||||
<div class="mc-form-item">
|
||||
<label>当前值</label>
|
||||
<input v-model.number="localForm.currentValue" class="mc-input" type="number" placeholder="当前实际值" />
|
||||
</div>
|
||||
|
||||
<!-- 单位 -->
|
||||
<div class="mc-form-row">
|
||||
<div class="mc-form-item mc-form-item-flex">
|
||||
<label>单位</label>
|
||||
<input v-model="localForm.unit" class="mc-input" placeholder="如:万元、%" />
|
||||
</div>
|
||||
<div class="mc-form-item mc-form-item-flex">
|
||||
<label>责任人</label>
|
||||
<input v-model="localForm.owner" class="mc-input" placeholder="责任人姓名" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 领先/滞后指标 -->
|
||||
<div class="mc-form-item">
|
||||
<label>指标类型</label>
|
||||
<div class="mc-leading-switch">
|
||||
<label class="mc-radio-label" :class="{ active: !localForm.isLeading }">
|
||||
<input type="radio" v-model="localForm.isLeading" :value="false" />
|
||||
滞后指标(结果)
|
||||
</label>
|
||||
<label class="mc-radio-label" :class="{ active: localForm.isLeading }">
|
||||
<input type="radio" v-model="localForm.isLeading" :value="true" />
|
||||
领先指标(驱动)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<button class="mc-btn" @click="emit('update:visible', false)">取消</button>
|
||||
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
nodeData: any | null
|
||||
layerKey: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
(e: 'save', data: {
|
||||
name: string
|
||||
description: string
|
||||
targetValue: number | null
|
||||
currentValue: number | null
|
||||
unit: string
|
||||
owner: string
|
||||
isLeading: boolean
|
||||
layer: string
|
||||
icon?: string
|
||||
kpis?: string[]
|
||||
}): void
|
||||
}>()
|
||||
|
||||
const nameInputRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const defaultForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
targetValue: null as number | null,
|
||||
currentValue: null as number | null,
|
||||
unit: '%',
|
||||
owner: '',
|
||||
isLeading: false,
|
||||
icon: 'target',
|
||||
kpis: [] as string[],
|
||||
}
|
||||
|
||||
const localForm = reactive({ ...defaultForm })
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
if (props.nodeData) {
|
||||
// 编辑模式
|
||||
isEditing.value = true
|
||||
localForm.name = props.nodeData.name || ''
|
||||
localForm.description = props.nodeData.description || ''
|
||||
localForm.targetValue = props.nodeData.targetValue ?? null
|
||||
localForm.currentValue = props.nodeData.currentValue ?? null
|
||||
localForm.unit = props.nodeData.unit || '%'
|
||||
localForm.owner = props.nodeData.owner || ''
|
||||
localForm.isLeading = props.nodeData.isLeading ?? false
|
||||
localForm.icon = props.nodeData.icon || 'target'
|
||||
localForm.kpis = props.nodeData.kpis ? [...props.nodeData.kpis] : []
|
||||
} else {
|
||||
// 新增模式 - 根据层智能设置默认单位
|
||||
isEditing.value = false
|
||||
Object.assign(localForm, { ...defaultForm })
|
||||
const layerDefaults: Record<string, { unit: string; isLeading: boolean }> = {
|
||||
financial: { unit: '万元', isLeading: false },
|
||||
customer: { unit: '%', isLeading: true },
|
||||
process: { unit: '%', isLeading: true },
|
||||
learning: { unit: '%', isLeading: true },
|
||||
}
|
||||
const defaults = layerDefaults[props.layerKey]
|
||||
if (defaults) {
|
||||
localForm.unit = defaults.unit
|
||||
localForm.isLeading = defaults.isLeading
|
||||
}
|
||||
}
|
||||
nextTick(() => nameInputRef.value?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
function onSave() {
|
||||
if (!localForm.name?.trim()) {
|
||||
ElMessage.warning('请输入目标名称')
|
||||
nextTick(() => nameInputRef.value?.focus())
|
||||
return
|
||||
}
|
||||
emit('save', {
|
||||
name: localForm.name.trim(),
|
||||
description: localForm.description?.trim() || '',
|
||||
targetValue: localForm.targetValue ?? null,
|
||||
currentValue: localForm.currentValue ?? null,
|
||||
unit: localForm.unit?.trim() || '%',
|
||||
owner: localForm.owner?.trim() || '',
|
||||
isLeading: localForm.isLeading ?? false,
|
||||
layer: props.layerKey,
|
||||
icon: localForm.icon || 'target',
|
||||
kpis: [...localForm.kpis],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mc-dialog-overlay {
|
||||
position: fixed; top:0; left:0; right:0; bottom:0;
|
||||
background:rgba(0,0,0,0.5); z-index:9999;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.mc-dialog-box {
|
||||
background:#fff; border-radius:12px; width:520px;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.18); overflow:hidden;
|
||||
}
|
||||
.mc-dialog-header {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
padding:16px 20px; border-bottom:1px solid #f0f0f0;
|
||||
font-weight:600; font-size:16px;
|
||||
}
|
||||
.mc-dialog-close {
|
||||
background:none; border:none; font-size:22px; color:#999;
|
||||
cursor:pointer; padding:0 4px; line-height:1;
|
||||
}
|
||||
.mc-dialog-close:hover { color:#333; }
|
||||
.mc-dialog-body { padding:20px; }
|
||||
.mc-dialog-footer {
|
||||
display:flex; justify-content:flex-end; gap:10px;
|
||||
padding:12px 20px; border-top:1px solid #f0f0f0;
|
||||
}
|
||||
.mc-form-item { margin-bottom:16px; }
|
||||
.mc-form-item label {
|
||||
display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500;
|
||||
}
|
||||
.mc-form-row {
|
||||
display:flex; gap:12px;
|
||||
}
|
||||
.mc-form-item-flex {
|
||||
flex:1;
|
||||
}
|
||||
.mc-required { color:#f56c6c; margin-left:2px; }
|
||||
.mc-input {
|
||||
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
|
||||
border-radius:6px; font-size:14px; outline:none; box-sizing:border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.mc-input:focus { border-color:#409eff; }
|
||||
.mc-input[type="number"] { font-variant-numeric: tabular-nums; }
|
||||
.mc-textarea { resize:vertical; min-height:50px; font-family:inherit; }
|
||||
.mc-btn {
|
||||
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
|
||||
background:#fff; color:#333; font-size:14px; cursor:pointer;
|
||||
}
|
||||
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
|
||||
.mc-btn:hover { opacity:0.85; }
|
||||
.mc-leading-switch {
|
||||
display:flex; gap:12px;
|
||||
}
|
||||
.mc-radio-label {
|
||||
display:flex; align-items:center; gap:6px;
|
||||
padding:8px 14px; border:2px solid #e8e8e8; border-radius:8px;
|
||||
cursor:pointer; font-size:13px; color:#666; transition:all .15s;
|
||||
flex:1; justify-content:center;
|
||||
}
|
||||
.mc-radio-label:hover { border-color:#409eff; }
|
||||
.mc-radio-label.active {
|
||||
border-color:#409eff; background:#ecf5ff; color:#409eff; font-weight:500;
|
||||
}
|
||||
.mc-radio-label input { display:none; }
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div v-if="visible" class="mc-dialog-overlay" @click.self="$emit('close')">
|
||||
<div class="mc-dialog-box">
|
||||
<div class="mc-dialog-header">
|
||||
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
|
||||
<button class="mc-dialog-close" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body">
|
||||
<div class="mc-form-item">
|
||||
<label>目标名称 <span class="mc-required">*</span></label>
|
||||
<input v-model="localForm.name" class="mc-input" placeholder="请输入目标名称" ref="nameInputRef" />
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>描述</label>
|
||||
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>图标</label>
|
||||
<div class="mc-icon-picker">
|
||||
<button
|
||||
v-for="(ico, key) in iconOptions" :key="key"
|
||||
class="mc-icon-btn"
|
||||
:class="{ active: localForm.icon === key }"
|
||||
@click="localForm.icon = key"
|
||||
:title="ico"
|
||||
>{{ iconEmoji(key) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-form-item">
|
||||
<label>关联KPI <span class="mc-hint">(最多3个)</span></label>
|
||||
<div class="mc-kpi-select-wrap">
|
||||
<div class="mc-kpi-tags" v-if="localForm.kpis.length > 0">
|
||||
<span class="mc-kpi-tag" v-for="(code, i) in localForm.kpis" :key="code">
|
||||
{{ getKpiName(code) }}
|
||||
<button class="mc-tag-remove" @click="localForm.kpis.splice(i, 1)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mc-kpi-add-row" v-if="localForm.kpis.length < 3 && allKpis.length > 0">
|
||||
<select class="mc-select mc-select-sm" @change="addKpi($event)" style="flex:1;">
|
||||
<option value="">+ 选择KPI({{ dimLabel }})</option>
|
||||
<option v-for="k in availableKpis" :key="k.id" :value="k.kpi_code">{{ k.kpi_name }}</option>
|
||||
<option value="__new__">+ 新建KPI...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="allKpis.length === 0" style="color:#999;font-size:12px;">
|
||||
暂无该维度KPI,
|
||||
<el-button text size="small" type="primary" @click="onNewKpi">点击新建</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<button class="mc-btn" @click="$emit('close')">取消</button>
|
||||
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
form: { name: string; description: string; icon: string; kpis: string[] }
|
||||
dimKey: string
|
||||
dimLabel: string
|
||||
isEditing: boolean
|
||||
allKpis: any[]
|
||||
kpiNameMap: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'save', form: { name: string; description: string; icon: string; kpis: string[] }): void
|
||||
(e: 'new-kpi'): void
|
||||
}>()
|
||||
|
||||
const nameInputRef = ref<HTMLElement | null>(null)
|
||||
const localForm = reactive({ name: '', description: '', icon: 'target', kpis: [] as string[] })
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
localForm.name = props.form.name
|
||||
localForm.description = props.form.description
|
||||
localForm.icon = props.form.icon || 'target'
|
||||
localForm.kpis = [...(props.form.kpis || [])]
|
||||
nextTick(() => nameInputRef.value?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
const iconOptions: Record<string, string> = {
|
||||
target: '目标', star: '星级', rocket: '火箭', chart: '图表', team: '团队',
|
||||
light: '灯泡', shield: '盾牌', gear: '齿轮', handshake: '握手', medal: '奖牌',
|
||||
}
|
||||
|
||||
function iconEmoji(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
target: '🎯', star: '⭐', rocket: '🚀', chart: '📊', team: '👥',
|
||||
light: '💡', shield: '🛡️', gear: '⚙️', handshake: '🤝', medal: '🏅',
|
||||
}
|
||||
return map[key] || '📌'
|
||||
}
|
||||
|
||||
const availableKpis = computed(() => {
|
||||
return props.allKpis.filter((k: any) => {
|
||||
if (k.dimension !== props.dimKey) return false
|
||||
if (localForm.kpis.includes(k.kpi_code)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function getKpiName(code: string): string {
|
||||
return props.kpiNameMap[code] || code
|
||||
}
|
||||
|
||||
function addKpi(e: any) {
|
||||
const val = e.target.value
|
||||
if (val === '__new__') {
|
||||
e.target.value = ''
|
||||
emit('new-kpi')
|
||||
return
|
||||
}
|
||||
if (val && !localForm.kpis.includes(val) && localForm.kpis.length < 3) {
|
||||
localForm.kpis.push(val)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function onNewKpi() {
|
||||
emit('new-kpi')
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (!localForm.name?.trim()) {
|
||||
ElMessage.warning('请输入目标名称')
|
||||
nextTick(() => nameInputRef.value?.focus())
|
||||
return
|
||||
}
|
||||
emit('save', {
|
||||
name: localForm.name.trim(),
|
||||
description: localForm.description?.trim() || '',
|
||||
icon: localForm.icon || 'target',
|
||||
kpis: [...localForm.kpis],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mc-dialog-overlay {
|
||||
position: fixed; top:0; left:0; right:0; bottom:0;
|
||||
background:rgba(0,0,0,0.5); z-index:9999;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.mc-dialog-box {
|
||||
background:#fff; border-radius:12px; width:520px;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.18); overflow:hidden;
|
||||
}
|
||||
.mc-dialog-header {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
padding:16px 20px; border-bottom:1px solid #f0f0f0;
|
||||
font-weight:600; font-size:16px;
|
||||
}
|
||||
.mc-dialog-close {
|
||||
background:none; border:none; font-size:22px; color:#999;
|
||||
cursor:pointer; padding:0 4px; line-height:1;
|
||||
}
|
||||
.mc-dialog-close:hover { color:#333; }
|
||||
.mc-dialog-body { padding:20px; }
|
||||
.mc-dialog-footer {
|
||||
display:flex; justify-content:flex-end; gap:10px;
|
||||
padding:12px 20px; border-top:1px solid #f0f0f0;
|
||||
}
|
||||
.mc-form-item { margin-bottom:16px; }
|
||||
.mc-form-item label {
|
||||
display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500;
|
||||
}
|
||||
.mc-required { color:#f56c6c; margin-left:2px; }
|
||||
.mc-hint { font-weight:400; color:#999; font-size:12px; }
|
||||
.mc-input {
|
||||
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
|
||||
border-radius:6px; font-size:14px; outline:none; box-sizing:border-box;
|
||||
}
|
||||
.mc-input:focus { border-color:#409eff; }
|
||||
.mc-textarea { resize:vertical; min-height:60px; font-family:inherit; }
|
||||
.mc-select {
|
||||
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
|
||||
border-radius:6px; font-size:14px; outline:none; background:#fff; box-sizing:border-box;
|
||||
}
|
||||
.mc-select:focus { border-color:#409eff; }
|
||||
.mc-select-sm { font-size:13px; padding:6px 10px; }
|
||||
.mc-btn {
|
||||
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
|
||||
background:#fff; color:#333; font-size:14px; cursor:pointer;
|
||||
}
|
||||
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
|
||||
.mc-btn:hover { opacity:0.85; }
|
||||
.mc-icon-picker {
|
||||
display:flex; flex-wrap:wrap; gap:6px;
|
||||
}
|
||||
.mc-icon-btn {
|
||||
width:40px; height:40px; display:flex; align-items:center; justify-content:center;
|
||||
font-size:20px; border:2px solid #e8e8e8; border-radius:8px;
|
||||
background:#fff; cursor:pointer; transition:all .15s;
|
||||
}
|
||||
.mc-icon-btn:hover { border-color:#409eff; transform:scale(1.1); }
|
||||
.mc-icon-btn.active { border-color:#409eff; background:#ecf5ff; box-shadow:0 0 0 2px rgba(64,158,255,0.2); }
|
||||
.mc-kpi-select-wrap { display:flex; flex-direction:column; gap:6px; }
|
||||
.mc-kpi-tags { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.mc-kpi-tag {
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
padding:4px 10px; background:#ecf5ff; color:#409eff;
|
||||
border-radius:4px; font-size:12px;
|
||||
}
|
||||
.mc-tag-remove {
|
||||
background:none; border:none; color:#a0cfff; cursor:pointer;
|
||||
font-size:14px; padding:0; line-height:1;
|
||||
}
|
||||
.mc-tag-remove:hover { color:#f56c6c; }
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
|
||||
<div class="layer-header" :style="{ background: color }">
|
||||
<span class="layer-icon">{{ icon }}</span>
|
||||
<span class="layer-name">{{ label }}</span>
|
||||
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
|
||||
<el-dropdown trigger="click" @command="onLayerAction">
|
||||
<el-button class="layer-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>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="layer-body" ref="bodyRef">
|
||||
<!-- vuedraggable: 每层独立group → 禁止跨层拖拽 -->
|
||||
<draggable
|
||||
v-if="objectives.length > 0"
|
||||
:list="objectives"
|
||||
:group="layerKey"
|
||||
item-key="name"
|
||||
:animation="150"
|
||||
@end="onDragEnd"
|
||||
class="layer-draggable"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="layer-node-wrapper">
|
||||
<MapNode
|
||||
:key="`${layerKey}-${index}`"
|
||||
:ref="el => setNodeRef(`${layerKey}-${index}`, el)"
|
||||
:objective="element"
|
||||
:node-key="`${layerKey}-${index}`"
|
||||
:index="index"
|
||||
:level="getNodeLevel(`${layerKey}-${index}`)"
|
||||
:progress-data="getNodeProgress(`${layerKey}-${index}`)"
|
||||
:draggable="false"
|
||||
:is-linking-source="linkingFromKey === `${layerKey}-${index}`"
|
||||
:is-linking-target="!!linkingFromKey && linkingFromKey !== `${layerKey}-${index}`"
|
||||
:is-drag-connect-target="dragConnectTarget === `${layerKey}-${index}`"
|
||||
:icon-map="iconMap"
|
||||
:kpi-name-map="kpiNameMap"
|
||||
:all-kpis="allKpis"
|
||||
@click="onNodeClick(layerKey, index, element)"
|
||||
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
|
||||
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
|
||||
@link-click="$emit('start-link', { key: `${layerKey}-${index}`, obj: element })"
|
||||
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, index, element)"
|
||||
@kpi-click="(code: string) => $emit('kpi-click', code)"
|
||||
@plan-click="$emit('show-plan-list', { dimKey: layerKey, idx: index, obj: element })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- 空状态占位提示 -->
|
||||
<div v-if="objectives.length === 0" class="layer-empty">
|
||||
<el-empty description="暂无目标" :image-size="60">
|
||||
<el-button text type="primary" size="small" @click="$emit('add-objective', layerKey)">
|
||||
点击 + 添加目标
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 底部添加按钮 -->
|
||||
<el-button text type="primary" size="small" class="layer-add-btn" @click="$emit('add-objective', layerKey)">
|
||||
+ 添加目标
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { MoreFilled } from '@element-plus/icons-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import MapNode from './MapNode.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
layerKey: string
|
||||
label: string
|
||||
icon: string
|
||||
color: string
|
||||
objectives: any[]
|
||||
linkingFromKey?: string | null
|
||||
dragConnectTarget?: string | null
|
||||
iconMap?: Record<string, string>
|
||||
kpiNameMap?: Record<string, string>
|
||||
allKpis?: any[]
|
||||
getNodeLevel: (key: string) => string
|
||||
getNodeProgress: (key: string) => any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'add-objective', dimKey: string): void
|
||||
(e: 'edit-objective', payload: { dimKey: string; idx: number; obj: any }): void
|
||||
(e: 'delete-objective', payload: { dimKey: string; idx: number }): void
|
||||
(e: 'start-link', payload: { key: string; obj: any }): void
|
||||
(e: 'link-drag-start', event: MouseEvent, dimKey: string, oi: number, obj: any): void
|
||||
(e: 'kpi-click', code: string): void
|
||||
(e: 'show-plan-list', payload: { dimKey: string; idx: number; obj: any }): void
|
||||
(e: 'node-click', dimKey: string, oi: number, obj: any): void
|
||||
(e: 'reorder', dimKey: string): void
|
||||
(e: 'layer-action', cmd: string): void
|
||||
(e: 'update:label', val: string): void
|
||||
(e: 'update:color', val: string): void
|
||||
(e: 'update:icon', val: string): void
|
||||
}>()
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Node DOM refs for line calculation (reactive so parent can aggregate)
|
||||
const nodeRefs = reactive<Record<string, any>>({})
|
||||
function setNodeRef(key: string, el: any) {
|
||||
if (el) nodeRefs[key] = el
|
||||
}
|
||||
|
||||
// Expose nodeRefs to parent for connection line calculation
|
||||
defineExpose({ nodeRefs, bodyRef })
|
||||
|
||||
function onLayerAction(cmd: string) {
|
||||
emit('layer-action', cmd)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽排序完成回调(由 vuedraggable/SortableJS 触发)
|
||||
* 由于 :list 传递的是父级响应式数组的引用,SortableJS 已直接完成了数组重排,
|
||||
* 此处仅需通知父级重算连线位置和触发保存
|
||||
*/
|
||||
function onDragEnd() {
|
||||
emit('reorder', props.layerKey)
|
||||
}
|
||||
|
||||
function onNodeClick(dimKey: string, oi: number, obj: any) {
|
||||
emit('node-click', dimKey, oi, obj)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.strategy-layer {
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
min-height: 120px;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
padding: 10px 14px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.layer-objective-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.layer-more-btn {
|
||||
color: rgba(255,255,255,0.7) !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
.layer-more-btn:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.layer-body {
|
||||
padding: 10px 14px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* vuedraggable 容器 */
|
||||
.layer-draggable {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* 每个节点包裹元素 */
|
||||
.layer-node-wrapper {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.layer-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* 底部添加按钮 */
|
||||
.layer-add-btn {
|
||||
width: 100% !important;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
|
||||
<div class="layer-header" :style="{ background: color }">
|
||||
<span class="layer-icon">{{ icon }}</span>
|
||||
<span class="layer-name">{{ label }}</span>
|
||||
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
|
||||
<el-dropdown trigger="click" @command="onLayerAction">
|
||||
<el-button class="layer-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>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="layer-body" ref="bodyRef">
|
||||
<MapNode
|
||||
v-for="(obj, oi) in objectives" :key="`${layerKey}-${oi}`"
|
||||
:ref="el => setNodeRef(`${layerKey}-${oi}`, el)"
|
||||
:objective="obj"
|
||||
:node-key="`${layerKey}-${oi}`"
|
||||
:index="oi"
|
||||
:level="getNodeLevel(`${layerKey}-${oi}`)"
|
||||
:progress-data="getNodeProgress(`${layerKey}-${oi}`)"
|
||||
:draggable="true"
|
||||
:is-linking-source="linkingFromKey === `${layerKey}-${oi}`"
|
||||
:is-linking-target="!!linkingFromKey && linkingFromKey !== `${layerKey}-${oi}`"
|
||||
:is-drag-connect-target="dragConnectTarget === `${layerKey}-${oi}`"
|
||||
:is-drag-over="dragOverNodeKey === `${layerKey}-${oi}`"
|
||||
:icon-map="iconMap"
|
||||
:kpi-name-map="kpiNameMap"
|
||||
:all-kpis="allKpis"
|
||||
@drag-start="(p: any) => onNodeDragStart(layerKey, oi, p.event)"
|
||||
@drag-over="onNodeDragOver(layerKey, oi)"
|
||||
@drag-leave="onNodeDragLeave"
|
||||
@drop="onNodeDrop(layerKey, oi)"
|
||||
@click="onNodeClick(layerKey, oi, obj)"
|
||||
@edit="$emit('edit-objective', { dimKey: layerKey, idx: oi, obj })"
|
||||
@delete="$emit('delete-objective', { dimKey: layerKey, idx: oi })"
|
||||
@link-click="$emit('start-link', { key: `${layerKey}-${oi}`, obj })"
|
||||
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, oi, obj)"
|
||||
@kpi-click="(code: string) => $emit('kpi-click', code)"
|
||||
@plan-click="$emit('show-plan-list', { dimKey: layerKey, idx: oi, obj })"
|
||||
/>
|
||||
<el-button text type="primary" size="small" style="margin-top:8px;width:100%;" @click="$emit('add-objective', layerKey)">
|
||||
+ 添加目标
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { MoreFilled } from '@element-plus/icons-vue'
|
||||
import MapNode from './MapNode.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
layerKey: string
|
||||
label: string
|
||||
icon: string
|
||||
color: string
|
||||
objectives: any[]
|
||||
linkingFromKey?: string | null
|
||||
dragConnectTarget?: string | null
|
||||
dragOverNodeKey?: string | null
|
||||
iconMap?: Record<string, string>
|
||||
kpiNameMap?: Record<string, string>
|
||||
allKpis?: any[]
|
||||
getNodeLevel: (key: string) => string
|
||||
getNodeProgress: (key: string) => any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'add-objective', dimKey: string): void
|
||||
(e: 'edit-objective', payload: { dimKey: string; idx: number; obj: any }): void
|
||||
(e: 'delete-objective', payload: { dimKey: string; idx: number }): void
|
||||
(e: 'start-link', payload: { key: string; obj: any }): void
|
||||
(e: 'link-drag-start', event: MouseEvent, dimKey: string, oi: number, obj: any): void
|
||||
(e: 'kpi-click', code: string): void
|
||||
(e: 'show-plan-list', payload: { dimKey: string; idx: number; obj: any }): void
|
||||
(e: 'node-click', dimKey: string, oi: number, obj: any): void
|
||||
(e: 'drag-start', dimKey: string, oi: number, event: DragEvent): void
|
||||
(e: 'drag-over', dimKey: string, oi: number): void
|
||||
(e: 'drag-leave'): void
|
||||
(e: 'drop', dimKey: string, oi: number): void
|
||||
(e: 'layer-action', cmd: string): void
|
||||
(e: 'update:label', val: string): void
|
||||
(e: 'update:color', val: string): void
|
||||
(e: 'update:icon', val: string): void
|
||||
}>()
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Node DOM refs for line calculation (reactive so parent can aggregate)
|
||||
const nodeRefs = reactive<Record<string, any>>({})
|
||||
function setNodeRef(key: string, el: any) {
|
||||
if (el) nodeRefs[key] = el
|
||||
}
|
||||
|
||||
// Expose nodeRefs to parent for connection line calculation
|
||||
defineExpose({ nodeRefs, bodyRef })
|
||||
|
||||
function onLayerAction(cmd: string) {
|
||||
emit('layer-action', cmd)
|
||||
}
|
||||
|
||||
function onNodeDragStart(dimKey: string, oi: number, event: DragEvent) {
|
||||
emit('drag-start', dimKey, oi, event)
|
||||
}
|
||||
function onNodeDragOver(dimKey: string, oi: number) {
|
||||
emit('drag-over', dimKey, oi)
|
||||
}
|
||||
function onNodeDragLeave() {
|
||||
emit('drag-leave')
|
||||
}
|
||||
function onNodeDrop(dimKey: string, oi: number) {
|
||||
emit('drop', dimKey, oi)
|
||||
}
|
||||
function onNodeClick(dimKey: string, oi: number, obj: any) {
|
||||
emit('node-click', dimKey, oi, obj)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.strategy-layer {
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
min-height: 120px;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
padding: 10px 14px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.layer-objective-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.layer-more-btn {
|
||||
color: rgba(255,255,255,0.7) !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
.layer-more-btn:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.layer-body {
|
||||
padding: 10px 14px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Each MapNode in a layer gets flex basis */
|
||||
.layer-body > .map-node-wrapper {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Adjust for the direct MapNode child */
|
||||
.layer-body :deep(.map-node) {
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 220px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Add objective button full width row */
|
||||
.layer-body > .el-button {
|
||||
width: 100% !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<svg class="connection-svg" ref="svgRef">
|
||||
<defs>
|
||||
<!-- 跨层箭头(↓方向) -->
|
||||
<marker
|
||||
id="arrow-down"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="5"
|
||||
refY="7"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0,0 10,3.5 0,7" fill="#909399" />
|
||||
</marker>
|
||||
<!-- 同层箭头(→方向) -->
|
||||
<marker
|
||||
id="arrow-right"
|
||||
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="arrow-active"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="10"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0,0 10,3.5 0,7" fill="#f56c6c" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- 跨层固定箭头:每层底部 → 下层顶部 -->
|
||||
<line
|
||||
v-for="(line, idx) in crossLayerLines"
|
||||
:key="'cross-' + idx"
|
||||
:x1="line.x1"
|
||||
:y1="line.y1"
|
||||
:x2="line.x2"
|
||||
:y2="line.y2"
|
||||
stroke="#909399"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="6,3"
|
||||
marker-end="url(#arrow-down)"
|
||||
class="cross-layer-line"
|
||||
/>
|
||||
|
||||
<!-- 同层用户手动连线箭头(→方向) -->
|
||||
<g v-for="(conn, idx) in layerConnections" :key="'conn-' + idx">
|
||||
<path
|
||||
:d="conn.path"
|
||||
:class="[
|
||||
'conn-line',
|
||||
{ 'conn-selected': selectedIdx === idx },
|
||||
]"
|
||||
:stroke="selectedIdx === idx ? '#f56c6c' : '#409eff'"
|
||||
:stroke-width="selectedIdx === idx ? 3 : 2"
|
||||
fill="none"
|
||||
marker-end="url(#arrow-right)"
|
||||
@click.stop="$emit('select-connection', idx)"
|
||||
@mouseenter="hoverIdx = idx"
|
||||
@mouseleave="hoverIdx = null"
|
||||
/>
|
||||
<!-- hover提示 -->
|
||||
<rect
|
||||
v-if="hoverIdx === idx"
|
||||
:x="conn.mx - 50"
|
||||
:y="conn.my - 10"
|
||||
width="100"
|
||||
height="20"
|
||||
rx="4"
|
||||
fill="rgba(0,0,0,0.65)"
|
||||
class="conn-hover-bg"
|
||||
/>
|
||||
<text
|
||||
v-if="hoverIdx === idx"
|
||||
:x="conn.mx"
|
||||
:y="conn.my + 4"
|
||||
text-anchor="middle"
|
||||
fill="#fff"
|
||||
font-size="11"
|
||||
class="conn-hover-text"
|
||||
>
|
||||
点击选中
|
||||
</text>
|
||||
<!-- 删除按钮(选中时) -->
|
||||
<g v-if="selectedIdx === idx">
|
||||
<rect
|
||||
:x="conn.mx - 20"
|
||||
:y="conn.my - 28"
|
||||
width="88"
|
||||
height="24"
|
||||
rx="12"
|
||||
fill="#f56c6c"
|
||||
class="del-btn-bg"
|
||||
@click.stop="$emit('delete-connection', idx)"
|
||||
/>
|
||||
<text
|
||||
:x="conn.mx + 24"
|
||||
:y="conn.my - 12"
|
||||
text-anchor="middle"
|
||||
fill="#fff"
|
||||
font-size="12"
|
||||
font-weight="bold"
|
||||
class="del-btn-text"
|
||||
@click.stop="$emit('delete-connection', idx)"
|
||||
>
|
||||
✕ 删除连线
|
||||
</text>
|
||||
</g>
|
||||
</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(#arrow-down)"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
|
||||
layerRefs: { type: Object, required: true },
|
||||
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
|
||||
nodeRefs: { type: Object, default: () => ({}) },
|
||||
/** 层顺序列表 */
|
||||
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
|
||||
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1'}] */
|
||||
connections: { type: Array, default: () => [] },
|
||||
/** 临时连线 */
|
||||
tempLine: { type: Object, default: null },
|
||||
/** 容器尺寸变化触发重算 */
|
||||
containerKey: { type: Number, default: 0 },
|
||||
/** 视角方向:cascade(级联↓)| causal(因果↑) */
|
||||
viewDirection: { type: String, default: 'cascade' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select-connection', 'delete-connection'])
|
||||
|
||||
const svgRef = ref(null)
|
||||
const hoverIdx = ref(null)
|
||||
const selectedIdx = ref(null)
|
||||
const layerConnections = ref([])
|
||||
const crossLayerLines = ref([])
|
||||
|
||||
/**
|
||||
* 计算跨层固定箭头
|
||||
* 级联视角(cascade):从上层底部中心 → 下层顶部中心(目标分解)
|
||||
* 因果视角(causal):从下层顶部中心 → 上层底部中心(因果驱动)
|
||||
*/
|
||||
function calcCrossLayerLines() {
|
||||
const lines = []
|
||||
const keys = props.layerKeys
|
||||
const isCausal = props.viewDirection === 'causal'
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const upperEl = props.layerRefs[keys[i]]
|
||||
const lowerEl = props.layerRefs[keys[i + 1]]
|
||||
if (!upperEl || !lowerEl) continue
|
||||
|
||||
const uRect = upperEl.getBoundingClientRect()
|
||||
const lRect = lowerEl.getBoundingClientRect()
|
||||
const svg = svgRef.value
|
||||
if (!svg) continue
|
||||
const svgRect = svg.getBoundingClientRect()
|
||||
|
||||
if (isCausal) {
|
||||
// 因果视角:箭头从下层顶部 → 上层底部
|
||||
lines.push({
|
||||
x1: lRect.left + lRect.width / 2 - svgRect.left,
|
||||
y1: lRect.top - svgRect.top,
|
||||
x2: uRect.left + uRect.width / 2 - svgRect.left,
|
||||
y2: uRect.bottom - svgRect.top,
|
||||
})
|
||||
} else {
|
||||
// 级联视角:箭头从上层底部 → 下层顶部(原行为)
|
||||
lines.push({
|
||||
x1: uRect.left + uRect.width / 2 - svgRect.left,
|
||||
y1: uRect.bottom - svgRect.top,
|
||||
x2: lRect.left + lRect.width / 2 - svgRect.left,
|
||||
y2: lRect.top - svgRect.top,
|
||||
})
|
||||
}
|
||||
}
|
||||
crossLayerLines.value = lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算同层用户连线
|
||||
* 使用贝塞尔曲线从源节点右侧 → 目标节点左侧
|
||||
*/
|
||||
function calcLayerConnections() {
|
||||
const result = []
|
||||
const svg = svgRef.value
|
||||
if (!svg) return
|
||||
const svgRect = svg.getBoundingClientRect()
|
||||
|
||||
for (const conn of props.connections) {
|
||||
const fromEl = props.nodeRefs[conn.from]
|
||||
const toEl = props.nodeRefs[conn.to]
|
||||
if (!fromEl || !toEl) continue
|
||||
|
||||
const fr = fromEl.getBoundingClientRect()
|
||||
const tr = toEl.getBoundingClientRect()
|
||||
|
||||
// 源节点右侧中点 → 目标节点左侧中点
|
||||
const x1 = fr.right - svgRect.left
|
||||
const y1 = fr.top + fr.height / 2 - svgRect.top
|
||||
const x2 = tr.left - svgRect.left
|
||||
const y2 = tr.top + tr.height / 2 - svgRect.top
|
||||
|
||||
// 贝塞尔曲线控制点
|
||||
const dx = Math.abs(x2 - x1) * 0.5
|
||||
const cp1x = x1 + dx
|
||||
const cp1y = y1
|
||||
const cp2x = x2 - dx
|
||||
const cp2y = y2
|
||||
|
||||
const path = `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`
|
||||
|
||||
result.push({
|
||||
path,
|
||||
mx: (x1 + x2) / 2,
|
||||
my: (y1 + y2) / 2,
|
||||
from: conn.from,
|
||||
to: conn.to,
|
||||
})
|
||||
}
|
||||
layerConnections.value = result
|
||||
}
|
||||
|
||||
function recalcAll() {
|
||||
calcCrossLayerLines()
|
||||
calcLayerConnections()
|
||||
}
|
||||
|
||||
// 暴露给父组件调用
|
||||
defineExpose({ recalcAll })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(recalcAll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connection-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.cross-layer-line {
|
||||
pointer-events: none;
|
||||
}
|
||||
.conn-line {
|
||||
cursor: pointer;
|
||||
transition: stroke 0.15s, stroke-width 0.15s;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
.conn-line:hover {
|
||||
stroke: #e6a23c !important;
|
||||
stroke-width: 4 !important;
|
||||
}
|
||||
.conn-selected {
|
||||
stroke: #f56c6c !important;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.del-btn-bg {
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
.del-btn-bg:hover {
|
||||
fill: #e74c3c !important;
|
||||
}
|
||||
.del-btn-text {
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
user-select: none;
|
||||
}
|
||||
.conn-hover-bg,
|
||||
.conn-hover-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<svg class="connection-svg" ref="svgRef">
|
||||
<defs>
|
||||
<!-- 跨层箭头(↓方向) -->
|
||||
<marker
|
||||
id="arrow-down"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="5"
|
||||
refY="7"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0,0 10,3.5 0,7" fill="#909399" />
|
||||
</marker>
|
||||
<!-- 同层箭头(→方向) -->
|
||||
<marker
|
||||
id="arrow-right"
|
||||
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="arrow-active"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="10"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0,0 10,3.5 0,7" fill="#f56c6c" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- 跨层固定箭头:每层底部 → 下层顶部 -->
|
||||
<line
|
||||
v-for="(line, idx) in crossLayerLines"
|
||||
:key="'cross-' + idx"
|
||||
:x1="line.x1"
|
||||
:y1="line.y1"
|
||||
:x2="line.x2"
|
||||
:y2="line.y2"
|
||||
stroke="#909399"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="6,3"
|
||||
marker-end="url(#arrow-down)"
|
||||
class="cross-layer-line"
|
||||
/>
|
||||
|
||||
<!-- 同层用户手动连线箭头(→方向) -->
|
||||
<g v-for="(conn, idx) in layerConnections" :key="'conn-' + idx">
|
||||
<path
|
||||
:d="conn.path"
|
||||
:class="[
|
||||
'conn-line',
|
||||
{ 'conn-selected': selectedIdx === idx },
|
||||
]"
|
||||
:stroke="selectedIdx === idx ? '#f56c6c' : '#409eff'"
|
||||
:stroke-width="selectedIdx === idx ? 3 : 2"
|
||||
fill="none"
|
||||
marker-end="url(#arrow-right)"
|
||||
@click.stop="$emit('select-connection', idx)"
|
||||
@mouseenter="hoverIdx = idx"
|
||||
@mouseleave="hoverIdx = null"
|
||||
/>
|
||||
<!-- hover提示 -->
|
||||
<rect
|
||||
v-if="hoverIdx === idx"
|
||||
:x="conn.mx - 50"
|
||||
:y="conn.my - 10"
|
||||
width="100"
|
||||
height="20"
|
||||
rx="4"
|
||||
fill="rgba(0,0,0,0.65)"
|
||||
class="conn-hover-bg"
|
||||
/>
|
||||
<text
|
||||
v-if="hoverIdx === idx"
|
||||
:x="conn.mx"
|
||||
:y="conn.my + 4"
|
||||
text-anchor="middle"
|
||||
fill="#fff"
|
||||
font-size="11"
|
||||
class="conn-hover-text"
|
||||
>
|
||||
点击选中
|
||||
</text>
|
||||
<!-- 删除按钮(选中时) -->
|
||||
<g v-if="selectedIdx === idx">
|
||||
<rect
|
||||
:x="conn.mx - 20"
|
||||
:y="conn.my - 28"
|
||||
width="88"
|
||||
height="24"
|
||||
rx="12"
|
||||
fill="#f56c6c"
|
||||
class="del-btn-bg"
|
||||
@click.stop="$emit('delete-connection', idx)"
|
||||
/>
|
||||
<text
|
||||
:x="conn.mx + 24"
|
||||
:y="conn.my - 12"
|
||||
text-anchor="middle"
|
||||
fill="#fff"
|
||||
font-size="12"
|
||||
font-weight="bold"
|
||||
class="del-btn-text"
|
||||
@click.stop="$emit('delete-connection', idx)"
|
||||
>
|
||||
✕ 删除连线
|
||||
</text>
|
||||
</g>
|
||||
</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(#arrow-down)"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
|
||||
layerRefs: { type: Object, required: true },
|
||||
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
|
||||
nodeRefs: { type: Object, default: () => ({}) },
|
||||
/** 层顺序列表 */
|
||||
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
|
||||
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1'}] */
|
||||
connections: { type: Array, default: () => [] },
|
||||
/** 临时连线 */
|
||||
tempLine: { type: Object, default: null },
|
||||
/** 容器尺寸变化触发重算 */
|
||||
containerKey: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select-connection', 'delete-connection'])
|
||||
|
||||
const svgRef = ref(null)
|
||||
const hoverIdx = ref(null)
|
||||
const selectedIdx = ref(null)
|
||||
const layerConnections = ref([])
|
||||
const crossLayerLines = ref([])
|
||||
|
||||
/**
|
||||
* 计算跨层固定箭头
|
||||
* 从上层底部中心 → 下层顶部中心
|
||||
*/
|
||||
function calcCrossLayerLines() {
|
||||
const lines = []
|
||||
const keys = props.layerKeys
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const fromEl = props.layerRefs[keys[i]]
|
||||
const toEl = props.layerRefs[keys[i + 1]]
|
||||
if (!fromEl || !toEl) continue
|
||||
|
||||
const fr = fromEl.getBoundingClientRect()
|
||||
const tr = toEl.getBoundingClientRect()
|
||||
const svg = svgRef.value
|
||||
if (!svg) continue
|
||||
const svgRect = svg.getBoundingClientRect()
|
||||
|
||||
lines.push({
|
||||
x1: fr.left + fr.width / 2 - svgRect.left,
|
||||
y1: fr.bottom - svgRect.top,
|
||||
x2: tr.left + tr.width / 2 - svgRect.left,
|
||||
y2: tr.top - svgRect.top,
|
||||
})
|
||||
}
|
||||
crossLayerLines.value = lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算同层用户连线
|
||||
* 使用贝塞尔曲线从源节点右侧 → 目标节点左侧
|
||||
*/
|
||||
function calcLayerConnections() {
|
||||
const result = []
|
||||
const svg = svgRef.value
|
||||
if (!svg) return
|
||||
const svgRect = svg.getBoundingClientRect()
|
||||
|
||||
for (const conn of props.connections) {
|
||||
const fromEl = props.nodeRefs[conn.from]
|
||||
const toEl = props.nodeRefs[conn.to]
|
||||
if (!fromEl || !toEl) continue
|
||||
|
||||
const fr = fromEl.getBoundingClientRect()
|
||||
const tr = toEl.getBoundingClientRect()
|
||||
|
||||
// 源节点右侧中点 → 目标节点左侧中点
|
||||
const x1 = fr.right - svgRect.left
|
||||
const y1 = fr.top + fr.height / 2 - svgRect.top
|
||||
const x2 = tr.left - svgRect.left
|
||||
const y2 = tr.top + tr.height / 2 - svgRect.top
|
||||
|
||||
// 贝塞尔曲线控制点
|
||||
const dx = Math.abs(x2 - x1) * 0.5
|
||||
const cp1x = x1 + dx
|
||||
const cp1y = y1
|
||||
const cp2x = x2 - dx
|
||||
const cp2y = y2
|
||||
|
||||
const path = `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`
|
||||
|
||||
result.push({
|
||||
path,
|
||||
mx: (x1 + x2) / 2,
|
||||
my: (y1 + y2) / 2,
|
||||
from: conn.from,
|
||||
to: conn.to,
|
||||
})
|
||||
}
|
||||
layerConnections.value = result
|
||||
}
|
||||
|
||||
function recalcAll() {
|
||||
calcCrossLayerLines()
|
||||
calcLayerConnections()
|
||||
}
|
||||
|
||||
// 暴露给父组件调用
|
||||
defineExpose({ recalcAll })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(recalcAll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connection-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.cross-layer-line {
|
||||
pointer-events: none;
|
||||
}
|
||||
.conn-line {
|
||||
cursor: pointer;
|
||||
transition: stroke 0.15s, stroke-width 0.15s;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
.conn-line:hover {
|
||||
stroke: #e6a23c !important;
|
||||
stroke-width: 4 !important;
|
||||
}
|
||||
.conn-selected {
|
||||
stroke: #f56c6c !important;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.del-btn-bg {
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
.del-btn-bg:hover {
|
||||
fill: #e74c3c !important;
|
||||
}
|
||||
.del-btn-text {
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
user-select: none;
|
||||
}
|
||||
.conn-hover-bg,
|
||||
.conn-hover-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 四层战略地图泳道配置常量
|
||||
* 管理会计OS — BSC四层因果链
|
||||
*
|
||||
* 颜色规范:
|
||||
* 财务层 #F56C6C (红) — 滞后指标(结果)
|
||||
* 客户层 #409EFF (蓝) — 领先指标(驱动)
|
||||
* 流程层 #67C23A (绿) — 领先指标(驱动)
|
||||
* 学习层 #E6A23C (橙) — 领先指标(根本驱动)
|
||||
*/
|
||||
|
||||
export const LAYER_KEYS = ['financial', 'customer', 'process', 'learning']
|
||||
|
||||
/** 每层默认的颜色/图标/预设节点 */
|
||||
export const LAYER_CONFIG = {
|
||||
financial: {
|
||||
key: 'financial',
|
||||
label: '财务层',
|
||||
enLabel: 'Financial',
|
||||
icon: '💰',
|
||||
iconComponent: 'Money',
|
||||
color: '#F56C6C',
|
||||
bgColor: '#FEF0F0',
|
||||
lightColor: '#FDE2E2',
|
||||
order: 1,
|
||||
description: '股东价值目标 — 滞后指标(结果)',
|
||||
defaultNodes: [
|
||||
{ name: '营收目标', targetValue: null, unit: '万元', isLeading: false },
|
||||
{ name: '净利润率', targetValue: null, unit: '%', isLeading: false },
|
||||
{ name: '现金流', targetValue: null, unit: '万元', isLeading: false },
|
||||
],
|
||||
},
|
||||
customer: {
|
||||
key: 'customer',
|
||||
label: '客户层',
|
||||
enLabel: 'Customer',
|
||||
icon: '👥',
|
||||
iconComponent: 'User',
|
||||
color: '#409EFF',
|
||||
bgColor: '#ECF5FF',
|
||||
lightColor: '#D9ECFF',
|
||||
order: 2,
|
||||
description: '客户价值主张 — 领先指标(驱动)',
|
||||
defaultNodes: [
|
||||
{ name: '客户满意度', targetValue: null, unit: '%', isLeading: true },
|
||||
{ name: '市场份额', targetValue: null, unit: '%', isLeading: true },
|
||||
{ name: '客户保留率', targetValue: null, unit: '%', isLeading: true },
|
||||
],
|
||||
},
|
||||
process: {
|
||||
key: 'process',
|
||||
label: '内部流程层',
|
||||
enLabel: 'Internal Process',
|
||||
icon: '⚙️',
|
||||
iconComponent: 'Setting',
|
||||
color: '#67C23A',
|
||||
bgColor: '#F0F9EB',
|
||||
lightColor: '#E1F3D8',
|
||||
order: 3,
|
||||
description: '流程卓越 — 领先指标(驱动)',
|
||||
defaultNodes: [
|
||||
{ name: '运营效率', targetValue: null, unit: '%', isLeading: true },
|
||||
{ name: '质量合格率', targetValue: null, unit: '%', isLeading: true },
|
||||
],
|
||||
},
|
||||
learning: {
|
||||
key: 'learning',
|
||||
label: '学习成长层',
|
||||
enLabel: 'Learning & Growth',
|
||||
icon: '📚',
|
||||
iconComponent: 'Reading',
|
||||
color: '#E6A23C',
|
||||
bgColor: '#FDF6EC',
|
||||
lightColor: '#FAECD8',
|
||||
order: 4,
|
||||
description: '无形资产 — 领先指标(根本驱动)',
|
||||
defaultNodes: [
|
||||
{ name: '关键岗位胜任度', targetValue: null, unit: '%', isLeading: true },
|
||||
{ name: '培训完成率', targetValue: null, unit: '%', isLeading: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/** 根据层KEY获取颜色 */
|
||||
export function getLayerColor(layerKey) {
|
||||
return LAYER_CONFIG[layerKey]?.color || '#909399'
|
||||
}
|
||||
|
||||
/** 根据层KEY获取中文标签 */
|
||||
export function getLayerLabel(layerKey) {
|
||||
return LAYER_CONFIG[layerKey]?.label || layerKey
|
||||
}
|
||||
|
||||
/** 根据层KEY获取英文标签 */
|
||||
export function getLayerEnLabel(layerKey) {
|
||||
return LAYER_CONFIG[layerKey]?.enLabel || layerKey
|
||||
}
|
||||
|
||||
/** 获取所有层的配置数组(按order排序) */
|
||||
export function getLayerList() {
|
||||
return LAYER_KEYS.map((k) => LAYER_CONFIG[k]).sort(
|
||||
(a, b) => a.order - b.order
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算节点状态
|
||||
* @param {number|null} currentValue
|
||||
* @param {number|null} targetValue
|
||||
* @param {boolean} isLeading - 领先指标
|
||||
* @returns {'success'|'warning'|'danger'|'info'}
|
||||
*/
|
||||
export function computeNodeStatus(currentValue, targetValue) {
|
||||
if (currentValue == null || targetValue == null) return 'info'
|
||||
const ratio = targetValue === 0 ? 0 : currentValue / targetValue
|
||||
if (ratio >= 1.0) return 'success'
|
||||
if (ratio >= 0.8) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div style="padding:0 16px;">
|
||||
<!-- 顶部标题+操作 -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h3 style="margin:0;">行动方案库</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索标题/KPI名称/编码" clearable style="width:240px;" @clear="loadData" @keyup.enter="loadData" />
|
||||
<el-button type="primary" @click="openCreate">+ 新建行动方案</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态统计卡片 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;">
|
||||
<div v-for="card in statCards" :key="card.key"
|
||||
:class="['stat-card', { active: filterStatus === card.key }]"
|
||||
@click="filterStatus = card.key; page = 1; loadData()"
|
||||
style="cursor:pointer;min-width:100px;padding:14px 20px;border-radius:8px;border:2px solid transparent;transition:all .2s;background:#f5f7fa;text-align:center;">
|
||||
<div style="font-size:12px;color:#909399;">{{ card.label }}</div>
|
||||
<div style="font-size:24px;font-weight:600;margin-top:4px;" :style="{ color: card.color }">{{ stats[card.key] ?? '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:100px;" @change="page=1;loadData()">
|
||||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filterKpi" placeholder="关联KPI" clearable filterable style="width:180px;" @change="page=1;loadData()">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filterAssignee" placeholder="负责人" clearable filterable style="width:120px;" @change="page=1;loadData()">
|
||||
<el-option v-for="u in userOptions" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<el-select v-model="filterPriority" placeholder="优先级" clearable style="width:100px;" @change="page=1;loadData()">
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="低" value="low" />
|
||||
</el-select>
|
||||
<el-button v-if="hasFilter" size="small" @click="clearFilter">清除筛选</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="list" v-loading="loading" border stripe size="small" style="width:100%;" @selection-change="(sel: any[]) => selectedRows = sel">
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column label="行动方案" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<span style="font-weight:500;cursor:pointer;color:#409eff;" @click="viewDetail(row)">{{ row.title }}</span>
|
||||
<div v-if="row.target_value" style="font-size:11px;color:#67c23a;margin-top:2px;display:flex;align-items:center;gap:4px;">
|
||||
<span style="font-size:10px;border:1px solid #67c23a;border-radius:2px;padding:0 3px;line-height:14px;">S</span>
|
||||
{{ row.target_value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联KPI" width="180">
|
||||
<template #default="{ row }">
|
||||
<div style="font-size:12px;line-height:1.4;">
|
||||
<span style="color:#666;">{{ row.kpi_code }}</span><br>
|
||||
<span>{{ row.kpi_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="维度" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="dimTagType(row.kpi_dimension)">{{ dimLabel(row.kpi_dimension) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="90" prop="assignee" />
|
||||
<el-table-column label="优先级" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.priority === 'high' ? 'danger' : row.priority === 'low' ? 'info' : 'warning'">
|
||||
{{ row.priority === 'high' ? '高' : row.priority === 'low' ? '低' : '中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="120">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<el-progress :percentage="row.progress || 0" :stroke-width="8" style="flex:1;" />
|
||||
<span style="font-size:11px;color:#999;min-width:28px;">{{ row.progress || 0 }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截止日" width="100" prop="due_date">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!row.due_date" style="color:#ccc;">—</span>
|
||||
<span v-else :style="{ color: isOverdue(row) ? '#f56c6c' : '#666', fontWeight: isOverdue(row) ? 600 : 400 }">
|
||||
{{ row.due_date?.slice(0, 10) }}
|
||||
<el-tooltip v-if="isOverdue(row)" content="已逾期" placement="top"><span style="margin-left:2px;">⚠️</span></el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="editPlan(row)">编辑</el-button>
|
||||
<el-button size="small" link type="danger" @click="deletePlan(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<div v-if="selectedRows.length > 0" style="margin-top:8px;display:flex;gap:8px;align-items:center;padding:8px 12px;background:#f0f9ff;border-radius:6px;">
|
||||
<span style="font-size:13px;color:#409eff;">已选 {{ selectedRows.length }} 项</span>
|
||||
<el-button size="small" @click="batchSetStatus('in_progress')">置为进行中</el-button>
|
||||
<el-button size="small" type="success" @click="batchSetStatus('completed')">置为已完成</el-button>
|
||||
<el-button size="small" @click="clearSelected">取消选择</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div style="display:flex;justify-content:center;margin-top:16px;">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next, total" @current-change="loadData" background small />
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<MyDialog v-model="showForm" :title="formTitle" :width="550">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<el-tag size="small" type="success">S 具体的</el-tag>
|
||||
<el-tag size="small" type="warning">M 可衡量</el-tag>
|
||||
<el-tag size="small">A 可实现</el-tag>
|
||||
<el-tag size="small" type="primary">R 相关(已关联KPI)</el-tag>
|
||||
<el-tag size="small" type="danger">T 有时限(截止日)</el-tag>
|
||||
</div>
|
||||
<el-form :model="form" label-width="80px" size="small">
|
||||
<el-form-item label="行动标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入行动方案标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值" help="SMART:具体、可衡量的成功标准">
|
||||
<div style="display:flex;gap:4px;flex-direction:column;width:100%;">
|
||||
<el-input v-model="form.target_value" placeholder="例:满意度从85%提升至92%、不良率降低50%以下" />
|
||||
<span style="font-size:11px;color:#999;line-height:1.4;">设置可量化的成功标准,完成后对照核验</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联KPI" required>
|
||||
<el-select v-model="form.kpi_id" placeholder="选择关联KPI" filterable style="width:100%;" :teleported="false">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="详细描述(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-select v-model="form.assignee" placeholder="选择负责人" filterable allow-create style="width:100%;" :teleported="false">
|
||||
<el-option v-for="u in userOptions" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-radio-group v-model="form.priority">
|
||||
<el-radio value="high">高</el-radio>
|
||||
<el-radio value="medium">中</el-radio>
|
||||
<el-radio value="low">低</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="截止日期">
|
||||
<el-date-picker v-model="form.due_date" type="date" placeholder="选择截止日期" style="width:100%;" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId" label="状态">
|
||||
<el-select v-model="form.status" style="width:100%;" :teleported="false">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="进行中" value="in_progress" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId" label="进度 %">
|
||||
<el-slider v-model="form.progress" :min="0" :max="100" :step="5" show-input style="width:100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId && form.status === 'completed'" label="改善结果">
|
||||
<el-input v-model="form.result" type="textarea" :rows="3" placeholder="描述改善结果,对照目标值核验" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePlan" :loading="saving">{{ editingId ? '保存修改' : '创建' }}</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<MyDialog v-model="showDetail" :title="detailTitle" :width="600">
|
||||
<div v-if="detailPlan">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">关联KPI</div>
|
||||
<div style="margin-top:2px;">{{ detailPlan.kpi_code }} {{ detailPlan.kpi_name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">维度</div>
|
||||
<div style="margin-top:2px;">
|
||||
<el-tag size="small" :type="dimTagType(detailPlan.kpi_dimension)">{{ dimLabel(detailPlan.kpi_dimension) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">负责人</div>
|
||||
<div style="margin-top:2px;">{{ detailPlan.assignee || '未指定' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">优先级</div>
|
||||
<div style="margin-top:2px;">
|
||||
<el-tag size="small" :type="detailPlan.priority === 'high' ? 'danger' : detailPlan.priority === 'low' ? 'info' : 'warning'">
|
||||
{{ detailPlan.priority === 'high' ? '高' : detailPlan.priority === 'low' ? '低' : '中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">截止日期</div>
|
||||
<div style="margin-top:2px;" :class="{ 'overdue-text': isOverdue(detailPlan) }">
|
||||
{{ detailPlan.due_date?.slice(0, 10) || '未设置' }}
|
||||
<span v-if="isOverdue(detailPlan)" style="color:#f56c6c;font-size:12px;margin-left:4px;">已逾期</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:#999;">状态</div>
|
||||
<div style="margin-top:2px;">
|
||||
<el-tag size="small" :type="statusTagType(detailPlan.status)">{{ statusLabel(detailPlan.status) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<div style="font-size:12px;color:#999;">进度</div>
|
||||
<el-progress :percentage="detailPlan.progress || 0" :stroke-width="12" style="margin-top:4px;" />
|
||||
</div>
|
||||
<!-- 目标值 -->
|
||||
<div v-if="detailPlan.target_value" style="margin-top:12px;padding:10px 14px;background:#f0f9eb;border-radius:6px;border-left:3px solid #67c23a;">
|
||||
<div style="font-size:12px;color:#67c23a;font-weight:500;display:flex;align-items:center;gap:4px;">
|
||||
<span style="font-size:10px;border:1px solid #67c23a;border-radius:2px;padding:0 3px;line-height:14px;">SMART</span>
|
||||
目标值 / 成功标准
|
||||
</div>
|
||||
<div style="margin-top:4px;line-height:1.6;font-size:14px;">{{ detailPlan.target_value }}</div>
|
||||
</div>
|
||||
<div v-if="detailPlan.description" style="margin-top:12px;">
|
||||
<div style="font-size:12px;color:#999;">详细描述</div>
|
||||
<div style="margin-top:4px;line-height:1.6;background:#f9f9f9;padding:8px 12px;border-radius:4px;">{{ detailPlan.description }}</div>
|
||||
</div>
|
||||
<div v-if="detailPlan.result" style="margin-top:12px;">
|
||||
<div style="font-size:12px;color:#999;">改善结果</div>
|
||||
<div style="margin-top:4px;line-height:1.6;background:#f0f9eb;padding:8px 12px;border-radius:4px;">{{ detailPlan.result }}</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;font-size:12px;color:#ccc;display:flex;gap:16px;">
|
||||
<span>创建人: {{ detailPlan.created_by }}</span>
|
||||
<span>创建时间: {{ detailPlan.created_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showDetail = false">关闭</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { actionPlanApi, kpiApi, userApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// ── 数据 ──
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const stats = ref<Record<string, number>>({})
|
||||
const selectedRows = ref<any[]>([])
|
||||
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const userOptions = ref<string[]>([])
|
||||
|
||||
// ── 筛选 ──
|
||||
const filterStatus = ref('')
|
||||
const filterDimension = ref('')
|
||||
const filterKpi = ref<number | null>(null)
|
||||
const filterAssignee = ref('')
|
||||
const filterPriority = ref('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const dimensions = [
|
||||
{ value: 'finance', label: '财务' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'process', label: '内部流程' },
|
||||
{ value: 'learning', label: '学习成长' },
|
||||
]
|
||||
|
||||
const hasFilter = computed(() =>
|
||||
filterStatus.value || filterDimension.value || filterKpi.value ||
|
||||
filterAssignee.value || filterPriority.value || searchKeyword.value
|
||||
)
|
||||
|
||||
function clearFilter() {
|
||||
filterStatus.value = ''
|
||||
filterDimension.value = ''
|
||||
filterKpi.value = null
|
||||
filterAssignee.value = ''
|
||||
filterPriority.value = ''
|
||||
searchKeyword.value = ''
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
selectedRows.value = []
|
||||
}
|
||||
|
||||
// ── 统计卡片 ──
|
||||
const statCards = [
|
||||
{ key: '', label: '全部', color: '#409eff' },
|
||||
{ key: 'pending', label: '待处理', color: '#909399' },
|
||||
{ key: 'in_progress', label: '进行中', color: '#e6a23c' },
|
||||
{ key: 'completed', label: '已完成', color: '#67c23a' },
|
||||
{ key: 'overdue', label: '已逾期', color: '#f56c6c' },
|
||||
]
|
||||
|
||||
// ── 加载数据 ──
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterDimension.value) params.dimension = filterDimension.value
|
||||
if (filterKpi.value) params.kpi_id = filterKpi.value
|
||||
if (filterAssignee.value) params.assignee = filterAssignee.value
|
||||
if (filterPriority.value) params.priority = filterPriority.value
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||
|
||||
const r: any = await actionPlanApi.list(params)
|
||||
const d = r.data || []
|
||||
list.value = Array.isArray(d) ? d : []
|
||||
total.value = r.total || list.value.length
|
||||
} catch { ElMessage.error('加载行动方案失败') }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r: any = await actionPlanApi.stats()
|
||||
stats.value = r
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadKpis() {
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 500 })
|
||||
const d = r.data || r || []
|
||||
kpiOptions.value = Array.isArray(d) ? d : []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const r: any = await userApi.list()
|
||||
const d = r.data || []
|
||||
if (Array.isArray(d)) {
|
||||
userOptions.value = d.map((u: any) => u.name || u.username).filter(Boolean)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── 新建/编辑 ──
|
||||
const showForm = ref(false)
|
||||
const formTitle = ref('')
|
||||
const editingId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
const form = ref({
|
||||
title: '',
|
||||
target_value: '',
|
||||
kpi_id: null as number | null,
|
||||
description: '',
|
||||
assignee: '',
|
||||
priority: 'medium',
|
||||
due_date: null as string | null,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
result: '',
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
formTitle.value = '新建行动方案'
|
||||
form.value = { title: '', target_value: '', kpi_id: null, description: '', assignee: '', priority: 'medium', due_date: null, status: 'pending', progress: 0, result: '' }
|
||||
// 如果路由携带 kpi_id 参数,自动填入
|
||||
if (route.query.kpi_id) {
|
||||
form.value.kpi_id = Number(route.query.kpi_id)
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function editPlan(row: any) {
|
||||
editingId.value = row.id
|
||||
formTitle.value = '编辑行动方案'
|
||||
form.value = {
|
||||
title: row.title,
|
||||
target_value: row.target_value || '',
|
||||
kpi_id: row.kpi_id,
|
||||
description: row.description || '',
|
||||
assignee: row.assignee || '',
|
||||
priority: row.priority || 'medium',
|
||||
due_date: row.due_date?.slice(0, 10) || null,
|
||||
status: row.status || 'pending',
|
||||
progress: row.progress || 0,
|
||||
result: row.result || '',
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function savePlan() {
|
||||
if (!form.value.title || !form.value.kpi_id) {
|
||||
ElMessage.warning('请填写标题并选择关联KPI')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { ...form.value }
|
||||
if (editingId.value) {
|
||||
await actionPlanApi.update(editingId.value, data)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await actionPlanApi.create(data)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
showForm.value = false
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
// ── 删除 ──
|
||||
async function deletePlan(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此行动方案?')
|
||||
await actionPlanApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ── 批量操作 ──
|
||||
async function batchSetStatus(status: string) {
|
||||
const ids = selectedRows.value.map(r => r.id)
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将选中的 ${ids.length} 项置为「${status === 'completed' ? '已完成' : '进行中'}」?`)
|
||||
for (const id of ids) {
|
||||
await actionPlanApi.update(id, { status })
|
||||
}
|
||||
ElMessage.success(`已更新 ${ids.length} 项`)
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error('批量操作失败') }
|
||||
}
|
||||
|
||||
// ── 详情 ──
|
||||
const showDetail = ref(false)
|
||||
const detailTitle = ref('')
|
||||
const detailPlan = ref<any>(null)
|
||||
|
||||
function viewDetail(row: any) {
|
||||
detailPlan.value = row
|
||||
detailTitle.value = row.title
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
// ── 辅助函数 ──
|
||||
function isOverdue(row: any) {
|
||||
if (!row.due_date) return false
|
||||
if (row.status === 'completed' || row.status === 'cancelled') return false
|
||||
return new Date(row.due_date.slice(0, 10)) < new Date(new Date().toLocaleDateString())
|
||||
}
|
||||
|
||||
function dimTagType(dim: string) {
|
||||
const map: Record<string, string> = { finance: 'primary', customer: 'success', process: 'warning', learning: 'danger' }
|
||||
return map[dim] || 'info'
|
||||
}
|
||||
|
||||
function dimLabel(dim: string) {
|
||||
const map: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
return map[dim] || dim || '-'
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
const map: Record<string, string> = { pending: 'info', in_progress: 'warning', completed: 'success', cancelled: 'info' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// ── 初始化 ──
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadData()
|
||||
loadKpis()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card:hover {
|
||||
border-color: #dcdfe6;
|
||||
background: #fff;
|
||||
}
|
||||
.stat-card.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.overdue-text {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预算管理</h3>
|
||||
<div v-if="noMap" style="padding:60px 0;text-align:center;color:#999;">
|
||||
<p style="font-size:16px;margin-bottom:12px;">暂无已发布的战略地图</p>
|
||||
<p style="font-size:13px;">预算编制需要基于已发布的战略地图,请先在【战略地图】中创建并发布</p>
|
||||
<el-button type="primary" style="margin-top:16px;" @click="$router.push('/maps')">去创建战略地图</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<el-tab-pane label="预算录入" name="input">
|
||||
<div style="margin-bottom:8px;font-size:12px;color:#909399;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
选择战略地图
|
||||
<el-select v-model="selectedMapId" placeholder="选择已发布的战略地图" style="width:260px;" @change="onMapSelect">
|
||||
<el-option v-for="m in publishedMaps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<el-tag v-if="selectedMapId" size="small" type="success">已选择</el-tag>
|
||||
<span style="color:#999;">共 {{ total }} 条KPI</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
|
||||
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
|
||||
<el-button type="primary" @click="loadBudget">查询</el-button>
|
||||
<el-button type="success" @click="showAddBudget = true">+ 新增预算</el-button>
|
||||
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-radio-group v-model="budgetViewMode" size="small" @change="onViewModeChange"><el-radio-button value="list">KPI列表</el-radio-button><el-radio-button value="summary">维度汇总</el-radio-button></el-radio-group>
|
||||
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
|
||||
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
|
||||
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
|
||||
</div>
|
||||
<template v-if="budgetViewMode === 'list'">
|
||||
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
|
||||
<el-table-column type="index" label="#" width="40" />
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
|
||||
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="budget_value" label="预算值" width="170">
|
||||
<template #default="{ row }"><el-input-number v-if="row._editing" v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:155px;" @change="onEditChange(row)" /><span v-else>{{ formatNumber(row.budget_value, row.unit) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
<!-- 行动方案列(非财务维度显示) -->
|
||||
<el-table-column label="行动方案" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.dimension === 'finance'" style="color:#ccc;font-size:12px;">—</span>
|
||||
<el-button v-else size="small" link type="primary" @click="showActionPlans(row)">
|
||||
{{ row._actionCount != null ? `${row._actionCount}项` : '关联' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!batchEditMode && !row._editing" size="small" @click="startEdit(row)">编辑</el-button>
|
||||
<template v-else-if="!batchEditMode"><el-button size="small" type="primary" @click="saveEdit(row)" :loading="row._saving">保存</el-button><el-button size="small" @click="cancelEdit(row)">取消</el-button></template>
|
||||
<el-button v-if="!batchEditMode && row.id" size="small" :type="row.dimension === 'finance' ? 'info' : 'danger'" :disabled="row.dimension === 'finance'" @click="row.dimension !== 'finance' && doDelete(row)">{{ row.dimension === 'finance' ? '财务指标' : '删除' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-if="budgetViewMode === 'summary'">
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
|
||||
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + card.color }">
|
||||
<div style="text-align:center;"><div style="font-size:14px;margin-bottom:4px;">{{ card.icon }} {{ card.name }}</div><div style="font-size:24px;font-weight:600;color:#333;">{{ formatNumber(card.totalBudget) }}</div><div style="font-size:11px;color:#999;margin-top:4px;">{{ card.kpiCount }}个KPI · 占比{{ card.ratio }}%</div></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-table :data="dimSummaryRows" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="dimension" label="维度" width="100"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="kpiCount" label="KPI数" width="80" />
|
||||
<el-table-column prop="totalBudget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.totalBudget) }}</template></el-table-column>
|
||||
<el-table-column prop="ratio" label="占比" width="100"><template #default="{ row }"><el-progress :percentage="row.ratio" :stroke-width="12" text-inside /></template></el-table-column>
|
||||
<el-table-column prop="avgPerKpi" label="KPI均值" width="140"><template #default="{ row }">{{ formatNumber(row.avgPerKpi) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="年度分解" name="decompose">
|
||||
<el-card>
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center;"><span>年度预算 → 月度自动分解</span><el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button></div></template>
|
||||
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
|
||||
<el-form-item label="年份"><el-select v-model="decomposeForm.year" style="width:150px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
|
||||
<el-form-item label="分解方式"><el-radio-group v-model="decomposeForm.method"><el-radio value="equal">均分(1/12)</el-radio><el-radio value="weighted">按历史权重</el-radio></el-radio-group></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
|
||||
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="120" /><el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
|
||||
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
|
||||
<el-table-column prop="method" label="方式" width="80" /><el-table-column prop="monthly_count" label="月数" width="60" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="战略预算编制" name="strategy">
|
||||
<div v-if="!selectedMap" style="padding:40px 0;text-align:center;color:#999;"><p>请先在预算录入中选择战略地图</p></div>
|
||||
<template v-else>
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<span style="font-weight:500;">{{ selectedMap.title }}</span><el-tag size="small" type="success">已发布</el-tag>
|
||||
<el-select v-model="strategyFilterYear" placeholder="年份" style="width:100px;" @change="loadStrategyBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="strategyFilterMonth" placeholder="月份" style="width:90px;" @change="loadStrategyBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
|
||||
<el-button type="primary" size="small" @click="loadStrategyBudget">刷新</el-button>
|
||||
<el-divider direction="vertical" /><span style="font-size:13px;color:#666;">已选 {{ strategySelectedKpis.length }} 个KPI</span>
|
||||
<el-button size="small" @click="strategyBatchSetSame">统一设值</el-button>
|
||||
<el-input-number v-model="strategyBatchValue" :min="0" controls-position="right" style="width:120px;" size="small" />
|
||||
<el-button v-if="strategySelectedKpis.length > 0" size="small" type="primary" :loading="strategyBatchSaving" @click="strategyBatchSave">批量保存</el-button>
|
||||
</div>
|
||||
<div v-for="dim in strategyTree" :key="dim.key" class="strategy-dim-block">
|
||||
<div class="strategy-dim-header" :style="{ borderLeftColor: dim.color }">
|
||||
<span class="strategy-dim-icon">{{ dim.icon }}</span><span class="strategy-dim-name">{{ dim.name }}</span>
|
||||
<span class="strategy-dim-summary">({{ dim.kpiCount }}个KPI · 预算合计:{{ formatNumber(dim.totalBudget) }})</span>
|
||||
<el-button size="small" link style="margin-left:auto;" @click="strategySelectDim(dim.key)">全选</el-button>
|
||||
</div>
|
||||
<div v-for="obj in dim.objectives" :key="obj.name" class="strategy-obj-block">
|
||||
<div class="strategy-obj-header"><span class="strategy-obj-name">{{ obj.name }}</span></div>
|
||||
<el-table :data="obj.kpis" border stripe size="small" style="width:100%;" :show-header="false" @selection-change="(sel: any[]) => onStrategyKpiSelect(sel, dim.key)">
|
||||
<el-table-column type="selection" width="36" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
|
||||
<el-table-column label="预算值" width="190">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:150px;" @change="row._changed = (row._editValue !== (row.budget_value ?? 0))" />
|
||||
<span style="font-size:12px;color:#999;margin-left:2px;">{{ row.unit }}</span>
|
||||
<el-button v-if="row._changed" size="small" type="primary" link @click="saveStrategyKpi(row)" :loading="row._saving" style="margin-left:4px;">保存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="period" label="期间" width="90" /><el-table-column prop="version" label="版本" width="60" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="版本管理" name="versions">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<el-select v-model="versionYear" placeholder="年份" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-button type="primary" @click="loadVersions">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="versionsList" v-loading="versionsLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="version" label="版本" width="90"><template #default="{ row }"><el-tag :type="row.version === currentVersion ? 'success' : 'info'" size="small">{{ row.version }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="approval_status" label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag v-if="row.approval_status === 'approved'" type="success" size="small">已批准</el-tag><el-tag v-else-if="row.approval_status === 'submitted'" type="warning" size="small">待审批</el-tag><el-tag v-else-if="row.approval_status === 'rejected'" type="danger" size="small">已驳回</el-tag><el-tag v-else type="info" size="small">草稿</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plan_count" label="预算条目数" width="100" />
|
||||
<el-table-column prop="total_budget" label="预算总额" width="130"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
|
||||
<el-table-column prop="last_updated" label="最后更新" min-width="150" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.approval_status === 'draft'" size="small" @click="submitVersion(row)">提交审批</el-button>
|
||||
<template v-if="row.approval_status === 'submitted'"><el-button size="small" type="success" @click="approveVersion(row, 'approved')">批准</el-button><el-button size="small" type="danger" @click="approveVersion(row, 'rejected')">驳回</el-button></template>
|
||||
<el-button v-if="row.approval_status === 'rejected'" size="small" @click="resubmitVersion(row)">重新提交</el-button>
|
||||
<el-checkbox v-model="row._selected" @change="onVersionSelect(row)" :disabled="selectedVersions.length >= 2 && !row._selected" style="margin-left:8px;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="selectedVersions.length === 2" style="margin-top:16px;"><el-button type="primary" @click="loadVersionDiff" :loading="diffLoading">对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}</el-button></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="预算执行" name="execution">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="execFilterYear" placeholder="年份" style="width:100px;" @change="loadExecutionReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="execFilterMonth" placeholder="截止月份" style="width:110px;" @change="loadExecutionReport"><el-option v-for="m in 12" :key="m" :label="`至${m}月`" :value="m" /></el-select>
|
||||
<el-select v-model="execFilterDim" placeholder="维度" clearable style="width:110px;" @change="loadExecutionReport"><el-option label="财务" value="finance" /><el-option label="客户" value="customer" /><el-option label="内部流程" value="process" /><el-option label="学习成长" value="learning" /></el-select>
|
||||
<el-button type="primary" @click="loadExecutionReport">刷新</el-button>
|
||||
</div>
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6" v-for="card in execSummaryCards" :key="card.label"><el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ card.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: card.color}">{{ card.value }}</div></div></el-card></el-col>
|
||||
</el-row>
|
||||
<el-table :data="execReport" v-loading="execLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
|
||||
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
|
||||
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
|
||||
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
|
||||
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
|
||||
<el-form :model="addForm" label-width="80px">
|
||||
<el-form-item label="KPI"><el-select v-model="addForm.kpi_id" placeholder="搜索并选择KPI" filterable remote :remote-method="searchKpiForAdd" :loading="addKpiLoading" style="width:100%;" :teleported="false"><el-option v-for="k in addKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" /></el-select></el-form-item>
|
||||
<el-form-item label="年份"><el-select v-model="addForm.year" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
|
||||
<el-form-item label="月份"><el-select v-model="addForm.month" style="width:120px;"><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select></el-form-item>
|
||||
<el-form-item label="预算值"><el-input-number v-model="addForm.value" :min="0" :precision="0" style="width:200px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="showAddBudget = false">取消</el-button><el-button type="primary" @click="doAddBudget" :loading="addSaving">创建</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<MyDialog v-model="showVersionDiff" title="版本差异对比" :width="900">
|
||||
<template v-if="diffData">
|
||||
<el-alert show-icon :closable="false" style="margin-bottom:12px;"><template #title><span>对比 {{ diffData.summary.version_a }} → {{ diffData.summary.version_b }}</span></template></el-alert>
|
||||
<el-table :data="diffData.diffs" border stripe size="small" max-height="500" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="名称" min-width="140" /><el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="old_value" label="旧值" width="110"><template #default="{ row }">{{ formatNumber(row.old_value) }}</template></el-table-column>
|
||||
<el-table-column prop="new_value" label="新值" width="110"><template #default="{ row }">{{ formatNumber(row.new_value) }}</template></el-table-column>
|
||||
<el-table-column prop="diff_value" label="差值" width="110"><template #default="{ row }"><span :style="{color: row.diff_value > 0 ? '#f56c6c' : row.diff_value < 0 ? '#67c23a' : '#999'}">{{ row.diff_value > 0 ? '+' : '' }}{{ formatNumber(row.diff_value) }}</span></template></el-table-column>
|
||||
<el-table-column prop="diff_rate" label="变动率" width="90"><template #default="{ row }"><el-tag v-if="row.diff_rate === 0" size="small" type="info">持平</el-tag><el-tag v-else :type="row.diff_rate > 0 ? 'danger' : 'success'" size="small">{{ row.diff_rate > 0 ? '+' : '' }}{{ row.diff_rate }}%</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template #footer><el-button @click="showVersionDiff = false">关闭</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 行动方案关联弹窗 -->
|
||||
<MyDialog v-model="showActionDialog" :title="actionDialogTitle" :width="600">
|
||||
<template v-if="actionPlansForKpi.length > 0">
|
||||
<div style="margin-bottom:12px;font-size:13px;color:#666;">已关联 {{ actionPlansForKpi.length }} 项行动方案</div>
|
||||
<div v-for="plan in actionPlansForKpi" :key="plan.id" style="border:1px solid #e8e8e8;border-radius:8px;padding:12px;margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||
<div>
|
||||
<div style="font-weight:500;">{{ plan.title }}</div>
|
||||
<div v-if="plan.description" style="font-size:12px;color:#888;margin-top:4px;">{{ plan.description }}</div>
|
||||
</div>
|
||||
<el-tag v-if="plan.status === 'completed'" type="success" size="small">已完成</el-tag>
|
||||
<el-tag v-else-if="plan.status === 'in_progress'" type="warning" size="small">进行中</el-tag>
|
||||
<el-tag v-else type="info" size="small">{{ plan.status }}</el-tag>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;margin-top:8px;font-size:12px;color:#999;">
|
||||
<span>负责人: {{ plan.assignee || '未指定' }}</span>
|
||||
<span v-if="plan.due_date">截止: {{ plan.due_date?.slice(0,10) }}</span>
|
||||
<span>优先级: {{ plan.priority }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else style="padding:40px 0;text-align:center;color:#999;">
|
||||
<p>暂无关联行动方案</p>
|
||||
<p style="font-size:12px;margin-top:4px;">非财务维度KPI需要配套行动方案才能将战略意图转化为实际行动</p>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<el-button type="primary" @click="goCreateActionPlan">+ 新建行动方案</el-button>
|
||||
<el-button @click="goActionPlansPage">去行动方案库查看</el-button>
|
||||
</div>
|
||||
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const activeTab = ref('input')
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
|
||||
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref('')
|
||||
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
|
||||
const batchEditMode = ref(false); const batchSavingAll = ref(false)
|
||||
|
||||
// ── 战略地图选择 ──
|
||||
const publishedMaps = ref<any[]>([])
|
||||
const selectedMapId = ref<number | null>(null)
|
||||
const selectedMap = ref<any>(null)
|
||||
|
||||
async function loadPublishedMaps() {
|
||||
try {
|
||||
const r: any = await mapApi.list(); const maps = Array.isArray(r) ? r : (r.data || r.items || [])
|
||||
const published = maps.filter((m: any) => m.status === 'published')
|
||||
.sort((a: any, b: any) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime())
|
||||
publishedMaps.value = published
|
||||
noMap.value = published.length === 0
|
||||
// 如果有默认选中的地图ID没变,保持
|
||||
if (selectedMapId.value && published.find(m => m.id === selectedMapId.value)) return
|
||||
// 默认选中第一个
|
||||
if (published.length > 0) {
|
||||
selectedMapId.value = published[0].id
|
||||
selectedMap.value = published[0]
|
||||
}
|
||||
} catch { publishedMaps.value = []; noMap.value = true }
|
||||
}
|
||||
|
||||
function onMapSelect(id: number) {
|
||||
selectedMap.value = publishedMaps.value.find(m => m.id === id) || null
|
||||
loadBudget()
|
||||
}
|
||||
|
||||
function getMapKpiCodes(): Set<string> {
|
||||
const codes = new Set<string>(); if (!selectedMap.value) return codes
|
||||
let dims = selectedMap.value.dimensions
|
||||
if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { return codes } }
|
||||
if (!Array.isArray(dims)) return codes
|
||||
for (const dim of dims) for (const obj of (dim.objectives || [])) for (const code of (obj.kpis || [])) if (code) codes.add(code)
|
||||
return codes
|
||||
}
|
||||
|
||||
const budgetViewMode = ref('list'); const summaryCards = ref<any[]>([]); const dimSummaryRows = ref<any[]>([])
|
||||
function onViewModeChange(mode: string) { if (mode === 'summary') loadSummary() }
|
||||
function loadSummary() {
|
||||
const cfg: Record<string, any> = { finance: { n: '财务维度', i: '💰', c: '#409eff' }, customer: { n: '客户维度', i: '🤝', c: '#67c23a' }, process: { n: '内部流程', i: '⚙️', c: '#e6a23c' }, learning: { n: '学习成长', i: '📚', c: '#f56c6c' } }
|
||||
const dd: Record<string, { t: number; n: number }> = {}
|
||||
for (const item of budgetList.value) { const d = item.dimension || 'other'; if (!dd[d]) dd[d] = { t: 0, n: 0 }; dd[d].t += (item.budget_value || 0); dd[d].n++ }
|
||||
const tb = Object.values(dd).reduce((s, v) => s + v.t, 0); const cards: any[] = []; const rows: any[] = []
|
||||
for (const [k, v] of Object.entries(dd)) { const c = cfg[k] || { n: k, i: '📊', c: '#909399' }; const r = tb > 0 ? Math.round(v.t / tb * 100) : 0; cards.push({ key: k, name: c.n, icon: c.i, color: c.c, totalBudget: v.t, kpiCount: v.n, ratio: r }); rows.push({ dimension: k, kpiCount: v.n, totalBudget: v.t, ratio: r, avgPerKpi: v.n > 0 ? Math.round(v.t / v.n) : 0 }) }
|
||||
summaryCards.value = cards; dimSummaryRows.value = rows
|
||||
}
|
||||
|
||||
const showAddBudget = ref(false)
|
||||
const addForm = ref({ kpi_id: null as any, year: currentYear, month: new Date().getMonth() + 1, value: 0 })
|
||||
const addKpiOptions = ref<any[]>([]); const addKpiLoading = ref(false); const addSaving = ref(false)
|
||||
async function searchKpiForAdd(query: string) {
|
||||
addKpiLoading.value = true
|
||||
try { const r: any = await kpiApi.list({ keyword: query, page_size: 20 }); const d = r.data || r || []; addKpiOptions.value = Array.isArray(d) ? d : (d.items || []) } catch { }
|
||||
addKpiLoading.value = false
|
||||
}
|
||||
async function doAddBudget() {
|
||||
if (!addForm.value.kpi_id) { ElMessage.warning('请选择KPI'); return }; addSaving.value = true
|
||||
try { await budgetApi.create({ kpi_id: addForm.value.kpi_id, period: `${addForm.value.year}-${String(addForm.value.month).padStart(2, '0')}`, budget_value: addForm.value.value, budget_year: addForm.value.year, budget_month: addForm.value.month }); ElMessage.success('预算已创建'); showAddBudget.value = false; loadBudget(); addForm.value = { kpi_id: null, year: currentYear, month: new Date().getMonth() + 1, value: 0 } } catch (e: any) { ElMessage.error(e?.response?.data?.detail || e?.message || '创建失败') }
|
||||
addSaving.value = false
|
||||
}
|
||||
|
||||
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
|
||||
function dimLabel(d: string) { return dimMap[d] || d }; function dimTag(d: string) { return dimTagMap[d] || 'info' }
|
||||
function formatNumber(v: any, unit?: string) { if (v === null || v === undefined) return '--'; const n = Number(v); const p = unit === '%' ? 2 : 0; return n.toLocaleString('zh-CN', { minimumFractionDigits: p, maximumFractionDigits: p }) + (unit ? ` ${unit}` : '') }
|
||||
function rowClass({ row }: any) { return row._changed ? 'row-changed' : '' }
|
||||
|
||||
async function loadBudget() {
|
||||
if (batchEditMode.value && changedRows.value.length > 0) { try { await ElMessageBox.confirm(`有 ${changedRows.value.length} 条修改未保存,是否放弃?`, '提示', { confirmButtonText: '放弃修改', cancelButtonText: '取消' }) } catch { return } }
|
||||
if (!selectedMap.value) return
|
||||
const mapCodes = getMapKpiCodes()
|
||||
if (mapCodes.size === 0) { budgetList.value = []; total.value = 0; return }
|
||||
noMap.value = false; loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }; if (filterYear.value) params.year = filterYear.value; if (filterMonth.value > 0) params.budget_month = filterMonth.value; if (searchKpi.value) params.keyword = searchKpi.value
|
||||
const r: any = await budgetApi.list(params); const d = r.data || r || []; const allPlans = (Array.isArray(d) ? d : (d.items || [])) as any[]
|
||||
// 只保留选中地图关联KPI的预算记录
|
||||
const plans = allPlans.filter((p: any) => mapCodes.has(p.kpi_code))
|
||||
const kr: any = await kpiApi.list({ page_size: 100, keyword: searchKpi.value || undefined }); const kd = kr.data || kr || []; const ak = Array.isArray(kd) ? kd : (kd.items || [])
|
||||
const mk = ak.filter((k: any) => mapCodes.has(k.kpi_code)); const pk = new Set(plans.map((p: any) => p.kpi_code))
|
||||
const ek = mk.filter((k: any) => !pk.has(k.kpi_code)).map((k: any) => ({ kpi_id: k.id, kpi_code: k.kpi_code, kpi_name: k.kpi_name, dimension: k.dimension, unit: k.unit || '', period: `${filterYear.value || currentYear}-${String(filterMonth.value || 1).padStart(2, '0')}`, budget_value: null, version: 'v1.0', status: '', precision: 0, step: 1 }))
|
||||
budgetList.value = [...plans.map((item: any) => initRow({ ...item, _empty: false })), ...ek.map(item => initRow({ ...item, _empty: true }))]
|
||||
total.value = budgetList.value.length; if (budgetViewMode.value === 'summary') loadSummary()
|
||||
// 加载非财务维度KPI的行动方案计数
|
||||
loadActionPlanCounts()
|
||||
} catch (e) { ElMessage.error('加载预算数据失败') }; loading.value = false
|
||||
}
|
||||
function initRow(item: any) { return { ...item, _editing: false, _editValue: item.budget_value ?? 0, _originalValue: item.budget_value, _saving: false, _changed: false } }
|
||||
|
||||
// ── 行动方案关联 ──
|
||||
const showActionDialog = ref(false)
|
||||
const actionDialogTitle = ref('')
|
||||
const actionPlansForKpi = ref<any[]>([])
|
||||
const actionDialogCurrentRow = ref<any>(null)
|
||||
|
||||
async function loadActionPlanCounts() {
|
||||
// 筛选出非财务维度且有关联kpi_id的行
|
||||
const rows = budgetList.value.filter(r => r.dimension !== 'finance' && r.kpi_id)
|
||||
for (const row of rows) {
|
||||
row._actionCount = null // 重置
|
||||
try {
|
||||
const r: any = await actionPlanApi.list({ kpi_id: row.kpi_id, page_size: 1 })
|
||||
const d = r.data || []
|
||||
row._actionCount = Array.isArray(d) ? d.length : 0
|
||||
} catch { row._actionCount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function showActionPlans(row: any) {
|
||||
actionDialogCurrentRow.value = row
|
||||
actionDialogTitle.value = `行动方案 - ${row.kpi_name || row.kpi_code}`
|
||||
actionPlansForKpi.value = []
|
||||
if (!row.kpi_id) { showActionDialog.value = true; return }
|
||||
actionPlanApi.list({ kpi_id: row.kpi_id }).then((r: any) => {
|
||||
const d = r.data || []
|
||||
actionPlansForKpi.value = Array.isArray(d) ? d : []
|
||||
}).catch(() => { actionPlansForKpi.value = [] })
|
||||
showActionDialog.value = true
|
||||
}
|
||||
|
||||
function goCreateActionPlan() {
|
||||
const row = actionDialogCurrentRow.value
|
||||
if (!row) return
|
||||
// 跳转到差异分析页面并预填KPI
|
||||
window.open(`/action-plans?kpi_id=${row.kpi_id}&kpi_name=${encodeURIComponent(row.kpi_name || '')}`, '_blank')
|
||||
}
|
||||
|
||||
function goActionPlansPage() {
|
||||
window.open('/action-plans', '_blank')
|
||||
}
|
||||
|
||||
function onBatchEditToggle(val: boolean) { budgetList.value.forEach(r => { r._editing = val; r._editValue = r.budget_value ?? 0; r._originalValue = r.budget_value; r._changed = false }) }
|
||||
function onEditChange(row: any) { row._changed = row._editValue !== row._originalValue }
|
||||
const changedRows = computed(() => budgetList.value.filter(r => r._changed))
|
||||
async function batchSaveAll() {
|
||||
const ts = changedRows.value; if (ts.length === 0) return; batchSavingAll.value = true; let ok = 0; let no = 0
|
||||
for (const r of ts) { try { if (r._empty || !r.id) { await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }) } else { await budgetApi.update(r.id, { budget_value: r._editValue }) }; r.budget_value = r._editValue; r._originalValue = r._editValue; r._changed = false; ok++ } catch { no++ } }
|
||||
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); batchSavingAll.value = false
|
||||
}
|
||||
function batchCancelAll() { budgetList.value.forEach(r => { r._editValue = r._originalValue ?? 0; r._changed = false }) }
|
||||
function startEdit(row: any) { row._editing = true; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value }
|
||||
function cancelEdit(row: any) { row._editing = false; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value; row._changed = false }
|
||||
async function saveEdit(row: any) {
|
||||
row._saving = true
|
||||
try { if (row._empty || !row.id) { await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }); ElMessage.success('预算已创建') } else { await budgetApi.update(row.id, { budget_value: row._editValue }); ElMessage.success('已更新') }; row.budget_value = row._editValue; row._originalValue = row._editValue; row._editing = false; row._changed = false; row._empty = false } catch (e) { ElMessage.error('保存失败') }
|
||||
row._saving = false
|
||||
}
|
||||
async function doDelete(row: any) { try { await ElMessageBox.confirm(`确认删除「${row.kpi_name || row.kpi_code}」的预算?`, '确认'); await budgetApi.delete(row.id); ElMessage.success('已删除'); loadBudget() } catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') } }
|
||||
|
||||
const showDecompose = ref(false); const decomposeForm = ref({ year: currentYear, method: 'equal' }); const decomposing = ref(false); const decomposeResult = ref(''); const decomposeDetails = ref<any[]>([])
|
||||
const strategyTree = ref<any[]>([]); const strategyFilterYear = ref(currentYear); const strategyFilterMonth = ref(0); const strategyAllBudgetPlans = ref<any[]>([])
|
||||
|
||||
async function loadStrategyBudget() {
|
||||
if (!selectedMap.value) return
|
||||
try { const p: any = { budget_year: strategyFilterYear.value }; if (strategyFilterMonth.value > 0) p.budget_month = strategyFilterMonth.value; const r: any = await budgetApi.list(p); const d = r.data || r || []; const plans = (Array.isArray(d) ? d : (d.items || [])) as any[]; strategyAllBudgetPlans.value = plans
|
||||
let dims = selectedMap.value.dimensions; if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { dims = [] } }; if (!Array.isArray(dims) || dims.length === 0) { strategyTree.value = []; return }
|
||||
const kr: any = await kpiApi.list({ page_size: 100 }); const kd = kr.data || kr || []; const km = new Map<string, any>(); (Array.isArray(kd) ? kd : (kd.items || [])).forEach((k: any) => km.set(k.kpi_code, k))
|
||||
const tree: any[] = []
|
||||
for (const dim of dims) { const dt = { budget: 0, kpiCount: 0 }; const objs = (dim.objectives || []).map((obj: any) => { const kpis = (obj.kpis || []).map((code: string) => { const kpi = km.get(code); const plan = plans.find((p: any) => p.kpi_code === code); const bv = plan ? plan.budget_value : null; dt.kpiCount++; if (bv) dt.budget += bv; return { kpi_code: code, kpi_id: kpi?.id || null, kpi_name: kpi?.kpi_name || code, unit: kpi?.unit || '', precision: kpi?.precision || 0, step: kpi?.step || 1, dimension: dim.key, period: plan?.period || `${strategyFilterYear.value}-${String(strategyFilterMonth.value || 1).padStart(2, '0')}`, version: plan?.version || 'v1.0', budget_value: bv, plan_id: plan?.id || null, _editValue: bv ?? 0, _changed: false, _saving: false, _dimKey: dim.key } }).filter(Boolean); return { name: obj.name, kpis } }); tree.push({ key: dim.key, name: dim.name, icon: dim.icon, color: dim.color, kpiCount: dt.kpiCount, totalBudget: dt.budget, objectives: objs }) }
|
||||
strategyTree.value = tree
|
||||
} catch (e: any) { ElMessage.error('加载战略预算数据失败: ' + (e?.response?.data?.detail || e?.message || '未知错误')) }
|
||||
}
|
||||
|
||||
const strategySelectedKpis = ref<any[]>([]); const strategyBatchValue = ref(0); const strategyBatchSaving = ref(false)
|
||||
function onStrategyKpiSelect(sel: any[], dimKey: string) { sel.forEach((i: any) => { i._dimKey = dimKey }); strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...sel] }
|
||||
function strategySelectDim(dimKey: string) { const dim = strategyTree.value.find((d: any) => d.key === dimKey); if (!dim) return; const all: any[] = []; for (const o of dim.objectives) for (const k of o.kpis) { k._dimKey = dimKey; all.push(k) }; strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...all] }
|
||||
function strategyBatchSetSame() { strategySelectedKpis.value.forEach((k: any) => { k._editValue = strategyBatchValue.value; k._changed = (k._editValue !== (k.budget_value ?? 0)) }) }
|
||||
async function strategyBatchSave() {
|
||||
const ts = strategySelectedKpis.value.filter((k: any) => k._changed); if (ts.length === 0) { ElMessage.warning('没有需要保存的变更'); return }; strategyBatchSaving.value = true; let ok = 0; let no = 0
|
||||
for (const r of ts) { try { if (r.plan_id) { await budgetApi.update(r.plan_id, { budget_value: r._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); r.plan_id = rr.id }; r.budget_value = r._editValue; r._changed = false; ok++ } catch { no++ } }
|
||||
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); strategyBatchSaving.value = false; loadStrategyBudget()
|
||||
}
|
||||
async function saveStrategyKpi(row: any) {
|
||||
row._saving = true
|
||||
try { if (row.plan_id) { await budgetApi.update(row.plan_id, { budget_value: row._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); row.plan_id = rr.id }; row.budget_value = row._editValue; row._changed = false; ElMessage.success('已保存') } catch (e) { ElMessage.error('保存失败') }
|
||||
row._saving = false
|
||||
}
|
||||
|
||||
async function submitVersion(row: any) { try { await ElMessageBox.confirm(`确认将版本「${row.version}」提交审批?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已提交审批'); loadVersions() } catch { } }
|
||||
async function approveVersion(row: any, action: string) { const l = action === 'approved' ? '批准' : '驳回'; try { await ElMessageBox.confirm(`确认${l}版本「${row.version}」?`, '确认'); await budgetApi.versionApprove({ version: row.version, action }); ElMessage.success(`已${l}`); loadVersions() } catch { } }
|
||||
async function resubmitVersion(row: any) { try { await ElMessageBox.confirm(`确认重新提交版本「${row.version}」?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已重新提交'); loadVersions() } catch { } }
|
||||
|
||||
const versionYear = ref(currentYear); const versionsList = ref<any[]>([]); const versionsLoading = ref(false); const selectedVersions = ref<any[]>([]); const showVersionDiff = ref(false); const diffLoading = ref(false); const diffData = ref<any>(null); const currentVersion = ref('')
|
||||
async function loadVersions() { versionsLoading.value = true; try { const r: any = await budgetApi.versions({ year: versionYear.value }); const d = r.data || []; versionsList.value = d.map((v: any) => ({ ...v, _selected: false })); currentVersion.value = d[0]?.version || '' } catch (e) { ElMessage.error('加载版本列表失败') }; versionsLoading.value = false }
|
||||
function onVersionSelect(row: any) { selectedVersions.value = versionsList.value.filter((v: any) => v._selected); if (selectedVersions.value.length > 2) { row._selected = false; selectedVersions.value = versionsList.value.filter((v: any) => v._selected) } }
|
||||
async function loadVersionDiff() { if (selectedVersions.value.length !== 2) return; diffLoading.value = true; try { const [a, b] = selectedVersions.value; const r: any = await budgetApi.versionDiff({ version_a: a.version, version_b: b.version, year: versionYear.value }); diffData.value = r; showVersionDiff.value = true } catch (e) { ElMessage.error('加载版本对比失败') }; diffLoading.value = false }
|
||||
|
||||
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
|
||||
async function loadExecutionReport() {
|
||||
execLoading.value = true
|
||||
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
|
||||
execLoading.value = false
|
||||
}
|
||||
|
||||
async function doDecompose() {
|
||||
decomposing.value = true; decomposeResult.value = ''; decomposeDetails.value = []
|
||||
try { const r: any = await budgetApi.autoDecompose({ year: decomposeForm.value.year, method: decomposeForm.value.method }); decomposeResult.value = r.message || '分解成功'; if (r.results) decomposeDetails.value = r.results.map((res: any) => ({ kpi_code: res.kpi_code, kpi_name: res.kpi_name, annual_budget: res.annual_budget, method: res.method === 'equal' ? '均分' : '加权', monthly_count: res.monthly.length })); ElMessage.success('年度预算分解完成'); loadBudget() } catch (e: any) { ElMessage.error(e.detail || e.message || '分解失败') }
|
||||
decomposing.value = false
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'decompose') loadBudget()
|
||||
else if (tab === 'strategy') loadStrategyBudget()
|
||||
else if (tab === 'versions') loadVersions()
|
||||
else if (tab === 'execution') loadExecutionReport()
|
||||
})
|
||||
onMounted(async () => {
|
||||
await loadPublishedMaps()
|
||||
loadBudget()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.row-changed) { --el-table-tr-bg-color: #fff7e6 !important; }
|
||||
.strategy-dim-block { margin-bottom: 20px; border: 1px solid #ebeef5; border-radius: 8px; overflow: hidden; }
|
||||
.strategy-dim-header { padding: 10px 16px; background: #f5f7fa; border-left: 4px solid #409eff; display: flex; align-items: center; gap: 8px; }
|
||||
.strategy-dim-icon { font-size: 20px; }
|
||||
.strategy-dim-name { font-weight: 600; font-size: 15px; }
|
||||
.strategy-dim-summary { font-size: 12px; color: #999; }
|
||||
.strategy-obj-block { padding: 8px 16px; }
|
||||
.strategy-obj-block:not(:last-child) { border-bottom: 1px solid #f0f0f0; }
|
||||
.strategy-obj-header { padding: 6px 0 8px; font-size: 13px; color: #555; font-weight: 500; }
|
||||
.strategy-obj-name::before { content: '◆ '; color: #999; font-size: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="kb-page">
|
||||
<div class="kb-header">
|
||||
<h3>📖 CMA知识库</h3>
|
||||
<el-input v-model="search" placeholder="搜索术语、计算公式、最佳实践..." prefix-icon="Search" clearable style="width:360px;" />
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="search" class="search-results">
|
||||
<h4>搜索结果({{ searchResults.length }}条)</h4>
|
||||
<div v-for="item in searchResults" :key="item.title" class="search-item" @click="openItem(item)">
|
||||
<span class="search-cat-tag">{{ catLabel(item.category) }}</span>
|
||||
<span class="search-title">{{ item.title }}</span>
|
||||
<span class="search-summary">{{ item.summary }}</span>
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0" class="empty-search">未找到相关内容</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类展示 -->
|
||||
<div v-else class="kb-grid">
|
||||
<div v-for="(cat, ci) in categories" :key="ci" class="kb-category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-icon">{{ cat.icon }}</span>
|
||||
<span class="cat-title">{{ cat.name }}</span>
|
||||
<span class="cat-count">{{ cat.items.length }}篇</span>
|
||||
</div>
|
||||
<div class="cat-body">
|
||||
<div v-for="(item, ii) in cat.items" :key="ii" class="kb-item" @click="openItem(item)">
|
||||
<span class="kb-item-title">{{ item.title }}</span>
|
||||
<span class="kb-item-summary">{{ item.summary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="showDetail" :title="detailItem?.title" width="700" top="5vh">
|
||||
<div v-if="detailItem" class="detail-content" v-html="detailItem.content"></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue"
|
||||
|
||||
const search = ref("")
|
||||
const showDetail = ref(false)
|
||||
const detailItem = ref<any>(null)
|
||||
|
||||
// ── 分类配置 ──
|
||||
const categories = ref([
|
||||
{
|
||||
name: "术语解释", icon: "📖", key: "term",
|
||||
items: [
|
||||
{
|
||||
title: "KPI(关键绩效指标)",
|
||||
summary: "衡量战略目标达成情况的量化指标",
|
||||
content: `<h4>KPI(Key Performance Indicator)</h4>
|
||||
<p>关键绩效指标,是衡量企业战略目标达成情况的量化指标。在管理会计OS中,KPI贯穿战略地图、驾驶舱、预警中心等所有模块。</p>
|
||||
<h5>特征</h5>
|
||||
<ul>
|
||||
<li><b>可量化</b>:每个KPI有明确的计算公式和数值</li>
|
||||
<li><b>有时效</b>:按日/周/月/季/年采集和考核</li>
|
||||
<li><b>有标准</b>:每个KPI设定了目标值和红黄绿灯阈值</li>
|
||||
</ul>
|
||||
<h5>编码规则</h5>
|
||||
<p>KPI编码按 BSC维度_类别_序号 命名,如 <code>F_REVENUE_001</code>:</p>
|
||||
<ul>
|
||||
<li>F = 财务维度 (Finance)</li>
|
||||
<li>C = 客户维度 (Customer)</li>
|
||||
<li>P = 内部流程 (Process)</li>
|
||||
<li>L = 学习成长 (Learning)</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "BSC(平衡计分卡)",
|
||||
summary: "从财务、客户、内部流程、学习成长四个维度衡量企业绩效",
|
||||
content: `<h4>BSC(Balanced Scorecard)</h4>
|
||||
<p>平衡计分卡,由Kaplan和Norton提出,从四个维度衡量企业绩效:</p>
|
||||
<ul>
|
||||
<li><b>财务维度</b>:最终结果指标,如收入、利润、成本</li>
|
||||
<li><b>客户维度</b>:市场反馈指标,如客户数量、满意度</li>
|
||||
<li><b>内部流程</b>:运营效率指标,如交付及时率、库存周转</li>
|
||||
<li><b>学习成长</b>:驱动因素指标,如培训完成率、人才梯队</li>
|
||||
</ul>
|
||||
<p>四个维度形成因果链:<b>学习成长 → 内部流程 → 客户 → 财务</b></p>`,
|
||||
},
|
||||
{
|
||||
title: "红黄绿灯预警",
|
||||
summary: "KPI达成情况的颜色标识:绿=正常 黄=预警 红=异常",
|
||||
content: `<h4>红黄绿灯预警机制</h4>
|
||||
<p>系统自动根据KPI实际值与目标值的比率,生成三种预警等级:</p>
|
||||
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
|
||||
<tr style="background:#f5f5f5;"><th>颜色</th><th>等级</th><th>判定标准</th><th>建议动作</th></tr>
|
||||
<tr><td style="color:#67c23a;font-weight:bold;">🟢 绿色</td><td>正常</td><td>达成率 ≥ 90%</td><td>保持现状</td></tr>
|
||||
<tr><td style="color:#e6a23c;font-weight:bold;">🟡 黄色</td><td>预警</td><td>70% ≤ 达成率 < 90%</td><td>关注趋势,制定改善计划</td></tr>
|
||||
<tr><td style="color:#f56c6c;font-weight:bold;">🔴 红色</td><td>异常</td><td>达成率 < 70%</td><td>立即分析原因,启动改善行动</td></tr>
|
||||
</table>`,
|
||||
},
|
||||
{
|
||||
title: "战略地图",
|
||||
summary: "用BSC四维框架可视化展示战略目标与因果关系的工具",
|
||||
content: `<h4>战略地图</h4>
|
||||
<p>战略地图是平衡计分卡的图形化表达,将战略目标按BSC四个维度排列,并用箭头展示因果驱动关系。</p>
|
||||
<h5>使用方法</h5>
|
||||
<ol>
|
||||
<li>在「战略地图」页面创建一张新地图</li>
|
||||
<li>在每个维度下添加战略目标</li>
|
||||
<li>给目标关联KPI,用于量化衡量</li>
|
||||
<li>从低维度向高维度画因果连线(如 人才培养→流程优化→客户满意→收入增长)</li>
|
||||
<li>发布后可在「战略回顾会」中查看执行情况</li>
|
||||
</ol>`,
|
||||
},
|
||||
{
|
||||
title: "改善行动计划(CAP)",
|
||||
summary: "针对预警问题制定的改进措施,跟踪执行和效果",
|
||||
content: `<h4>改善行动计划</h4>
|
||||
<p>当KPI出现黄色或红色预警时,需要制定改善行动计划来解决问题。</p>
|
||||
<h5>流程</h5>
|
||||
<ol>
|
||||
<li>在预警中心识别异常KPI</li>
|
||||
<li>创建改善行动计划:指定标题、负责人、截止日期、优先级</li>
|
||||
<li>执行过程中更新进度(0-100%)</li>
|
||||
<li>完成后填写改善结果</li>
|
||||
<li>系统会持续跟踪逾期和进度</li>
|
||||
</ol>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "计算公式", icon: "📐", key: "formula",
|
||||
items: [
|
||||
{
|
||||
title: "达成率",
|
||||
summary: "实际值 ÷ 目标值 × 100%",
|
||||
content: `<h4>达成率</h4>
|
||||
<p><b>公式</b>:达成率 = 实际值 / 目标值 × 100%</p>
|
||||
<h5>示例</h5>
|
||||
<p>销售总额目标2000万,实际1640万:1640/2000 = <b>82%</b></p>
|
||||
<h5>说明</h5>
|
||||
<ul>
|
||||
<li>达成率 ≥ 90% 为绿灯</li>
|
||||
<li>70% ≤ 达成率 < 90% 为黄灯</li>
|
||||
<li>达成率 < 70% 为红灯</li>
|
||||
<li>部分反向指标(如成本控制率)需取相反判断</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "偏差率",
|
||||
summary: "(实际值 - 目标值)÷ 目标值 × 100%",
|
||||
content: `<h4>偏差率</h4>
|
||||
<p><b>公式</b>:偏差率 = (实际值 - 目标值) / 目标值 × 100%</p>
|
||||
<h5>示例</h5>
|
||||
<p>预算100万,实际支出120万:(120-100)/100 = <b>+20%</b>(超支)</p>
|
||||
<h5>说明</h5>
|
||||
<ul>
|
||||
<li>正偏差 = 超过目标(可能好也可能坏,取决于指标性质)</li>
|
||||
<li>负偏差 = 低于目标</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "销售毛利率",
|
||||
summary: "(销售收入 - 销售成本)÷ 销售收入 × 100%",
|
||||
content: `<h4>销售毛利率</h4>
|
||||
<p><b>公式</b>:毛利率 = (销售收入 - 销售成本) / 销售收入 × 100%</p>
|
||||
<h5>示例</h5>
|
||||
<p>收入1000万,成本700万:(1000-700)/1000 = <b>30%</b></p>
|
||||
<h5>说明</h5>
|
||||
<p>毛利率是衡量企业盈利能力的核心指标,越高代表产品附加值越大。</p>`,
|
||||
},
|
||||
{
|
||||
title: "库存周转率",
|
||||
summary: "销售成本 ÷ 平均库存金额",
|
||||
content: `<h4>库存周转率</h4>
|
||||
<p><b>公式</b>:库存周转率 = 销售成本 / 平均库存金额</p>
|
||||
<h5>示例</h5>
|
||||
<p>年销售成本1.2亿,平均库存1500万:1.2亿/1500万 = <b>8次/年</b></p>
|
||||
<h5>说明</h5>
|
||||
<p>库存周转率反映企业存货管理效率,越高说明资金占用越少。</p>`,
|
||||
},
|
||||
{
|
||||
title: "达成率 vs 偏差率 对照",
|
||||
summary: "两个指标的区别与使用场景",
|
||||
content: `<h4>达成率 vs 偏差率</h4>
|
||||
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
|
||||
<tr style="background:#f5f5f5;"><th></th><th>达成率</th><th>偏差率</th></tr>
|
||||
<tr><td><b>公式</b></td><td>实际/目标 × 100%</td><td>(实际-目标)/目标 × 100%</td></tr>
|
||||
<tr><td><b>理想值</b></td><td>≥ 100%</td><td>接近0%</td></tr>
|
||||
<tr><td><b>适用场景</b></td><td>正向指标(收入、满意度)</td><td>预算控制、成本差异</td></tr>
|
||||
<tr><td><b>红黄绿灯</b></td><td>≥90%绿,≥70%黄,<70%红</td><td>按偏差幅度自定义</td></tr>
|
||||
</table>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "最佳实践", icon: "⭐", key: "practice",
|
||||
items: [
|
||||
{
|
||||
title: "如何绘制一张好的战略地图",
|
||||
summary: "从设定目标到画因果链的实操指南",
|
||||
content: `<h4>绘制战略地图的5个步骤</h4>
|
||||
<ol>
|
||||
<li><b>明确战略主题</b>:确定本年度最重要的3-5个战略方向</li>
|
||||
<li><b>从财务开始</b>:先在财务维度设定最终目标(如"提升销售总额30%")</li>
|
||||
<li><b>追溯驱动因素</b>:思考\"要达成财务目标,客户需要什么?流程需要怎么优化?员工需要什么能力?\"</li>
|
||||
<li><b>关联KPI</b>:每个目标绑定1-2个可量化的KPI</li>
|
||||
<li><b>画因果连线</b>:从下到上连接各维度目标,形成因果链</li>
|
||||
</ol>
|
||||
<h5>常见错误</h5>
|
||||
<ul>
|
||||
<li>❌ 每个维度设太多目标(建议不超过4个)</li>
|
||||
<li>❌ 目标不关联KPI(无法量化跟踪)</li>
|
||||
<li>❌ 因果链混乱(学习→流程→客户→财务,不要跳层)</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "预算编制的10个原则",
|
||||
summary: "预算管理模块使用的最佳实践",
|
||||
content: `<h4>预算编制最佳实践</h4>
|
||||
<ol>
|
||||
<li><b>自上而下 vs 自下而上</b>:先定总盘子,再分解到各部门</li>
|
||||
<li><b>零基预算</b>:每年从零开始编制,不以上年基数为准</li>
|
||||
<li><b>弹性预算</b>:按业务量设定不同层级的预算标准</li>
|
||||
<li><b>滚动预算</b>:每季度更新一次全年预算</li>
|
||||
<li><b>差异分析</b>:每月对比实际与预算,找出偏差原因</li>
|
||||
</ol>`,
|
||||
},
|
||||
{
|
||||
title: "预警阈值设置的技巧",
|
||||
summary: "如何设置合理的红黄绿灯阈值",
|
||||
content: `<h4>阈值设置建议</h4>
|
||||
<p>合理的红黄绿灯阈值应该:</p>
|
||||
<ul>
|
||||
<li><b>不要过严</b>:绿灯设90%而不是95%,否则大部分KPI都是黄色</li>
|
||||
<li><b>区分指标性质</b>:正向指标(收入)和反向指标(成本)的绿/红判断逻辑不同</li>
|
||||
<li><b>结合历史数据</b>:参考过去6个月的实际值来设定合理目标</li>
|
||||
<li><b>动态调整</b>:每季度review一次阈值,随业务发展调整</li>
|
||||
</ul>`,
|
||||
},
|
||||
{
|
||||
title: "每日工作台的使用方法",
|
||||
summary: "如何高效使用我的工作台进行日常管理",
|
||||
content: `<h4>工作台使用三部曲</h4>
|
||||
<ol>
|
||||
<li><b>早上看KPI</b>:登录先看一眼我的KPI红黄绿灯,了解整体状态</li>
|
||||
<li><b>处理待办</b>:查看逾期改善行动和待审批事项</li>
|
||||
<li><b>跟踪异常</b>:红色预警的KPI立即查看详情,安排改善行动</li>
|
||||
</ol>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "常见问题", icon: "❓", key: "faq",
|
||||
items: [
|
||||
{
|
||||
title: "数据不同步怎么办?",
|
||||
summary: "ERP数据同步失败的排查步骤",
|
||||
content: `<h4>数据同步问题排查</h4>
|
||||
<ol>
|
||||
<li>检查ERP API网关是否正常运行(端口8300)</li>
|
||||
<li>在「数据管理」页面查看最近一次同步时间</li>
|
||||
<li>手动触发同步:数据管理 → 点击「手动同步」</li>
|
||||
<li>如仍失败,联系IT管理员查看后端日志</li>
|
||||
</ol>`,
|
||||
},
|
||||
{
|
||||
title: "为什么看不到某些KPI?",
|
||||
summary: "角色权限和KPI可见性的说明",
|
||||
content: `<h4>KPI可见性规则</h4>
|
||||
<ul>
|
||||
<li><b>CEO</b>:全量KPI可见</li>
|
||||
<li><b>财务</b>:财务维度KPI + 通用KPI</li>
|
||||
<li><b>业务</b>:客户、流程、学习维度KPI</li>
|
||||
<li><b>IT</b>:全量KPI可见(运维用途)</li>
|
||||
</ul>
|
||||
<p>如需调整可见范围,请联系管理员修改权限配置。</p>`,
|
||||
},
|
||||
{
|
||||
title: "如何删除一个战略目标?",
|
||||
summary: "画布上目标的编辑和删除操作",
|
||||
content: `<h4>删除战略目标</h4>
|
||||
<ol>
|
||||
<li>进入战略地图画布页面</li>
|
||||
<li>点击要删除的目标卡片(会弹出编辑弹窗)</li>
|
||||
<li>点击弹窗底部的「删除」按钮</li>
|
||||
<li>确认删除</li>
|
||||
<li>点击「保存」使改动生效</li>
|
||||
</ol>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// ── 计算属性 ──
|
||||
const searchResults = computed(() => {
|
||||
if (!search.value.trim()) return []
|
||||
const q = search.value.toLowerCase()
|
||||
const results: any[] = []
|
||||
for (const cat of categories.value) {
|
||||
for (const item of cat.items) {
|
||||
if (
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
item.summary.toLowerCase().includes(q) ||
|
||||
item.content.toLowerCase().includes(q)
|
||||
) {
|
||||
results.push({ ...item, category: cat.key })
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
// ── 方法 ──
|
||||
function catLabel(key: string) {
|
||||
const map: Record<string, string> = { term: "术语", formula: "公式", practice: "实践", faq: "FAQ" }
|
||||
return map[key] || key
|
||||
}
|
||||
|
||||
function openItem(item: any) {
|
||||
detailItem.value = item
|
||||
showDetail.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
|
||||
.kb-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.kb-header h3 { margin: 0; font-size: 20px; }
|
||||
.kb-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.kb-category { background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.cat-head { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #f0f0f0; }
|
||||
.cat-icon { font-size: 20px; }
|
||||
.cat-title { font-size: 16px; font-weight: 600; flex: 1; }
|
||||
.cat-count { font-size: 12px; color: #999; background: #f5f5f5; padding: 2px 8px; border-radius: 10px; }
|
||||
.cat-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.kb-item { padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.2s; }
|
||||
.kb-item:hover { background: #f5f7fa; }
|
||||
.kb-item-title { display: block; font-size: 14px; font-weight: 500; margin-bottom: 2px; }
|
||||
.kb-item-summary { display: block; font-size: 12px; color: #999; }
|
||||
.search-results { margin-bottom: 20px; }
|
||||
.search-results h4 { margin: 0 0 12px 0; font-size: 15px; color: #666; }
|
||||
.search-item { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 8px; cursor: pointer; background: #fff; margin-bottom: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
||||
.search-item:hover { background: #f5f7fa; }
|
||||
.search-cat-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: #ecf5ff; color: #409eff; flex-shrink: 0; }
|
||||
.search-title { font-weight: 500; font-size: 14px; flex-shrink: 0; }
|
||||
.search-summary { font-size: 12px; color: #999; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty-search { text-align: center; color: #999; padding: 40px; }
|
||||
.detail-content { line-height: 1.8; font-size: 14px; }
|
||||
.detail-content h4 { margin: 16px 0 8px; }
|
||||
.detail-content h5 { margin: 12px 0 6px; color: #666; }
|
||||
.detail-content ul, .detail-content ol { padding-left: 20px; }
|
||||
.detail-content li { margin-bottom: 4px; }
|
||||
.detail-content code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
.detail-content table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.detail-content th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
|
||||
.detail-content th { background: #f5f5f5; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div style="padding: 30px; max-width: 1000px; margin: 0 auto;">
|
||||
<h3 style="margin-bottom: 20px;">📊 图表组件测试</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px;">
|
||||
<!-- 子弹图测试 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span>🔥 实际 vs 目标(子弹图)</span></template>
|
||||
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||
<BulletChart actual="1280000" target="1500000" label="销售额" unit="元" />
|
||||
<BulletChart actual="85" target="100" label="预算执行率" unit="%" />
|
||||
<BulletChart actual="309393" target="500000" label="净利润" unit="元" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 仪表盘测试 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span>🎯 达标率(仪表盘)</span></template>
|
||||
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||
<GaugeChart actual="95" target="100" label="销售额达标率" unit="%" />
|
||||
<GaugeChart actual="62" target="100" label="毛利率达标率" unit="%" />
|
||||
<GaugeChart actual="38" target="100" label="净利润达标率" unit="%" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 瀑布图测试 -->
|
||||
<el-card shadow="never" style="margin-top: 24px;">
|
||||
<template #header><span>🌊 瀑布图 — 利润拆解</span></template>
|
||||
<div style="height: 300px;">
|
||||
<WaterfallChart :data="profitData" totalUnit="元" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BulletChart from '../components/charts/BulletChart.vue'
|
||||
import GaugeChart from '../components/charts/GaugeChart.vue'
|
||||
import WaterfallChart from '../components/charts/WaterfallChart.vue'
|
||||
|
||||
const profitData = [
|
||||
{ name: '营收', value: 1280000 },
|
||||
{ name: '成本', value: -850000 },
|
||||
{ name: '费用', value: -320000 },
|
||||
{ name: '其他收入', value: 50000 },
|
||||
{ name: '净利润', value: 160000 },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="customer-dashboard">
|
||||
<KPIListView
|
||||
ref="kpiListRef"
|
||||
:dimension="'customer'"
|
||||
:title="'客户维度KPI看板'"
|
||||
:icon="'👥'"
|
||||
:stat-labels="{ total: '客户指标总数', green: '达标', yellow: '预警', red: '未达标', noData: '无数据' }"
|
||||
:templates="CUSTOMER_TEMPLATES"
|
||||
:has-categories="false"
|
||||
:show-frequency="true"
|
||||
:name-placeholder="'客户满意度、渠道覆盖率'"
|
||||
:unit-options="['%', '元', '家', '分', '次']"
|
||||
form-title-create="新建客户KPI"
|
||||
form-title-edit="编辑客户KPI"
|
||||
/>
|
||||
|
||||
<!-- 趋势分析(客户维度特有) -->
|
||||
<el-card shadow="never" class="section-card" v-if="trendData.length > 0">
|
||||
<template #header>
|
||||
<div class="card-header-flex">
|
||||
<span>📈 关键指标趋势</span>
|
||||
<el-radio-group v-model="trendType" size="small" @change="loadTrend">
|
||||
<el-radio-button value="monthly">月度</el-radio-button>
|
||||
<el-radio-button value="quarterly">季度</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="trendLoading" style="padding:40px;text-align:center;">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
<div v-else ref="trendChartRef" style="width:100%;height:320px;"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import KPIListView from '../components/KPIListView.vue'
|
||||
import api from '../api/index'
|
||||
|
||||
// ── 客户维度预设模板 ──
|
||||
const CUSTOMER_TEMPLATES = [
|
||||
{ name: '客户满意度', unit: '%', targetValue: 90 },
|
||||
{ name: '客户留存率', unit: '%', targetValue: 85 },
|
||||
{ name: '新客户增长率', unit: '%', targetValue: 20 },
|
||||
{ name: '客户渠补率', unit: '%', targetValue: 95 },
|
||||
{ name: '客户投诉率', unit: '%', targetValue: 5 },
|
||||
{ name: '客户平均贡献值', unit: '元', targetValue: 10000 },
|
||||
{ name: '客户转化率', unit: '%', targetValue: 30 },
|
||||
{ name: '客户活跃度', unit: '%', targetValue: 70 },
|
||||
{ name: 'NPS净推荐值', unit: '分', targetValue: 60 },
|
||||
]
|
||||
|
||||
// ── 趋势分析 ──
|
||||
const kpiListRef = ref<InstanceType<typeof KPIListView> | null>(null)
|
||||
const trendType = ref('monthly')
|
||||
const trendLoading = ref(false)
|
||||
const trendData = ref<any[]>([])
|
||||
const trendChartRef = ref<HTMLElement | null>(null)
|
||||
let chartInstance: any = null
|
||||
|
||||
async function loadTrend() {
|
||||
// 从 KPIListView 内部获取当前 kpis
|
||||
// 由于 KPIListView 内部数据不暴露,直接通过 API 请求客户维度的前5个KPI
|
||||
trendLoading.value = true
|
||||
try {
|
||||
const r: any = await api.get('/kpis', { params: { dimension: 'customer', page_size: 5 } })
|
||||
const kpis = (r.data || []).slice(0, 5)
|
||||
const kpiIds = kpis.map((k: any) => k.id).filter(Boolean)
|
||||
if (kpiIds.length === 0) { trendLoading.value = false; return }
|
||||
const trendRes: any = await api.post('/dashboard/trend-analysis', {
|
||||
kpi_ids: kpiIds,
|
||||
dimension: 'customer',
|
||||
period_type: trendType.value,
|
||||
})
|
||||
trendData.value = trendRes.data || []
|
||||
trendLoading.value = false
|
||||
nextTick(() => renderChart())
|
||||
} catch {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!trendChartRef.value) return
|
||||
try {
|
||||
const chart = (window as any).echarts?.init(trendChartRef.value)
|
||||
if (chart) {
|
||||
chartInstance = chart
|
||||
const series = trendData.value.slice(0, 5).map((d: any) => ({
|
||||
name: d.kpi_name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: (d.period_values || []).map((pv: any) => pv.value),
|
||||
}))
|
||||
const months = trendData.value[0]?.period_values?.map((pv: any) => pv.period) || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: trendData.value.slice(0, 5).map((d: any) => d.kpi_name) },
|
||||
grid: { left: 60, right: 20, bottom: 30, top: 40 },
|
||||
xAxis: { type: 'category', data: months },
|
||||
yAxis: { type: 'value' },
|
||||
series,
|
||||
})
|
||||
}
|
||||
} catch { /* echarts not loaded */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 等待 KPIListView 加载完成后加载趋势
|
||||
setTimeout(() => loadTrend(), 500)
|
||||
// 尝试加载 ECharts
|
||||
if (!(window as any).echarts) {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/assets/echarts.min.js'
|
||||
script.onload = () => { nextTick(loadTrend) }
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.customer-dashboard { padding: 0; }
|
||||
.section-card { margin-bottom: 16px; margin-top: 16px; }
|
||||
.card-header-flex { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>差异分析看板</h3>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadReport">
|
||||
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
|
||||
</el-select>
|
||||
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadReport">
|
||||
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
|
||||
</el-select>
|
||||
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:110px;" @change="filterChanged">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
<el-select v-model="alertFilter" placeholder="预警级别" clearable style="width:120px;" @change="filterChanged">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="红色预警(超20%)" value="red" />
|
||||
<el-option label="黄色预警(超10%)" value="yellow" />
|
||||
<el-option label="正常(10%以内)" value="normal" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadReport">刷新</el-button>
|
||||
<el-button @click="exportReport">导出CSV</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 汇总统计卡片(维度级) -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">已录预算KPI</div>
|
||||
<div class="stat-value">{{ stats.total_kpis || 0 }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6" v-for="dim in dimensionCards" :key="dim.key">
|
||||
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + dim.color }" style="cursor:pointer;" @click="scrollToDim(dim.key)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ dim.icon }} {{ dim.name }}</div>
|
||||
<div class="stat-value" :class="dim.summary.level + '-text'">
|
||||
{{ dim.summary.deviation_rate != null ? dim.summary.deviation_rate.toFixed(1) + '%' : '-' }}
|
||||
</div>
|
||||
<div style="font-size:11px;color:#999;margin-top:2px;">
|
||||
{{ dim.summary.has_budget }}/{{ dim.summary.count }} KPI有预算
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 差异趋势图(简易柱状图) -->
|
||||
<el-card style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>差异趋势(各月实际 vs 预算)</span>
|
||||
<el-select v-model="trendKpi" placeholder="选择KPI" style="width:220px;" @change="loadTrend">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="trendData.length > 0" style="display:flex;align-items:flex-end;gap:8px;height:200px;padding:20px 0;overflow-x:auto;">
|
||||
<div v-for="(item, idx) in trendData" :key="idx" style="display:flex;flex-direction:column;align-items:center;min-width:60px;">
|
||||
<div :title="`实际: ${item.actual}, 预算: ${item.budget}`" class="trend-bar-group" style="display:flex;gap:3px;height:160px;align-items:flex-end;">
|
||||
<div
|
||||
class="trend-bar actual-bar"
|
||||
:style="{ height: barHeight(item.actual, maxTrend) + 'px' }"
|
||||
:title="`实际 ${item.actual}`"
|
||||
></div>
|
||||
<div
|
||||
class="trend-bar budget-bar"
|
||||
:style="{ height: barHeight(item.budget, maxTrend) + 'px' }"
|
||||
:title="`预算 ${item.budget}`"
|
||||
></div>
|
||||
</div>
|
||||
<span style="font-size:11px;color:#909399;margin-top:4px;">{{ item.period }}</span>
|
||||
<span v-if="item.deviation !== undefined" :style="{ fontSize:'10px', color: item.deviation > 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ item.deviation > 0 ? '+' : '' }}{{ item.deviation.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align:center;color:#999;padding:40px;">
|
||||
{{ trendLoading ? '加载中...' : '选择一个KPI查看趋势' }}
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 维度→目标→KPI 三层树形表格 -->
|
||||
<el-card v-if="hierarchyData.length > 0" :body-style="{ padding: 0 }">
|
||||
<el-table
|
||||
:data="flattenHierarchy"
|
||||
v-loading="loading"
|
||||
border stripe
|
||||
size="small"
|
||||
style="width:100%;"
|
||||
row-key="__uid"
|
||||
:tree-props="{ children: '_children' }"
|
||||
:indent="24"
|
||||
:default-expand-all="expandedAll"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column label="层级" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span v-if="row._type === 'dimension'" style="font-size:16px;">{{ row.icon }}</span>
|
||||
<span :style="{
|
||||
fontWeight: row._type === 'dimension' ? 700 : row._type === 'objective' ? 600 : 400,
|
||||
fontSize: row._type === 'dimension' ? '14px' : '13px',
|
||||
color: row._type === 'dimension' ? '#303133' : '#606266'
|
||||
}">{{ row._type === 'dimension' ? row.name + ' 维度' : row.name }}</span>
|
||||
<el-tag v-if="row._type === 'dimension'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
|
||||
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row._type === 'objective'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
|
||||
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
|
||||
</el-tag>
|
||||
<span v-if="row._type === 'dimension'" style="font-size:11px;color:#999;margin-left:8px;">
|
||||
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
|
||||
</span>
|
||||
<span v-if="row._type === 'objective'" style="font-size:11px;color:#999;margin-left:8px;">
|
||||
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" style="font-family:monospace;font-size:12px;">{{ row.kpi_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预算值" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" :style="{ color: row.budget_value != null ? '#303133' : '#999' }">
|
||||
{{ row.budget_value != null ? fmtVal(row.budget_value) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际值" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" :style="{ color: row.actual_value != null ? '#303133' : '#999' }">
|
||||
{{ row.actual_value != null ? fmtVal(row.actual_value) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi'" :style="{ color: (row.deviation_amount || 0) > 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ row.deviation_amount !== undefined ? fmtVal(row.deviation_amount) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异率" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row._type === 'kpi' && row.deviation_rate !== undefined" :type="deviationTag(row.deviation_rate)" size="small">
|
||||
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(2) }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="同比" width="85" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi' && row.yoy?.diff_rate != null"
|
||||
:style="{ color: (row.yoy.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
|
||||
{{ row.yoy.diff_rate > 0 ? '+' : '' }}{{ row.yoy.diff_rate.toFixed(1) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="环比" width="85" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._type === 'kpi' && row.mom?.diff_rate != null"
|
||||
:style="{ color: (row.mom.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
|
||||
{{ row.mom.diff_rate > 0 ? '+' : '' }}{{ row.mom.diff_rate.toFixed(1) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预警" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row._type === 'kpi' && row.deviation_rate != null" :type="deviationTag(row.deviation_rate)" size="small">
|
||||
{{ row.deviation_rate > 20 ? '红色' : row.deviation_rate > 10 ? '黄色' : '正常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<el-empty v-if="!loading && hierarchyData.length === 0 && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { budgetApi, kpiApi } from '../api/index'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = computed(() => {
|
||||
const years: number[] = []
|
||||
for (let y = currentYear - 2; y <= currentYear + 2; y++) years.push(y)
|
||||
return years
|
||||
})
|
||||
|
||||
const filterYear = ref(currentYear)
|
||||
const filterMonth = ref(new Date().getMonth() + 1)
|
||||
const filterDimension = ref('')
|
||||
const alertFilter = ref('')
|
||||
const loading = ref(false)
|
||||
const reportData = ref<any[]>([])
|
||||
const hierarchyData = ref<any[]>([])
|
||||
const stats = ref<any>({})
|
||||
const expandedAll = ref(true)
|
||||
|
||||
// ── 维度卡片计算 ──
|
||||
const dimensionCards = computed(() => {
|
||||
return hierarchyData.value.map(d => ({
|
||||
key: d.key,
|
||||
name: d.name,
|
||||
icon: d.icon,
|
||||
color: d.color,
|
||||
summary: d.summary
|
||||
}))
|
||||
})
|
||||
|
||||
// ── 平铺树结构为el-table可用的带_children的列表 ──
|
||||
const flattenHierarchy = computed(() => {
|
||||
const result: any[] = []
|
||||
let uid = 0
|
||||
for (const dim of hierarchyData.value) {
|
||||
const dimRow = { ...dim, _type: 'dimension', _children: [], __uid: `dim_${++uid}` }
|
||||
for (const obj of dim.objectives) {
|
||||
const objRow = { ...obj, _type: 'objective', _children: [], __uid: `obj_${++uid}` }
|
||||
for (const kpi of obj.kpis) {
|
||||
objRow._children.push({
|
||||
...kpi,
|
||||
_type: 'kpi',
|
||||
_children: undefined,
|
||||
__uid: `kpi_${++uid}_${kpi.kpi_id}`
|
||||
})
|
||||
}
|
||||
dimRow._children.push(objRow)
|
||||
}
|
||||
result.push(dimRow)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ── 工具函数 ──
|
||||
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
|
||||
|
||||
function levelTag(level: string) {
|
||||
if (level === 'red') return 'danger'
|
||||
if (level === 'yellow') return 'warning'
|
||||
if (level === 'green') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function deviationTag(rate: number) {
|
||||
if (rate > 20) return 'danger'
|
||||
if (rate > 10) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function fmtVal(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// ── 筛选变更(前端过滤树) ──
|
||||
function filterChanged() {
|
||||
// 维度或预警级别变化时,后端的hierarchical接口不支持前端过滤
|
||||
// 重新加载(后端过滤)
|
||||
loadReport()
|
||||
}
|
||||
|
||||
function scrollToDim(key: string) {
|
||||
// 展开所有,让目标维度可见
|
||||
expandedAll.value = true
|
||||
}
|
||||
|
||||
function onRowClick(row: any) {
|
||||
// KPI级点击跳到详情
|
||||
if (row._type === 'kpi' && row.kpi_id) {
|
||||
window.open('/#/kpis/' + row.kpi_id, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载差异报告 ──
|
||||
async function loadReport() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { hierarchical: true }
|
||||
if (filterYear.value) params.year = filterYear.value
|
||||
if (filterMonth.value) params.month = filterMonth.value
|
||||
if (filterDimension.value) params.dimension = filterDimension.value
|
||||
if (alertFilter.value) params.alert_level = alertFilter.value
|
||||
const r: any = await budgetApi.deviationReport(params)
|
||||
reportData.value = r.items || []
|
||||
hierarchyData.value = r.hierarchy || []
|
||||
stats.value = r.summary || {}
|
||||
// 如果hierarchy有数据,用层级中的统计补全stats
|
||||
if (hierarchyData.value.length > 0) {
|
||||
let total = 0, hasBudget = 0
|
||||
for (const dim of hierarchyData.value) {
|
||||
total += dim.summary.count || 0
|
||||
hasBudget += dim.summary.has_budget || 0
|
||||
}
|
||||
stats.value.total_kpis = total
|
||||
stats.value.has_budget = hasBudget
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('加载差异报告失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ── 趋势图 ──
|
||||
const trendKpi = ref<number | null>(null)
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const trendData = ref<any[]>([])
|
||||
const trendLoading = ref(false)
|
||||
const maxTrend = ref(0)
|
||||
|
||||
async function loadKpiOptions() {
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 200 })
|
||||
const data = r.data || r || []
|
||||
kpiOptions.value = Array.isArray(data) ? data : (data.items || [])
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadTrend() {
|
||||
if (!trendKpi.value) { trendData.value = []; return }
|
||||
trendLoading.value = true
|
||||
try {
|
||||
const r: any = await budgetApi.deviationReport({ kpi_id: trendKpi.value })
|
||||
const data = r.data || r || []
|
||||
const items = Array.isArray(data) ? data : (data.items || [])
|
||||
const monthly = items.filter((i: any) => i.budget_value !== null)
|
||||
.sort((a: any, b: any) => {
|
||||
const keyA = a.period || ''
|
||||
const keyB = b.period || ''
|
||||
return keyA.localeCompare(keyB)
|
||||
})
|
||||
trendData.value = monthly.map((i: any) => ({
|
||||
period: i.period,
|
||||
actual: i.actual_value || 0,
|
||||
budget: i.budget_value || 0,
|
||||
deviation: i.deviation_rate,
|
||||
}))
|
||||
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1)
|
||||
} catch (e) { /* ignore */ }
|
||||
trendLoading.value = false
|
||||
}
|
||||
|
||||
function barHeight(val: number, max: number) {
|
||||
if (!max) return 0
|
||||
return Math.max(4, (val / max) * 150)
|
||||
}
|
||||
|
||||
// ── 导出CSV ──
|
||||
function exportReport() {
|
||||
const dims = hierarchyData.value
|
||||
if (dims.length === 0) { ElMessage.warning('无数据可导出'); return }
|
||||
const headers = ['维度', '战略目标', 'KPI编码', 'KPI名称', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
|
||||
const csvRows = [headers.join(',')]
|
||||
for (const dim of dims) {
|
||||
for (const obj of dim.objectives) {
|
||||
if (obj.kpis.length === 0) {
|
||||
csvRows.push([dim.name, `"${obj.name}"`].join(','))
|
||||
}
|
||||
for (const k of obj.kpis) {
|
||||
csvRows.push([
|
||||
dim.name, `"${obj.name}"`, k.kpi_code || '', `"${k.kpi_name || ''}"`,
|
||||
k.budget_value ?? '', k.actual_value ?? '',
|
||||
k.deviation_amount ?? '', k.deviation_rate?.toFixed(2) ?? '',
|
||||
k.yoy?.diff_rate?.toFixed(1) ?? '', k.mom?.diff_rate?.toFixed(1) ?? '',
|
||||
k.deviation_rate != null ? (k.deviation_rate > 20 ? '红色' : k.deviation_rate > 10 ? '黄色' : '正常') : ''
|
||||
].join(','))
|
||||
}
|
||||
}
|
||||
}
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `偏差分析报告_${filterYear.value}年${filterMonth.value}月.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReport()
|
||||
loadKpiOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { text-align: center; padding: 4px 0; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-bottom: 6px; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #303133; }
|
||||
.red-text { color: #f56c6c; }
|
||||
.yellow-text { color: #e6a23c; }
|
||||
.green-text { color: #67c23a; }
|
||||
.gray-text { color: #909399; }
|
||||
.trend-bar { width: 18px; border-radius: 3px 3px 0 0; transition: height 0.3s; }
|
||||
.actual-bar { background: #409eff; }
|
||||
.budget-bar { background: #e6a23c; }
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="dupont-analysis" v-loading="loading">
|
||||
<!-- 1. ROE大数字 -->
|
||||
<div class="roe-hero" v-if="data.roe !== null">
|
||||
<div class="roe-value">{{ data.roe }}<span class="roe-unit">%</span></div>
|
||||
<div class="roe-label">净资产收益率 (ROE)</div>
|
||||
<div class="roe-change" :class="data.history?.trend || 'stable'">
|
||||
<span v-if="data.history?.trend === 'up'">↑</span>
|
||||
<span v-else-if="data.history?.trend === 'down'">↓</span>
|
||||
<span v-else>→</span>
|
||||
{{ data.history?.change?.roe_label || '无对比' }}
|
||||
<span class="change-sub">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="杜邦分析数据不足以计算,请确认营收、总资产、净资产数据已同步" />
|
||||
|
||||
<!-- 2. 三因子卡片 -->
|
||||
<div class="factor-cards" v-if="data.factors && Object.keys(data.factors).length">
|
||||
<div class="factor-card" v-for="(f, key) in data.factors" :key="key">
|
||||
<div class="factor-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="factor-label">{{ f.label }}</div>
|
||||
<div class="factor-desc">{{ f.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 杜邦拆解树 -->
|
||||
<div class="dupont-tree" v-if="data.roe !== null && data.raw_data">
|
||||
<div class="tree-title">杜邦拆解链路</div>
|
||||
<div class="tree-container">
|
||||
<!-- 第一层:ROE -->
|
||||
<div class="tree-node tree-root">
|
||||
<div class="node-value">{{ data.roe }}%</div>
|
||||
<div class="node-label">ROE</div>
|
||||
</div>
|
||||
<!-- 连线 -->
|
||||
<div class="tree-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal">
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二层:三因子 -->
|
||||
<div class="tree-row">
|
||||
<div class="tree-node tree-factor" v-for="(f, key) in data.factors" :key="'f-'+key">
|
||||
<div class="node-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="node-label">{{ f.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 连线 第二层到第三层 -->
|
||||
<div class="tree-lines-second">
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第三层:原始数据 -->
|
||||
<div class="tree-row" v-if="data.raw_data">
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.profit_net) }}</div>
|
||||
<div class="node-label sub">净利润</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label sub">总资产</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label">总资产</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.equity_total) }}</div>
|
||||
<div class="node-label sub">净资产</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 一句话AI解读 -->
|
||||
<div class="ai-insight" v-if="aiAnalysis">
|
||||
<div class="insight-label">💡 AI解读</div>
|
||||
<div class="insight-text">{{ aiAnalysis }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any>({})
|
||||
const aiAnalysis = ref('')
|
||||
|
||||
function formatFactor(val: number, key: string): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (key === 'equity_multiplier') return val.toFixed(2) + 'x'
|
||||
if (key === 'asset_turnover') return val.toFixed(2) + 'x'
|
||||
return (val * 100).toFixed(2) + '%'
|
||||
}
|
||||
|
||||
function formatMoney(val: number): string {
|
||||
if (!val) return '-'
|
||||
if (val >= 100000000) return (val / 100000000).toFixed(2) + '亿'
|
||||
if (val >= 10000) return (val / 10000).toFixed(0) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
|
||||
async function fetchDupont() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await dashboardApi.dupont?.() || await (await fetch('/api/cma/dashboard/dupont')).json()
|
||||
data.value = res
|
||||
} catch (e) {
|
||||
console.error('杜邦分析加载失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchDupont)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dupont-analysis {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ROE大数字 */
|
||||
.roe-hero {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.roe-value {
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.roe-unit { font-size: 24px; color: #8892b0; }
|
||||
.roe-label { font-size: 14px; color: #8892b0; margin-top: 4px; }
|
||||
.roe-change {
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
.roe-change.up { color: #2ecc71; background: rgba(46,204,113,0.15); }
|
||||
.roe-change.down { color: #e74c3c; background: rgba(231,76,60,0.15); }
|
||||
.roe-change.stable { color: #f39c12; background: rgba(243,156,18,0.15); }
|
||||
.change-sub { font-size: 12px; color: #8892b0; margin-left: 4px; font-weight: 400; }
|
||||
|
||||
/* 三因子卡片 */
|
||||
.factor-cards {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.factor-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
.factor-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
.factor-value { font-size: 28px; font-weight: 700; color: #2c3e50; }
|
||||
.factor-label { font-size: 14px; color: #7f8c8d; margin-top: 4px; }
|
||||
.factor-desc { font-size: 11px; color: #b0b0b0; margin-top: 8px; }
|
||||
|
||||
/* 杜邦拆解树 */
|
||||
.dupont-tree {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tree-title { font-size: 16px; font-weight: 600; margin-bottom: 20px; color: #2c3e50; }
|
||||
.tree-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.tree-node {
|
||||
text-align: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.tree-root {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
.tree-root .node-value { font-size: 32px; font-weight: 700; }
|
||||
.tree-root .node-label { font-size: 13px; opacity: 0.85; }
|
||||
.tree-factor {
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #d0d8f0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.tree-factor .node-value { font-size: 22px; font-weight: 700; }
|
||||
.tree-factor .node-label { font-size: 13px; color: #7f8c8d; }
|
||||
.tree-leaf {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
min-width: 110px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.tree-leaf .node-value { font-size: 16px; font-weight: 600; color: #2c3e50; }
|
||||
.tree-leaf .node-label { font-size: 12px; color: #95a5a6; }
|
||||
.tree-leaf .node-label.sub { font-size: 11px; color: #b0b0b0; margin-top: 2px; }
|
||||
.tree-leaf .node-hint { font-size: 14px; font-weight: 600; color: #667eea; margin-top: 8px; }
|
||||
|
||||
/* 连线 */
|
||||
.tree-lines, .tree-lines-second {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
}
|
||||
.line-vertical { width: 2px; height: 20px; background: #d0d8f0; }
|
||||
.line-horizontal {
|
||||
display: flex;
|
||||
gap: 160px;
|
||||
height: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.line-point { width: 2px; height: 20px; background: #d0d8f0; }
|
||||
.tree-row {
|
||||
display: flex;
|
||||
gap: 80px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.tree-lines-second {
|
||||
flex-direction: row;
|
||||
gap: 80px;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.sub-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.sub-lines .line-horizontal { gap: 50px; }
|
||||
|
||||
/* AI解读 */
|
||||
.ai-insight {
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.insight-label { font-size: 14px; font-weight: 600; color: #d48806; margin-bottom: 8px; }
|
||||
.insight-text { font-size: 14px; color: #5a4e2b; line-height: 1.7; }
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="alignment-page">
|
||||
<div class="page-header">
|
||||
<h3>战略执行看板</h3>
|
||||
<div>
|
||||
<el-button v-if="alignConfigured" @click="loadTree" :loading="loading" :disabled="loading" style="margin-right:8px;">
|
||||
刷新数据
|
||||
</el-button>
|
||||
<el-button type="primary" @click="showSelector = true">
|
||||
{{ alignConfigured ? '切换模式' : '配置对齐模式' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模式选择器 -->
|
||||
<div v-if="showSelector" class="align-setup">
|
||||
<el-alert v-if="!alignConfigured" title="系统尚未配置KPI对齐模式" type="warning" :closable="false" show-icon style="margin-bottom:16px;" />
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||
<el-card v-for="mode in alignModes" :key="mode.key"
|
||||
:class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }"
|
||||
shadow="hover" @click="selectedMode = mode.key"
|
||||
>
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span style="font-weight:600;">{{ mode.name }}</span>
|
||||
<el-radio :model-value="selectedMode === mode.key" :label="mode.key"> </el-radio>
|
||||
</div>
|
||||
</template>
|
||||
<p style="font-size:13px;color:#666;line-height:1.6;">{{ mode.description }}</p>
|
||||
<el-tag type="info" size="small">示例</el-tag>
|
||||
<p style="font-size:12px;color:#999;margin-top:4px;background:#f5f7fa;padding:6px 8px;border-radius:4px;">{{ mode.example }}</p>
|
||||
</el-card>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:12px;">
|
||||
<el-button type="primary" size="large" @click="saveAlignConfig" :loading="saving" :disabled="!selectedMode">
|
||||
{{ alignConfigured ? '切换为「' + selectedModeName + '」' : '确定并启用「' + selectedModeName + '」' }}
|
||||
</el-button>
|
||||
<el-button v-if="alignConfigured" @click="showSelector = false; selectedMode = currentMode?.key || ''">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 看板主体 -->
|
||||
<div v-else>
|
||||
<!-- 地图选择器 -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<span style="font-size:13px;color:#666;white-space:nowrap;">筛选地图:</span>
|
||||
<el-select v-model="selectedMapId" placeholder="全部KPI" clearable style="width:240px;" @change="onMapChange">
|
||||
<el-option v-for="m in mapList" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<span v-if="selectedMapId" style="font-size:12px;color:#409eff;">仅显示该地图关联KPI的对齐关系</span>
|
||||
<span v-else style="font-size:12px;color:#999;">显示全部KPI</span>
|
||||
</div>
|
||||
<!-- 状态栏 -->
|
||||
<div style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">对齐模式</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ currentMode?.name || '-' }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">KPI总数</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ treeData?.total || 0 }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">有数据</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ kpiWithData }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" style="flex:1;min-width:120px;">
|
||||
<div style="font-size:12px;color:#999;">红黄灯</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-top:4px;">
|
||||
<span v-if="redCount > 0" style="color:#f56c6c;">{{ redCount }}红</span>
|
||||
<span v-if="redCount > 0 && yellowCount > 0" style="color:#999;margin:0 4px;">/</span>
|
||||
<span v-if="yellowCount > 0" style="color:#e6a23c;">{{ yellowCount }}黄</span>
|
||||
<span v-if="redCount === 0 && yellowCount === 0" style="color:#67c23a;">全部正常</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 对齐树 -->
|
||||
<div style="border:1px solid #e4e7ed;border-radius:8px;padding:12px;background:#fff;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<span style="font-weight:500;">对齐关系({{ treeData?.mode_name || '' }})</span>
|
||||
<span style="font-size:12px;color:#999;" v-if="treeData">
|
||||
最后数据期间:{{ latestPeriod }}
|
||||
<el-button text size="small" @click="loadTree" :loading="loading" style="margin-left:8px;">刷新</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<el-tree v-if="treeData" :data="treeData.tree || []" :props="{ label: 'kpi_name', children: 'children' }" default-expand-all>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node-row">
|
||||
<!-- 维度组 -->
|
||||
<template v-if="data.is_dimension_group">
|
||||
<span :style="{color: dimColor(data.dimension), fontWeight:600}">{{ data.kpi_name }}</span>
|
||||
<span class="tree-meta">({{ data.child_count }}个KPI)</span>
|
||||
<span v-if="data.description" class="tree-desc">— {{ data.description }}</span>
|
||||
</template>
|
||||
<!-- 类别组 -->
|
||||
<template v-else-if="data.is_category_group">
|
||||
<el-tag size="small" type="success">{{ data.category_label }}</el-tag>
|
||||
<span v-if="data.feeds" class="tree-drive">→ 驱动 {{ feedLabels(data.feeds) }}</span>
|
||||
<span v-else class="tree-drive" style="color:#67c23a;">最终结果</span>
|
||||
<span class="tree-meta">({{ data.child_count }}个)</span>
|
||||
</template>
|
||||
<!-- KPI节点 -->
|
||||
<template v-else>
|
||||
<span class="tree-level-dot" :class="'level-' + (data.alert_level || 'none')"></span>
|
||||
<span class="tree-code">{{ data.kpi_code }}</span>
|
||||
<span class="tree-name">{{ data.kpi_name }}</span>
|
||||
<span v-if="data.alignment_type === 'vertical_split'" class="tree-tag tag-amber">承接 {{ data.parent_code }}</span>
|
||||
<span v-if="data.drives" class="tree-tag tag-gray">→ {{ dimLabel(data.drives) }}</span>
|
||||
<!-- 目标值 -->
|
||||
<span class="tree-val" v-if="data.target_value != null">
|
||||
目标:<strong>{{ fmtNum(data.target_value) }}</strong>{{ data.unit }}
|
||||
</span>
|
||||
<!-- 实际值 -->
|
||||
<span class="tree-val" v-if="data.actual_value != null" :class="statusClass(data)">
|
||||
实绩:<strong>{{ fmtNum(data.actual_value) }}</strong>{{ data.unit }}
|
||||
</span>
|
||||
<span v-else class="tree-val tree-val-na">实绩:-</span>
|
||||
<!-- 偏差率 -->
|
||||
<span v-if="data.actual_value != null && data.target_value != null && data.target_value > 0"
|
||||
class="tree-deviation" :class="devClass(data)">
|
||||
{{ calcDev(data) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
<div v-else-if="!loading" style="text-align:center;padding:40px;color:#999;">
|
||||
暂无对齐数据,请先配置对齐模式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const alignModes = ref<any[]>([])
|
||||
const alignConfigured = ref(false)
|
||||
const currentMode = ref<any>(null)
|
||||
const selectedMode = ref('')
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const treeData = ref<any>(null)
|
||||
const showSelector = ref(false)
|
||||
|
||||
// 地图选择
|
||||
const mapList = ref<any[]>([])
|
||||
const selectedMapId = ref<number | null>(null)
|
||||
|
||||
async function loadMapList() {
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const r = await fetch('/api/cma/maps', {
|
||||
headers: { 'Authorization': `'Bearer ' + token` },
|
||||
}).then(r => r.json())
|
||||
mapList.value = r.data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onMapChange(val: number | null) {
|
||||
selectedMapId.value = val
|
||||
// 更新URL(可选)
|
||||
if (val) {
|
||||
router.replace(`/alignment?map_id=${val}`)
|
||||
} else {
|
||||
router.replace('/alignment')
|
||||
}
|
||||
loadTree()
|
||||
}
|
||||
|
||||
const selectedModeName = computed(() => {
|
||||
const m = alignModes.value.find(m => m.key === selectedMode.value)
|
||||
return m ? m.name : ''
|
||||
})
|
||||
|
||||
// 统计
|
||||
const kpiWithData = computed(() => {
|
||||
if (!treeData.value) return 0
|
||||
return countKpiField(treeData.value.tree || [], 'actual_value')
|
||||
})
|
||||
const redCount = computed(() => {
|
||||
if (!treeData.value) return 0
|
||||
return countAlert(treeData.value.tree || [], 'red')
|
||||
})
|
||||
const yellowCount = computed(() => {
|
||||
if (!treeData.value) return 0
|
||||
return countAlert(treeData.value.tree || [], 'yellow')
|
||||
})
|
||||
const latestPeriod = computed(() => {
|
||||
if (!treeData.value) return '-'
|
||||
return findLatestPeriod(treeData.value.tree || [])
|
||||
})
|
||||
|
||||
function countKpiField(nodes: any[], field: string): number {
|
||||
let count = 0
|
||||
for (const n of nodes) {
|
||||
if (n.children) count += countKpiField(n.children, field)
|
||||
else if (n[field] != null) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
function countAlert(nodes: any[], level: string): number {
|
||||
let count = 0
|
||||
for (const n of nodes) {
|
||||
if (n.children) count += countAlert(n.children, level)
|
||||
else if (n.alert_level === level) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
function findLatestPeriod(nodes: any[]): string {
|
||||
let latest = ''
|
||||
for (const n of nodes) {
|
||||
if (n.children) {
|
||||
const sub = findLatestPeriod(n.children)
|
||||
if (sub > latest) latest = sub
|
||||
} else if (n.period && n.period > latest) {
|
||||
latest = n.period
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
const dimColor = (dim: string) => {
|
||||
const colors: Record<string, string> = { finance: '#409eff', customer: '#67c23a', process: '#e6a23c', learning: '#f56c6c' }
|
||||
return colors[dim] || '#999'
|
||||
}
|
||||
const dimLabel = (dim: string) => {
|
||||
const labels: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长', internal_process: '内部流程' }
|
||||
return labels[dim] || dim
|
||||
}
|
||||
const feedLabels = (feeds: string[] | null) => {
|
||||
if (!feeds) return ''
|
||||
const map: Record<string, string> = {
|
||||
supply_chain: '供应链效率', delivery_quality: '交付质量',
|
||||
customer_scale: '客户规模', customer_concentration: '客户集中度',
|
||||
customer_satisfaction: '客户满意', revenue_growth: '收入增长',
|
||||
profitability: '盈利水平', cost_control: '成本费用', asset_efficiency: '资产效率',
|
||||
}
|
||||
return feeds.map(f => map[f] || f).join('、')
|
||||
}
|
||||
const fmtNum = (v: number) => {
|
||||
if (v == null) return '-'
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||
if (Number.isInteger(v)) return v.toLocaleString()
|
||||
return v.toFixed(1)
|
||||
}
|
||||
const calcDev = (d: any) => {
|
||||
if (d.actual_value == null || d.target_value == null || d.target_value === 0) return ''
|
||||
const dev = ((d.actual_value - d.target_value) / d.target_value * 100)
|
||||
const sign = dev > 0 ? '+' : ''
|
||||
return `${sign}${dev.toFixed(1)}%`
|
||||
}
|
||||
const statusClass = (d: any) => {
|
||||
if (d.alert_level === 'red') return 'val-red'
|
||||
if (d.alert_level === 'yellow') return 'val-yellow'
|
||||
if (d.alert_level === 'green') return 'val-green'
|
||||
return ''
|
||||
}
|
||||
const devClass = (d: any) => {
|
||||
if (d.alert_level === 'red') return 'dev-red'
|
||||
if (d.alert_level === 'yellow') return 'dev-yellow'
|
||||
return 'dev-green'
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/cma/alignment/config').then(r => r.json())
|
||||
alignModes.value = r.modes || []
|
||||
alignConfigured.value = r.configured || false
|
||||
if (r.configured && r.mode) {
|
||||
currentMode.value = alignModes.value.find(m => m.key === r.mode.mode) || null
|
||||
selectedMode.value = r.mode.mode
|
||||
}
|
||||
showSelector.value = !r.configured
|
||||
if (r.configured) loadTree()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveAlignConfig() {
|
||||
if (!selectedMode.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
await fetch('/api/cma/alignment/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `'Bearer ' + token` },
|
||||
body: JSON.stringify({ mode: selectedMode.value }),
|
||||
}).then(r => r.json())
|
||||
ElMessage.success('对齐模式已设置')
|
||||
currentMode.value = alignModes.value.find(m => m.key === selectedMode.value) || null
|
||||
alignConfigured.value = true
|
||||
showSelector.value = false
|
||||
loadTree()
|
||||
} catch { ElMessage.error('设置失败') }
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
let url = '/api/cma/alignment/tree'
|
||||
if (selectedMapId.value) url += `?map_id=${selectedMapId.value}`
|
||||
const r = await fetch(url, {
|
||||
headers: { 'Authorization': `'Bearer ' + token` },
|
||||
}).then(r => r.json())
|
||||
treeData.value = r
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '加载失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
loadMapList()
|
||||
// 如果URL有map_id参数,自动选中对应地图
|
||||
if (route.query.map_id) {
|
||||
const mid = Number(route.query.map_id)
|
||||
if (mid) {
|
||||
selectedMapId.value = mid
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alignment-page { padding: 16px; height: 100%; overflow-y: auto; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.page-header h3 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
|
||||
.mode-card { cursor: pointer; transition: all 0.2s; border: 2px solid transparent; }
|
||||
.mode-card:hover { border-color: #409eff; }
|
||||
.selected-mode { border-color: #409eff; background: #f0f7ff; }
|
||||
|
||||
.tree-node-row { display: flex; align-items: center; gap: 6px; font-size: 13px; padding: 3px 0; flex-wrap: wrap; }
|
||||
.tree-code { font-family: monospace; font-size: 11px; color: #909399; }
|
||||
.tree-name { color: #303133; }
|
||||
.tree-meta { font-size: 11px; color: #999; }
|
||||
.tree-desc { font-size: 11px; color: #909399; }
|
||||
.tree-drive { font-size: 11px; color: #e6a23c; }
|
||||
.tree-tag { font-size: 11px; padding: 1px 6px; border-radius: 3px; }
|
||||
.tag-amber { background: #fdf6ec; color: #e6a23c; }
|
||||
.tag-gray { background: #f4f4f5; color: #909399; }
|
||||
.tree-level-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.level-red { background: #f56c6c; }
|
||||
.level-yellow { background: #e6a23c; }
|
||||
.level-green { background: #67c23a; }
|
||||
.level-none { background: #dcdfe6; }
|
||||
.tree-val { font-size: 12px; color: #606266; margin-left: 4px; }
|
||||
.tree-val-na { color: #ccc; }
|
||||
.val-red { color: #f56c6c; }
|
||||
.val-yellow { color: #e6a23c; }
|
||||
.val-green { color: #67c23a; }
|
||||
.tree-deviation { font-size: 11px; font-weight: 500; padding: 1px 5px; border-radius: 3px; }
|
||||
.dev-red { background: #fef0f0; color: #f56c6c; }
|
||||
.dev-yellow { background: #fdf6ec; color: #e6a23c; }
|
||||
.dev-green { background: #f0f9eb; color: #67c23a; }
|
||||
</style>
|
||||
@@ -0,0 +1,738 @@
|
||||
<template>
|
||||
<div class="kpi-dict-page">
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<h3>KPI字典</h3>
|
||||
<div class="header-actions">
|
||||
<el-button @click="showTemplateSelect = true">📋 从模板创建</el-button>
|
||||
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<!-- 左侧:BSC分类导航树 -->
|
||||
<div class="left-tree">
|
||||
<div class="tree-header">BSC分类导航</div>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="categoryTree"
|
||||
node-key="key"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
:default-expanded-keys="defaultExpanded"
|
||||
:show-checkbox="true"
|
||||
:check-strictly="true"
|
||||
@check="onTreeCheck"
|
||||
highlight-current
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="tree-node">
|
||||
<span class="tree-node-label">{{ data.label }}</span>
|
||||
<el-tag size="small" :type="data.children && data.children.length > 0 ? '' : 'info'" class="tree-count">{{ data.count }}</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:搜索+表格 -->
|
||||
<div class="right-content">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索KPI名称/编码" clearable style="width:220px" @clear="loadKpis" @keyup.enter="loadKpis" />
|
||||
<el-select v-model="searchDimension" placeholder="维度" clearable style="width:110px" @change="loadKpis">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
<el-select v-model="searchCategory" placeholder="二级类别" clearable style="width:140px" @change="loadKpis">
|
||||
<el-option v-for="c in allCategories" :key="c.value" :label="c.label" :value="c.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadKpis">查询</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<div class="batch-bar" v-if="selectedIds.length > 0">
|
||||
<span class="selected-info">已选 {{ selectedIds.length }} 条</span>
|
||||
<el-button size="small" @click="batchDelete">批量删除</el-button>
|
||||
<el-button size="small" @click="batchExport">导出CSV</el-button>
|
||||
<el-button size="small" @click="selectedIds=[]">取消选择</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="kpis"
|
||||
v-loading="loading"
|
||||
style="width:100%"
|
||||
@selection-change="onSelectionChange"
|
||||
@row-click="(row) => $router.push('/kpis/' + row.id)"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
>
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="kpi-code">{{ row.kpi_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="kpi_name" label="名称" min-width="170" />
|
||||
<el-table-column prop="dimension" label="维度" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="二级类别" width="90">
|
||||
<template #default="{ row }">{{ catLabel(row.category) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_value" label="目标值" width="90">
|
||||
<template #default="{ row }">{{ formatTarget(row.target_value, row.unit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="frequency" label="频率" width="70">
|
||||
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="data_source_type" label="数据源" width="70">
|
||||
<template #default="{ row }">{{ sourceLabel(row.data_source_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前值" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.actual_value !== null" :class="'status-' + (row.alert_level || 'none')">
|
||||
{{ row.actual_value }}{{ row.unit }}
|
||||
</span>
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_system" size="small" type="info">系统</el-tag>
|
||||
<el-tag v-else size="small" type="success">自定义</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预警" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.alert_level && row.alert_level !== 'none'" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
|
||||
{{ row.alert_level === 'red' ? '红灯' : row.alert_level === 'yellow' ? '黄灯' : '绿灯' }}
|
||||
</el-tag>
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click.stop="$router.push('/kpis/' + row.id)">编辑</el-button>
|
||||
<el-button size="small" text type="danger" @click.stop="doDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<span class="total-info">共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="loadKpis"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<MyDialog v-model="showForm" :title="editMode ? '编辑KPI' : '新建KPI'" :width="640">
|
||||
<el-form :model="form" label-width="110px" size="small">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码"><el-input v-model="form.kpi_code" :disabled="editMode" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI名称"><el-input v-model="form.kpi_name" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="维度">
|
||||
<el-select v-model="form.dimension" style="width:100%" @change="onDimensionChange">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="二级类别">
|
||||
<el-select v-model="form.category" style="width:100%" :disabled="!form.dimension">
|
||||
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标值"><el-input-number v-model="form.target_value" :min="0" style="width:100%" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, 元, 次, 个..." /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="频率">
|
||||
<el-select v-model="form.frequency" style="width:100%">
|
||||
<el-option label="月度" value="monthly" />
|
||||
<el-option label="季度" value="quarterly" />
|
||||
<el-option label="年度" value="yearly" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="form.data_source_type" style="width:100%">
|
||||
<el-option label="ERP自动" value="erp" />
|
||||
<el-option label="Excel导入" value="excel" />
|
||||
<el-option label="手工填报" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责部门"><el-input v-model="form.responsible_dept" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人"><el-input v-model="form.responsible_user" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="计算公式"><el-input v-model="form.formula" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="绿灯阈值">
|
||||
<el-input v-model="form.threshold_green" placeholder="如 >=80" />
|
||||
<div class="threshold-hint">超过此值视为正常</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="黄灯阈值">
|
||||
<el-input v-model="form.threshold_yellow" placeholder="如 >=60" />
|
||||
<div class="threshold-hint">超过此值预警</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="红灯阈值">
|
||||
<el-input v-model="form.threshold_red" placeholder="如 <60" />
|
||||
<div class="threshold-hint">触发此值紧急</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="所属Epic">
|
||||
<el-select v-model="form.epic" style="width:100%">
|
||||
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showForm=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveKPI">{{ editMode ? '保存' : '创建' }}</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 从模板创建弹窗 -->
|
||||
<el-dialog v-model="showTemplateSelect" title="从模板创建KPI" width="760" :close-on-click-modal="false">
|
||||
<div v-loading="templateLoading">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="search-bar" style="margin-bottom:12px;">
|
||||
<el-input v-model="templateKeyword" placeholder="搜索模板名称/编码" clearable style="width:200px" />
|
||||
<el-select v-model="templateDimFilter" placeholder="维度" clearable style="width:110px">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
<el-button @click="loadTemplates">查询</el-button>
|
||||
</div>
|
||||
<!-- 模板列表 -->
|
||||
<el-table :data="templates" style="width:100%" border stripe size="small"
|
||||
:highlight-current-row="true"
|
||||
@current-change="onTemplateSelect"
|
||||
>
|
||||
<el-table-column type="index" width="40" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="120">
|
||||
<template #default="{ row }"><span class="kpi-code">{{ row.kpi_code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="kpi_name" label="名称" min-width="140" />
|
||||
<el-table-column prop="dimension" label="维度" width="70">
|
||||
<template #default="{ row }"><el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类别" width="80">
|
||||
<template #default="{ row }">{{ catLabel(row.category) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
<el-table-column prop="target_value" label="建议目标" width="80">
|
||||
<template #default="{ row }">{{ row.target_value ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="来源" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_system" size="small" type="info">系统</el-tag>
|
||||
<el-tag v-else size="small" type="success">自定义</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!selectedTemplate" style="color:#999;font-size:13px;margin-top:8px;">请选择一个模板,然后填写实例化信息</div>
|
||||
<!-- 实例化表单 -->
|
||||
<div v-if="selectedTemplate" class="instantiate-form" style="margin-top:12px;border-top:1px solid #eee;padding-top:12px;">
|
||||
<div style="font-size:14px;font-weight:500;margin-bottom:8px;">从「{{ selectedTemplate.kpi_name }}」创建KPI实例</div>
|
||||
<el-form :model="instantiateForm" label-width="90px" size="small">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码"><el-input v-model="instantiateForm.kpi_code" :placeholder="selectedTemplate.kpi_code" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI名称"><el-input v-model="instantiateForm.kpi_name" :placeholder="selectedTemplate.kpi_name" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标值"><el-input-number v-model="instantiateForm.target_value" :min="0" style="width:100%" :placeholder="String(selectedTemplate.target_value ?? '')" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="instantiateForm.data_source_type" style="width:100%">
|
||||
<el-option label="ERP自动" value="erp" />
|
||||
<el-option label="Excel导入" value="excel" />
|
||||
<el-option label="手工填报" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责部门"><el-input v-model="instantiateForm.responsible_dept" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人"><el-input v-model="instantiateForm.responsible_user" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<div style="color:#999;font-size:12px;margin-top:4px;">* 编码和名称不填则使用模板默认值,后续可在KPI字典中修改</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showTemplateSelect=false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedTemplate" @click="doInstantiate">从模板创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { kpiApi, templateApi, dashboardApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
// ── 数据 ──
|
||||
const kpis = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
// ── 搜索筛选 ──
|
||||
const searchKeyword = ref('')
|
||||
const searchDimension = ref('')
|
||||
const searchCategory = ref('')
|
||||
|
||||
// ── 分类树 ──
|
||||
const categoryTree = ref<any[]>([])
|
||||
const defaultExpanded = ref<string[]>([])
|
||||
const treeRef = ref<any>(null)
|
||||
const treeFilterDims = ref<string[]>([])
|
||||
const treeFilterCats = ref<string[]>([])
|
||||
|
||||
// ── 多选 ──
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
// ── 新建/编辑表单 ──
|
||||
const showForm = ref(false)
|
||||
const editMode = ref(false)
|
||||
const form = ref<any>({})
|
||||
|
||||
// ── 维度映射 ──
|
||||
const DIM_MAP: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
const CAT_MAP: Record<string, string> = {
|
||||
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
|
||||
asset_efficiency: '资产效率', cash_risk: '现金流风控',
|
||||
customer_scale: '客户规模', customer_concentration: '客户集中度', customer_satisfaction: '客户满意',
|
||||
supply_chain: '供应链效率', delivery_quality: '交付质量',
|
||||
talent_pipeline: '人才梯队', employee_engagement: '员工敬业', innovation: '创新改善',
|
||||
}
|
||||
const FREQ_MAP: Record<string, string> = { daily: '日', weekly: '周', monthly: '月', quarterly: '季', yearly: '年' }
|
||||
const SOURCE_MAP: Record<string, string> = { erp: 'ERP', excel: 'Excel', manual: '手工' }
|
||||
|
||||
// 维度→二级类别 对照映射
|
||||
const DIM_CAT_MAP: Record<string, string[]> = {
|
||||
finance: ['revenue_growth', 'profitability', 'cost_control', 'asset_efficiency', 'cash_risk'],
|
||||
customer: ['customer_scale', 'customer_concentration', 'customer_satisfaction'],
|
||||
process: ['supply_chain', 'delivery_quality'],
|
||||
learning: ['talent_pipeline', 'employee_engagement', 'innovation'],
|
||||
}
|
||||
|
||||
// 二级类别选项(联动维度)
|
||||
const categoryOptions = computed(() => {
|
||||
const dim = form.value?.dimension
|
||||
if (!dim) return []
|
||||
const catKeys = DIM_CAT_MAP[dim] || []
|
||||
return catKeys.map(key => ({ value: key, label: CAT_MAP[key] || key }))
|
||||
})
|
||||
|
||||
// 所有二级类别(搜索栏用)
|
||||
const allCategories = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
const result: { value: string; label: string }[] = []
|
||||
for (const key in DIM_CAT_MAP) {
|
||||
for (const cat of DIM_CAT_MAP[key]) {
|
||||
if (!seen.has(cat)) {
|
||||
seen.add(cat)
|
||||
result.push({ value: cat, label: CAT_MAP[cat] || cat })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function dimLabel(d: string) { return DIM_MAP[d] || d }
|
||||
function catLabel(c: string) { return CAT_MAP[c] || c }
|
||||
function freqLabel(f: string) { return FREQ_MAP[f] || f }
|
||||
function sourceLabel(s: string) { return SOURCE_MAP[s] || s }
|
||||
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
|
||||
|
||||
function formatTarget(val: number, unit: string) {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (val >= 10000) return (val / 10000).toFixed(0) + '万' + (unit || '')
|
||||
return val + (unit || '')
|
||||
}
|
||||
|
||||
// ── 加载分类树 ──
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const r: any = await kpiApi.listCategories()
|
||||
categoryTree.value = [
|
||||
{ key: '_all', label: '全部', count: r.total, children: r.tree || [] }
|
||||
]
|
||||
defaultExpanded.value = ['_all', ...(r.tree || []).map((n: any) => n.key)]
|
||||
} catch (e) {
|
||||
console.error('加载分类树失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 树节点勾选 ──
|
||||
function onTreeCheck(data: any, { checkedKeys }: any) {
|
||||
// 简单模式:只支持单选过滤
|
||||
if (data.key === '_all') {
|
||||
// 勾选全部=清空过滤
|
||||
treeFilterDims.value = []
|
||||
treeFilterCats.value = []
|
||||
} else if (data.children) {
|
||||
// 维度节点
|
||||
treeFilterDims.value = checkedKeys.includes(data.key) ? [data.key] : []
|
||||
treeFilterCats.value = []
|
||||
} else {
|
||||
// 类别节点
|
||||
treeFilterCats.value = checkedKeys.includes(data.key) ? [data.key] : []
|
||||
treeFilterDims.value = []
|
||||
}
|
||||
loadKpis()
|
||||
}
|
||||
|
||||
// ── 加载KPI列表 ──
|
||||
async function loadKpis() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize }
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||
if (searchDimension.value) params.dimension = searchDimension.value
|
||||
if (searchCategory.value) params.category = searchCategory.value
|
||||
if (treeFilterDims.value.length > 0 && !params.dimension) params.dimension = treeFilterDims.value[0]
|
||||
if (treeFilterCats.value.length > 0 && !params.category) params.category = treeFilterCats.value[0]
|
||||
|
||||
const r: any = await kpiApi.list(params)
|
||||
kpis.value = r.data || []
|
||||
total.value = r.total || 0
|
||||
} catch (e) {
|
||||
console.error('加载KPI失败', e)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 获取当前值
|
||||
async function loadActualValues() {
|
||||
try {
|
||||
const r: any = await dashboardApi.kpis({ role: 'ceo', period: 'month' })
|
||||
const vals = r.data || []
|
||||
const valMap: Record<number, any> = {}
|
||||
for (const v of vals) {
|
||||
valMap[v.id] = v
|
||||
}
|
||||
for (const kpi of kpis.value) {
|
||||
if (valMap[kpi.id]) {
|
||||
kpi.actual_value = valMap[kpi.id].actual_value
|
||||
kpi.alert_level = valMap[kpi.id].alert_level
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchKeyword.value = ''
|
||||
searchDimension.value = ''
|
||||
searchCategory.value = ''
|
||||
treeFilterDims.value = []
|
||||
treeFilterCats.value = []
|
||||
if (treeRef.value) treeRef.value.setCheckedKeys([])
|
||||
page.value = 1
|
||||
loadKpis()
|
||||
}
|
||||
|
||||
// ── 多选 ──
|
||||
function onSelectionChange(rows: any[]) {
|
||||
selectedIds.value = rows.map((r: any) => r.id)
|
||||
}
|
||||
|
||||
function batchDelete() {
|
||||
ElMessageBox.confirm(`确定删除选中的 ${selectedIds.value.length} 条KPI?`, '提示').then(async () => {
|
||||
for (const id of selectedIds.value) {
|
||||
try { await kpiApi.delete(id) } catch (e) {}
|
||||
}
|
||||
ElMessage.success('批量删除完成')
|
||||
selectedIds.value = []
|
||||
loadKpis()
|
||||
loadCategories()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function batchExport() {
|
||||
const header = '编码,名称,维度,二级类别,目标值,频率,数据源,负责人,计算公式'
|
||||
const rows = kpis.value.map((k: any) =>
|
||||
[k.kpi_code, k.kpi_name, dimLabel(k.dimension), catLabel(k.category), k.target_value, freqLabel(k.frequency), sourceLabel(k.data_source_type), k.responsible_user, (k.formula || '')].join(',')
|
||||
)
|
||||
const bom = '\uFEFF' // Excel UTF-8 BOM
|
||||
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = 'KPI字典_' + new Date().toISOString().slice(0, 10) + '.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── 新建/保存 ──
|
||||
function onDimensionChange(val: string) {
|
||||
form.value.category = ''
|
||||
}
|
||||
|
||||
async function saveKPI() {
|
||||
try {
|
||||
if (editMode.value) {
|
||||
await kpiApi.update(form.value.id, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await kpiApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
showForm.value = false
|
||||
loadKpis()
|
||||
loadCategories()
|
||||
} catch (e) {
|
||||
ElMessage.error(editMode.value ? '保存失败' : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
function doDelete(id: number) {
|
||||
ElMessageBox.confirm('确定删除此KPI?', '提示').then(async () => {
|
||||
try { await kpiApi.delete(id); ElMessage.success('已删除'); loadKpis(); loadCategories() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.detail || '删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ── 从模板创建 ──
|
||||
const showTemplateSelect = ref(false)
|
||||
const templateLoading = ref(false)
|
||||
const templates = ref<any[]>([])
|
||||
const templateKeyword = ref('')
|
||||
const templateDimFilter = ref('')
|
||||
const selectedTemplate = ref<any>(null)
|
||||
const instantiateForm = ref<any>({})
|
||||
|
||||
async function loadTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (templateKeyword.value) params.keyword = templateKeyword.value
|
||||
if (templateDimFilter.value) params.dimension = templateDimFilter.value
|
||||
const r: any = await templateApi.list(params)
|
||||
templates.value = r.data || []
|
||||
} catch (e) {
|
||||
console.error('加载模板失败', e)
|
||||
}
|
||||
templateLoading.value = false
|
||||
}
|
||||
|
||||
function onTemplateSelect(tpl: any) {
|
||||
selectedTemplate.value = tpl
|
||||
instantiateForm.value = {
|
||||
kpi_code: '',
|
||||
kpi_name: '',
|
||||
target_value: undefined,
|
||||
data_source_type: 'manual',
|
||||
responsible_dept: '',
|
||||
responsible_user: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstantiate() {
|
||||
if (!selectedTemplate.value) return
|
||||
try {
|
||||
const data: any = {}
|
||||
if (instantiateForm.value.kpi_code) data.kpi_code = instantiateForm.value.kpi_code
|
||||
if (instantiateForm.value.kpi_name) data.kpi_name = instantiateForm.value.kpi_name
|
||||
if (instantiateForm.value.target_value !== undefined && instantiateForm.value.target_value !== null)
|
||||
data.target_value = instantiateForm.value.target_value
|
||||
if (instantiateForm.value.data_source_type) data.data_source_type = instantiateForm.value.data_source_type
|
||||
if (instantiateForm.value.responsible_dept) data.responsible_dept = instantiateForm.value.responsible_dept
|
||||
if (instantiateForm.value.responsible_user) data.responsible_user = instantiateForm.value.responsible_user
|
||||
await templateApi.instantiate(selectedTemplate.value.id, data)
|
||||
ElMessage.success(`已从模板「${selectedTemplate.value.kpi_name}」创建KPI`)
|
||||
showTemplateSelect.value = false
|
||||
selectedTemplate.value = null
|
||||
instantiateForm.value = {}
|
||||
loadKpis()
|
||||
loadCategories()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadKpis()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-dict-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.page-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.main-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.left-tree {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tree-header {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.tree-node-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
.tree-count {
|
||||
font-size: 11px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.right-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #ecf5ff;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.selected-info {
|
||||
font-size: 13px;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.kpi-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
.threshold-hint {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.status-red { color: #f56c6c; font-weight: 500; }
|
||||
.status-yellow { color: #e6a23c; font-weight: 500; }
|
||||
.status-green { color: #67c23a; font-weight: 500; }
|
||||
.status-none { color: #606266; }
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.total-info {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
:deep(.el-tree-node__content) {
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<KPIListView
|
||||
:dimension="'learning'"
|
||||
:title="'学习成长维度KPI看板'"
|
||||
:icon="'📚'"
|
||||
:stat-labels="{ total: '学习指标总数', green: '达标', yellow: '预警', red: '未达标', noData: '无数据' }"
|
||||
:templates="LEARNING_TEMPLATES"
|
||||
:has-categories="true"
|
||||
:categories="CAPITAL_CATEGORIES"
|
||||
:name-placeholder="'关键岗位胜任度'"
|
||||
:unit-options="['%', '分', '小时', '次', '个']"
|
||||
form-title-create="新建学习成长KPI"
|
||||
form-title-edit="编辑学习成长KPI"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import KPIListView from '../components/KPIListView.vue'
|
||||
|
||||
// ── 9个预设指标模板 ──
|
||||
const LEARNING_TEMPLATES = [
|
||||
// 人力资本 (3)
|
||||
{ category: '人力资本', name: '关键岗位胜任度', unit: '%', targetValue: 90 },
|
||||
{ category: '人力资本', name: '培训完成率', unit: '%', targetValue: 100 },
|
||||
{ category: '人力资本', name: '核心技能掌握度', unit: '%', targetValue: 85 },
|
||||
// 信息资本 (3)
|
||||
{ category: '信息资本', name: '系统覆盖率', unit: '%', targetValue: 95 },
|
||||
{ category: '信息资本', name: '数据自动化率', unit: '%', targetValue: 80 },
|
||||
{ category: '信息资本', name: '报表生成时间', unit: '小时', targetValue: 2 },
|
||||
// 组织资本 (3)
|
||||
{ category: '组织资本', name: '战略认知度', unit: '%', targetValue: 85 },
|
||||
{ category: '组织资本', name: '员工满意度', unit: '%', targetValue: 80 },
|
||||
{ category: '组织资本', name: '跨部门协作评分', unit: '分', targetValue: 4 },
|
||||
]
|
||||
|
||||
// ── 资本类型分类配置 ──
|
||||
const CAPITAL_CATEGORIES = [
|
||||
{ key: '人力资本', label: '人力资本', icon: '🧑🎓', subtitle: '关键岗位胜任度 / 培训完成率 / 核心技能掌握度', color: '#409EFF' },
|
||||
{ key: '信息资本', label: '信息资本', icon: '💻', subtitle: '系统覆盖率 / 数据自动化率 / 报表生成时间', color: '#67C23A' },
|
||||
{ key: '组织资本', label: '组织资本', icon: '🏛️', subtitle: '战略认知度 / 员工满意度 / 跨部门协作评分', color: '#E6A23C' },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,979 @@
|
||||
<template>
|
||||
<div class="map-canvas-page" ref="canvasRef">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<h3>战略地图画布 — 四层泳道</h3>
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadCanvas">
|
||||
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls" title="画布缩放">
|
||||
<el-button size="small" @click="zoomOut" :disabled="zoomLevel <= 0.5">−</el-button>
|
||||
<span class="zoom-label">{{ Math.round(zoomLevel * 100) }}%</span>
|
||||
<el-button size="small" @click="zoomIn" :disabled="zoomLevel >= 1.5">+</el-button>
|
||||
<el-button size="small" @click="zoomReset">100%</el-button>
|
||||
</div>
|
||||
<el-button type="primary" @click="saveCanvas">保存</el-button>
|
||||
<el-button type="success" @click="publishMap" v-if="currentMap?.status === 'draft'">发布</el-button>
|
||||
<el-button @click="showVersions = true; loadVersions()">版本历史</el-button>
|
||||
<el-button @click="goAlignment" type="info" plain>
|
||||
<svg style="width:14px;height:14px;margin-right:4px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h4l2-3 3 3 4-7 3 5 2-2 4 4"/>
|
||||
</svg>
|
||||
查看执行看板
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连线模式提示 -->
|
||||
<div v-if="linkingFrom" class="linking-bar">
|
||||
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
|
||||
<el-button size="small" @click="cancelLink">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state">
|
||||
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写">
|
||||
<template #image>
|
||||
<svg viewBox="0 0 120 120" width="120" height="120">
|
||||
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" />
|
||||
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" />
|
||||
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" />
|
||||
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" />
|
||||
</svg>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 画布主体(纵向泳道布局,CSS zoom缩放) -->
|
||||
<div class="canvas-scroll-wrap" ref="scrollWrapRef" :style="{ zoom: zoomLevel }">
|
||||
<div class="canvas-body" ref="bodyRef">
|
||||
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
|
||||
<div
|
||||
v-for="(cfg, idx) in orderedLayerConfigs"
|
||||
:key="cfg.key"
|
||||
:ref="el => setLayerRef(cfg.key, el)"
|
||||
class="layer-swimlane-wrapper"
|
||||
:style="{ borderLeft: '4px solid ' + cfg.color }"
|
||||
>
|
||||
<StrategyLayer
|
||||
:ref="el => setLayerCompRef(cfg.key, el)"
|
||||
:layer-key="cfg.key"
|
||||
:label="cfg.label"
|
||||
:icon="cfg.icon"
|
||||
:color="cfg.color"
|
||||
:objectives="getLayerNodes(cfg.key)"
|
||||
:linking-from-key="linkingFrom?.key ?? null"
|
||||
:drag-connect-target="dragConnectTarget"
|
||||
:icon-map="iconMap"
|
||||
:kpi-name-map="kpiNameMap"
|
||||
:all-kpis="allKpis"
|
||||
:get-node-level="getNodeLevel"
|
||||
:get-node-progress="getNodeProgress"
|
||||
@add-objective="(k: string) => openAddDialog(k)"
|
||||
@edit-objective="(p: any) => openEditDialog(p.dimKey, p.idx, p.obj)"
|
||||
@delete-objective="(p: any) => deleteNode(p.dimKey, p.idx)"
|
||||
@start-link="(p: any) => { const ps = p.key.split('-'); startLink(ps[0], parseInt(ps[ps.length-1]), p.obj) }"
|
||||
@link-drag-start="(e: MouseEvent, dk: string, oi: number, obj: any) => onLinkDragStart(e, dk, oi, obj)"
|
||||
@kpi-click="(code: string) => goKPI(code)"
|
||||
@show-plan-list="(p: any) => showPlanList(p.dimKey, p.idx, p.obj)"
|
||||
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
|
||||
@reorder="(dk: string) => onLayerReorder(dk)"
|
||||
/>
|
||||
|
||||
<!-- 跨层箭头指示器(本层→下层) -->
|
||||
<div v-if="idx < orderedLayerConfigs.length - 1" class="cross-layer-arrow">
|
||||
<svg width="24" height="32" viewBox="0 0 24 32">
|
||||
<line x1="12" y1="0" x2="12" y2="24" stroke="#909399" stroke-width="2" stroke-dasharray="4,3" />
|
||||
<polygon points="4,22 12,30 20,22" fill="#909399" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end canvas-body -->
|
||||
</div><!-- end canvas-scroll-wrap -->
|
||||
|
||||
<!-- SVG 连线层 (覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
<ConnectionLines
|
||||
ref="connectionLinesRef"
|
||||
:layer-refs="layerRefs"
|
||||
:node-refs="nodeRefs"
|
||||
:layer-keys="layerKeysOrdered"
|
||||
:connections="connections"
|
||||
:temp-line="tempLine"
|
||||
:container-key="recalcTrigger"
|
||||
@select-connection="onSelectConnection"
|
||||
@delete-connection="onDeleteConnection"
|
||||
/>
|
||||
|
||||
<!-- 节点编辑弹窗 -->
|
||||
<NodeEditDialog
|
||||
:visible="showNodeDialog"
|
||||
:node-data="editingNodeData"
|
||||
:layer-key="editingLayerKey"
|
||||
@update:visible="showNodeDialog = $event"
|
||||
@save="onNodeSave"
|
||||
/>
|
||||
|
||||
<!-- 版本历史弹窗 -->
|
||||
<div v-if="showVersions" class="mc-dialog-overlay" @click.self="showVersions=false">
|
||||
<div class="mc-dialog-box" style="width:620px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>版本历史</span>
|
||||
<button class="mc-dialog-close" @click="showVersions=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="mc-table" v-if="versions.length > 0">
|
||||
<thead><tr><th>说明</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="v in versions" :key="v.id">
|
||||
<td>{{ v.comment || '-' }}</td>
|
||||
<td>{{ v.created_at }}</td>
|
||||
<td><button class="mc-btn mc-btn-sm mc-btn-warn" @click="rollback(v.id)">回滚</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else style="text-align:center;color:#999;padding:20px;">暂无版本记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI详情浮层 -->
|
||||
<div v-if="showKpiDetail" class="mc-dialog-overlay" @click.self="showKpiDetail=false">
|
||||
<div class="mc-dialog-box" style="width:560px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>📊 {{ kpiDetailObjName }} — KPI详情</span>
|
||||
<button class="mc-dialog-close" @click="showKpiDetail=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:360px;overflow-y:auto;">
|
||||
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
该目标暂未关联KPI
|
||||
</div>
|
||||
<div v-for="(item, idx) in kpiDetailList" :key="idx" class="kpi-detail-card" :class="'kpi-detail-' + getKpiLevel(item)">
|
||||
<div class="kpi-detail-top">
|
||||
<span class="kpi-detail-badge">{{ badgeIcon(getKpiLevel(item)) }}</span>
|
||||
<span class="kpi-detail-code">{{ item.code }}</span>
|
||||
<span class="kpi-detail-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-values">
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">实际值</span>
|
||||
<span class="kdv-val">{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}</span>
|
||||
</div>
|
||||
<div class="kdv-item">
|
||||
<span class="kdv-label">目标值</span>
|
||||
<span class="kdv-val">{{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
|
||||
</div>
|
||||
<div v-if="item.actual != null && item.target" class="kdv-item">
|
||||
<span class="kdv-label">达成率</span>
|
||||
<span class="kdv-val" :style="{ color: getKpiLevel(item) === 'red' ? '#f56c6c' : getKpiLevel(item) === 'yellow' ? '#e6a23c' : '#67c23a' }">
|
||||
{{ (item.actual / item.target * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.unit" class="kdv-item">
|
||||
<span class="kdv-label">单位</span>
|
||||
<span class="kdv-val">{{ item.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<el-button size="small" @click="showKpiDetail=false">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="editKpiDetailObj">编辑目标</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 行动方案列表弹窗 -->
|
||||
<div v-if="showPlanPopup" class="mc-dialog-overlay" @click.self="showPlanPopup=false">
|
||||
<div class="mc-dialog-box" style="width:600px;">
|
||||
<div class="mc-dialog-header">
|
||||
<span>📋 {{ planPopupObjName }} — 关联行动方案</span>
|
||||
<button class="mc-dialog-close" @click="showPlanPopup=false">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
|
||||
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||
暂无关联的行动方案
|
||||
</div>
|
||||
<div v-for="p in planPopupList" :key="p.id" class="plan-popup-card">
|
||||
<div class="plan-popup-top">
|
||||
<span class="plan-popup-status" :class="'pps-' + p.status">{{ planStatusLabel(p.status) }}</span>
|
||||
<span class="plan-popup-title">{{ p.title }}</span>
|
||||
</div>
|
||||
<div class="plan-popup-meta">
|
||||
<span v-if="p.assignee">👤 {{ p.assignee }}</span>
|
||||
<span v-if="p.due_date">📅 {{ p.due_date.slice(0, 10) }}</span>
|
||||
<span v-if="p.progress > 0">进度 {{ p.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<el-button size="small" @click="showPlanPopup=false">关闭</el-button>
|
||||
<el-button size="small" type="primary" @click="quickCreatePlan">+ 新建行动方案</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- P1-3: 关联知识点 -->
|
||||
<div style="margin-top:12px;">
|
||||
<KnowledgePanel related-page="/maps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import { mapApi, kpiApi } from "../api/index"
|
||||
import KnowledgePanel from '../components/KnowledgePanel.vue'
|
||||
import api from "../api/index"
|
||||
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
||||
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
|
||||
import NodeEditDialog from "../components/strategy-map/NodeEditDialog.vue"
|
||||
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
|
||||
|
||||
// ── 四层泳道配置 ──
|
||||
const layerKeysOrdered = [...LAYER_KEYS]
|
||||
const orderedLayerConfigs = getLayerList()
|
||||
|
||||
// ── 状态 ──
|
||||
const maps = ref<any[]>([])
|
||||
const selectedMap = ref<number | null>(null)
|
||||
const currentMap = ref<any>(null)
|
||||
const allKpis = ref<any[]>([])
|
||||
const kpiNameMap = reactive<Record<string, string>>({})
|
||||
|
||||
// 维度数据(对应backend的dimensions)
|
||||
const dimensions = reactive<any[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// 连线
|
||||
const connections = ref<any[]>([])
|
||||
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
|
||||
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
|
||||
const dragConnectTarget = ref<string | null>(null)
|
||||
const selectedConnIdx = ref<number | null>(null)
|
||||
|
||||
// 缩放
|
||||
const zoomLevel = ref(1)
|
||||
const scrollWrapRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 节点状态(红黄绿灯)
|
||||
const objectiveLevels = reactive<Record<string, string>>({})
|
||||
|
||||
interface KpiProgressItem {
|
||||
ratio: number
|
||||
level: string
|
||||
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[]
|
||||
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null
|
||||
}
|
||||
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
|
||||
|
||||
// 弹窗
|
||||
const showNodeDialog = ref(false)
|
||||
const editingNodeData = ref<any>(null)
|
||||
const editingLayerKey = ref('')
|
||||
const showVersions = ref(false)
|
||||
const versions = ref<any[]>([])
|
||||
|
||||
// DOM引用
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const layerRefs: Record<string, HTMLElement> = {}
|
||||
const nodeRefs: Record<string, HTMLElement> = {}
|
||||
const connectionLinesRef = ref<any>(null)
|
||||
const recalcTrigger = ref(0)
|
||||
|
||||
// 泳道组件实例引用(用于汇聚各层内部的nodeRefs给ConnectionLines)
|
||||
const layerCompRefs: Record<string, any> = {}
|
||||
function setLayerCompRef(key: string, el: any) {
|
||||
if (el) layerCompRefs[key] = el
|
||||
}
|
||||
|
||||
/** 从四个泳道组件汇聚所有节点DOM引用用于连线渲染,同步到nodeRefs */
|
||||
function collectAllNodeRefs(): Record<string, any> {
|
||||
void (0) // keep as side-effect function
|
||||
// 清空旧refs
|
||||
Object.keys(nodeRefs).forEach(k => delete (nodeRefs as any)[k])
|
||||
// 从各泳道组件汇聚
|
||||
for (const key of layerKeysOrdered) {
|
||||
const comp = layerCompRefs[key]
|
||||
if (comp?.nodeRefs) {
|
||||
Object.assign(nodeRefs, comp.nodeRefs)
|
||||
}
|
||||
}
|
||||
return nodeRefs
|
||||
}
|
||||
|
||||
// 拖拽引用(由 vuedraggable 接管,内部不维护)
|
||||
|
||||
// KPI详情浮层
|
||||
const showKpiDetail = ref(false)
|
||||
const kpiDetailObjName = ref("")
|
||||
const kpiDetailDimKey = ref("")
|
||||
const kpiDetailObjIdx = ref(-1)
|
||||
const kpiDetailList = ref<any[]>([])
|
||||
|
||||
// 行动方案弹窗
|
||||
const showPlanPopup = ref(false)
|
||||
const planPopupObjName = ref("")
|
||||
const planPopupDimKey = ref("")
|
||||
const planPopupObjIdx = ref(-1)
|
||||
const planPopupList = ref<any[]>([])
|
||||
|
||||
// ── 图标映射(从旧MapCanvas复用) ──
|
||||
const iconMap: Record<string, string> = {
|
||||
target: "Star", star: "StarFilled", rocket: "Top", chart: "DataLine",
|
||||
team: "UserFilled", light: "Lightning", shield: "Shield", gear: "Setting",
|
||||
handshake: "Handshake", medal: "Trophy",
|
||||
}
|
||||
|
||||
// ── 辅助方法 ──
|
||||
function getLayerNodes(key: string): any[] {
|
||||
const dim = dimensions.find((d: any) => d.key === key)
|
||||
return dim?.objectives || []
|
||||
}
|
||||
|
||||
function setLayerRef(key: string, el: any) {
|
||||
if (el) layerRefs[key] = el
|
||||
}
|
||||
|
||||
function getNodeLevel(key: string): string {
|
||||
return objectiveLevels[key] || 'gray'
|
||||
}
|
||||
function getNodeProgress(key: string): any {
|
||||
return kpiProgressMap[key] || null
|
||||
}
|
||||
|
||||
/** 确保dimensions中存在所有4层 */
|
||||
function ensureFourLayers() {
|
||||
for (const cfg of orderedLayerConfigs) {
|
||||
if (!dimensions.find((d: any) => d.key === cfg.key)) {
|
||||
dimensions.push({
|
||||
key: cfg.key,
|
||||
name: cfg.label,
|
||||
label: cfg.label,
|
||||
icon: cfg.icon,
|
||||
color: cfg.color,
|
||||
objectives: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 缩放 ──
|
||||
function zoomIn() { zoomLevel.value = Math.min(1.5, Math.round((zoomLevel.value + 0.1) * 10) / 10) }
|
||||
function zoomOut() { zoomLevel.value = Math.max(0.5, Math.round((zoomLevel.value - 0.1) * 10) / 10) }
|
||||
function zoomReset() { zoomLevel.value = 1 }
|
||||
|
||||
// ── 画布加载 ──
|
||||
function loadCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
dimensions.splice(0, dimensions.length)
|
||||
connections.value = []
|
||||
linkingFrom.value = null
|
||||
selectedConnIdx.value = null
|
||||
loading.value = true
|
||||
|
||||
api.get("/maps").then((r: any) => {
|
||||
const data = r.data || []
|
||||
const map = data.find((m: any) => m.id === selectedMap.value)
|
||||
currentMap.value = map
|
||||
if (map?.dimensions) {
|
||||
for (const dim of map.dimensions) {
|
||||
dimensions.push({
|
||||
key: dim.key,
|
||||
label: dim.name || dim.label,
|
||||
icon: dim.icon || '📋',
|
||||
color: dim.color || LAYER_CONFIG[dim.key]?.color || '#409eff',
|
||||
objectives: dim.objectives || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
// 确保四层都存在
|
||||
ensureFourLayers()
|
||||
connections.value = map?.canvas_data?.connections || []
|
||||
loading.value = false
|
||||
nextTick(() => {
|
||||
triggerRecalc()
|
||||
loadObjectiveLevels()
|
||||
})
|
||||
}).catch(() => {
|
||||
ensureFourLayers()
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ── 触发连线重算 ──
|
||||
function triggerRecalc() {
|
||||
recalcTrigger.value++
|
||||
collectAllNodeRefs()
|
||||
nextTick(() => {
|
||||
connectionLinesRef.value?.recalcAll()
|
||||
})
|
||||
}
|
||||
|
||||
// ── 连线交互 ──
|
||||
function startLink(dimKey: string, oi: number, obj: any) {
|
||||
selectedConnIdx.value = null
|
||||
linkingFrom.value = { key: `${dimKey}-${oi}`, obj }
|
||||
}
|
||||
|
||||
function cancelLink() {
|
||||
linkingFrom.value = null
|
||||
tempLine.value = null
|
||||
dragConnectTarget.value = null
|
||||
}
|
||||
|
||||
function onNodeClick(dimKey: string, oi: number, obj: any) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
// 连线模式
|
||||
if (linkingFrom.value) {
|
||||
if (linkingFrom.value.key === key) { cancelLink(); return }
|
||||
completeConnection(linkingFrom.value.key, key)
|
||||
return
|
||||
}
|
||||
// 有KPI则展示详情
|
||||
if (kpiProgressMap[key]?.kpiList?.length > 0) {
|
||||
showKpiDetailModal(dimKey, oi, obj)
|
||||
} else {
|
||||
openEditDialog(dimKey, oi, obj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发
|
||||
* SortableJS 已通过 :list 引用直接更新了响应式数组,
|
||||
* 这里只需触发连线重算和自动保存
|
||||
*/
|
||||
function onLayerReorder(dimKey: string) {
|
||||
nextTick(() => {
|
||||
triggerRecalc()
|
||||
debouncedSaveCanvas()
|
||||
})
|
||||
}
|
||||
|
||||
function completeConnection(fromKey: string, toKey: string) {
|
||||
const fromDim = fromKey.split('-')[0]
|
||||
const toDim = toKey.split('-')[0]
|
||||
if (fromDim === toDim) {
|
||||
// 同层连线 -> 同层右侧箭头
|
||||
}
|
||||
// 检查重复
|
||||
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
|
||||
ElMessage.warning("已存在相同连线"); cancelLink(); return
|
||||
}
|
||||
api.post(`/maps/${selectedMap.value}/connections`, {
|
||||
from: fromKey, to: toKey,
|
||||
}).then(() => {
|
||||
ElMessage.success("连线已添加")
|
||||
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
|
||||
cancelLink()
|
||||
nextTick(() => { triggerRecalc(); saveConnections() })
|
||||
}).catch((e: any) => {
|
||||
if (e?.response?.status !== 400) {
|
||||
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
|
||||
ElMessage.success("连线已添加(本地)")
|
||||
} else {
|
||||
ElMessage.warning(e?.response?.data?.detail || "连线失败")
|
||||
}
|
||||
cancelLink()
|
||||
nextTick(triggerRecalc)
|
||||
})
|
||||
}
|
||||
|
||||
// 跳转KPI详情
|
||||
function goKPI(kpiCode: string) {
|
||||
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
|
||||
window.location.href = found ? '/kpis/' + found.id : '/kpis'
|
||||
}
|
||||
|
||||
// ── 拖拽连线的鼠标跟踪 ──
|
||||
function onLinkDragStart(e: MouseEvent, dimKey: string, oi: number, obj: any) {
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
startLink(dimKey, oi, obj)
|
||||
const onUp = (up: MouseEvent) => {
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
const dist = Math.hypot(up.clientX - startX, up.clientY - startY)
|
||||
if (dist >= 5 && linkingFrom.value) {
|
||||
let targetKey: string | null = null
|
||||
for (const key of Object.keys(nodeRefs)) {
|
||||
if (key === linkingFrom.value.key) continue
|
||||
const el = nodeRefs[key]
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (up.clientX >= rect.left && up.clientX <= rect.right &&
|
||||
up.clientY >= rect.top && up.clientY <= rect.bottom) {
|
||||
targetKey = key; break
|
||||
}
|
||||
}
|
||||
if (targetKey) {
|
||||
const parts = targetKey.split('-')
|
||||
const tDim = parts[0]
|
||||
const tIdx = parseInt(parts[1])
|
||||
const dim = dimensions.find((d: any) => d.key === tDim)
|
||||
if (dim && dim.objectives[tIdx]) {
|
||||
completeConnection(linkingFrom.value.key, targetKey)
|
||||
}
|
||||
}
|
||||
cancelLink()
|
||||
}
|
||||
dragConnectTarget.value = null
|
||||
}
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
function onSelectConnection(idx: number) {
|
||||
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
|
||||
}
|
||||
|
||||
function onDeleteConnection(idx: number) {
|
||||
if (selectedConnIdx.value !== idx) return
|
||||
const conn = connections.value[idx]
|
||||
if (!conn) return
|
||||
api.delete(`/maps/${selectedMap.value}/connections`, {
|
||||
data: { from: conn.from, to: conn.to },
|
||||
}).then(() => {
|
||||
ElMessage.success("连线已删除")
|
||||
connections.value.splice(idx, 1)
|
||||
selectedConnIdx.value = null
|
||||
nextTick(triggerRecalc)
|
||||
}).catch(() => {
|
||||
connections.value.splice(idx, 1)
|
||||
selectedConnIdx.value = null
|
||||
nextTick(triggerRecalc)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 节点CRUD ──
|
||||
function openAddDialog(layerKey: string) {
|
||||
editingLayerKey.value = layerKey
|
||||
editingNodeData.value = null
|
||||
showNodeDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(dimKey: string, oi: number, obj: any) {
|
||||
editingLayerKey.value = dimKey
|
||||
editingNodeData.value = {
|
||||
id: obj.id,
|
||||
name: obj.name,
|
||||
targetValue: obj.targetValue ?? obj.target_value ?? null,
|
||||
currentValue: obj.currentValue ?? obj.current_value ?? null,
|
||||
unit: obj.unit || '%',
|
||||
owner: obj.owner || '',
|
||||
isLeading: obj.isLeading ?? obj.is_leading ?? false,
|
||||
description: obj.description || '',
|
||||
}
|
||||
// 保存编辑索引以便更新
|
||||
editingNodeData.value._dimKey = dimKey
|
||||
editingNodeData.value._index = oi
|
||||
showNodeDialog.value = true
|
||||
}
|
||||
|
||||
function onNodeSave(data: any) {
|
||||
const dim = dimensions.find((d: any) => d.key === data.layer)
|
||||
if (!dim) return
|
||||
|
||||
// 构建节点对象
|
||||
const node = {
|
||||
name: data.name,
|
||||
targetValue: data.targetValue,
|
||||
currentValue: data.currentValue,
|
||||
unit: data.unit,
|
||||
owner: data.owner,
|
||||
isLeading: data.isLeading,
|
||||
description: data.description || '',
|
||||
layer: data.layer,
|
||||
}
|
||||
|
||||
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === data.layer) {
|
||||
// 编辑已有节点
|
||||
const idx = editingNodeData.value._index
|
||||
if (dim.objectives[idx]) {
|
||||
// 保留原有kpis等额外字段
|
||||
const extra = {
|
||||
kpis: dim.objectives[idx].kpis || [],
|
||||
icon: dim.objectives[idx].icon || 'target',
|
||||
}
|
||||
dim.objectives[idx] = { ...node, ...extra }
|
||||
ElMessage.success("节点已更新")
|
||||
}
|
||||
} else {
|
||||
// 新增节点
|
||||
dim.objectives.push({
|
||||
...node,
|
||||
kpis: [],
|
||||
icon: 'target',
|
||||
})
|
||||
ElMessage.success("节点已添加")
|
||||
}
|
||||
|
||||
editingNodeData.value = null
|
||||
debouncedSaveCanvas()
|
||||
nextTick(triggerRecalc)
|
||||
}
|
||||
|
||||
async function deleteNode(dimKey: string, oi: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要删除这个节点吗?", "确认删除")
|
||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||
if (dim) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
|
||||
dim.objectives.splice(oi, 1)
|
||||
ElMessage.success("节点已删除")
|
||||
nextTick(() => { triggerRecalc(); debouncedSaveCanvas() })
|
||||
}
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isSaving = false
|
||||
|
||||
function debouncedSaveCanvas() {
|
||||
if (isSaving) return
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => { saveCanvas() }, 500)
|
||||
}
|
||||
|
||||
async function saveCanvas() {
|
||||
if (!selectedMap.value) return
|
||||
if (isSaving) return
|
||||
isSaving = true
|
||||
const mapData = dimensions.map((d: any) => ({
|
||||
key: d.key, name: d.label || d.name, icon: d.icon, color: d.color,
|
||||
objectives: d.objectives,
|
||||
}))
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
version_num: currentMap.value?.version_num,
|
||||
})
|
||||
ElMessage.success("已保存")
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
ElMessage.warning("地图已被修改,刷新后重试")
|
||||
} else {
|
||||
console.error("saveCanvas error:", e)
|
||||
}
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConnections() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
canvas_data: { connections: connections.value },
|
||||
})
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── 发布/版本 ──
|
||||
async function publishMap() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, { status: "published" })
|
||||
ElMessage.success("已发布(自动创建版本快照)")
|
||||
currentMap.value.status = "published"
|
||||
loadCanvas()
|
||||
} catch (e) {
|
||||
ElMessage.error("发布失败")
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/versions`)
|
||||
versions.value = r.data || []
|
||||
} catch { versions.value = [] }
|
||||
}
|
||||
|
||||
async function rollback(verId: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
|
||||
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
|
||||
ElMessage.success("回滚成功,刷新页面")
|
||||
window.location.reload()
|
||||
} catch (e: any) {
|
||||
if (e !== "cancel") ElMessage.error("回滚失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载目标状态 ──
|
||||
async function loadObjectiveLevels() {
|
||||
if (!selectedMap.value) return
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
|
||||
for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key]
|
||||
for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key]
|
||||
const dims = r.dimensions || []
|
||||
for (const dim of dims) {
|
||||
const dimKey = dim.key
|
||||
const localDim = dimensions.find((d: any) => d.key === dimKey)
|
||||
if (!localDim) continue
|
||||
for (let i = 0; i < localDim.objectives.length; i++) {
|
||||
const key = `${dimKey}-${i}`
|
||||
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null }
|
||||
objectiveLevels[key] = 'gray'
|
||||
}
|
||||
dim.objectives.forEach((obj: any) => {
|
||||
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name)
|
||||
if (localIdx >= 0) {
|
||||
const key = `${dimKey}-${localIdx}`
|
||||
objectiveLevels[key] = obj.level || 'gray'
|
||||
const kpis = obj.kpis || []
|
||||
const kpiList: any[] = []
|
||||
let totalRatio = 0
|
||||
let count = 0
|
||||
for (const k of kpis) {
|
||||
kpiList.push({
|
||||
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value, unit: k.unit,
|
||||
})
|
||||
if (k.actual_value != null && k.target_value) {
|
||||
totalRatio += k.actual_value / k.target_value; count++
|
||||
}
|
||||
}
|
||||
if (kpiList.length > 0) {
|
||||
kpiProgressMap[key] = {
|
||||
ratio: count > 0 ? Math.round((totalRatio / count) * 100) : 0,
|
||||
level: obj.level || 'gray', kpiList,
|
||||
planSummary: obj.action_plan_summary || null,
|
||||
}
|
||||
} else if (obj.action_plan_summary?.total > 0) {
|
||||
kpiProgressMap[key] = { ratio: 0, level: obj.level || 'gray', kpiList: [], planSummary: obj.action_plan_summary }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── KPI详情浮层 ──
|
||||
function showKpiDetailModal(dimKey: string, oi: number, obj: any) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
kpiDetailObjName.value = obj.name
|
||||
kpiDetailDimKey.value = dimKey
|
||||
kpiDetailObjIdx.value = oi
|
||||
kpiDetailList.value = kpiProgressMap[key]?.kpiList || []
|
||||
showKpiDetail.value = true
|
||||
}
|
||||
|
||||
function editKpiDetailObj() {
|
||||
showKpiDetail.value = false
|
||||
const dim = dimensions.find((d: any) => d.key === kpiDetailDimKey.value)
|
||||
if (dim && dim.objectives[kpiDetailObjIdx.value]) {
|
||||
openEditDialog(kpiDetailDimKey.value, kpiDetailObjIdx.value, dim.objectives[kpiDetailObjIdx.value])
|
||||
}
|
||||
}
|
||||
|
||||
function getKpiLevel(item: any): string {
|
||||
if (item.actual != null && item.target) {
|
||||
const ratio = item.actual / item.target
|
||||
if (ratio >= 0.9) return 'green'
|
||||
if (ratio >= 0.7) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function fmtKpiVal(val: any): string {
|
||||
if (val == null) return '—'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function badgeIcon(level: string): string {
|
||||
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
|
||||
}
|
||||
|
||||
// ── 行动方案 ──
|
||||
async function showPlanList(dimKey: string, oi: number, obj: any) {
|
||||
planPopupObjName.value = obj.name
|
||||
planPopupDimKey.value = dimKey
|
||||
planPopupObjIdx.value = oi
|
||||
planPopupList.value = []
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
|
||||
const dims = r.dimensions || []
|
||||
const allPlans = r.action_plans || []
|
||||
for (const dim of dims) {
|
||||
if (dim.key !== dimKey) continue
|
||||
for (const robj of dim.objectives || []) {
|
||||
if (robj.name !== obj.name) continue
|
||||
const objKpiIds = (robj.kpis || []).map((k: any) => k.kpi_id)
|
||||
planPopupList.value = allPlans.filter((p: any) => objKpiIds.includes(p.kpi_id))
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
showPlanPopup.value = true
|
||||
}
|
||||
|
||||
function planStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function quickCreatePlan() {
|
||||
showPlanPopup.value = false
|
||||
window.location.href = `/action-plans?dimension=${planPopupDimKey.value}`
|
||||
}
|
||||
|
||||
function goAlignment() {
|
||||
const url = currentMap.value ? `/alignment?map_id=${currentMap.value.id}` : '/alignment'
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await mapApi.list()
|
||||
maps.value = r.data || []
|
||||
if (maps.value.length > 0) {
|
||||
selectedMap.value = maps.value[0].id
|
||||
loadCanvas()
|
||||
} else {
|
||||
ensureFourLayers()
|
||||
loading.value = false
|
||||
}
|
||||
const k: any = await kpiApi.list({ page_size: 100 })
|
||||
allKpis.value = k.data || []
|
||||
for (const kpi of allKpis.value) {
|
||||
if (kpi.kpi_code) kpiNameMap[kpi.kpi_code] = kpi.kpi_name || kpi.kpi_code
|
||||
}
|
||||
window.addEventListener('resize', triggerRecalc)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', triggerRecalc)
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── 页面布局 ── */
|
||||
.map-canvas-page { padding: 16px; height: 100vh; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
|
||||
.toolbar h3 { margin: 0; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.zoom-controls { display: flex; align-items: center; gap: 2px; margin-right: 8px; }
|
||||
.zoom-controls .el-button { padding: 4px 8px !important; font-size: 13px; min-height: auto; }
|
||||
.zoom-label { display: inline-block; min-width: 32px; text-align: center; font-size: 12px; color: #666; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.linking-bar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
|
||||
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ── 画布滚动容器 ── */
|
||||
.canvas-scroll-wrap {
|
||||
flex: 1; min-height: 0; overflow: auto;
|
||||
position: relative; width: 100%;
|
||||
}
|
||||
|
||||
.canvas-body {
|
||||
display: flex; flex-direction: column; gap: 0;
|
||||
position: relative; isolation: isolate;
|
||||
width: 100%; padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ── 泳道外层包装 ── */
|
||||
.layer-swimlane-wrapper {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── 跨层箭头指示器 ── */
|
||||
.cross-layer-arrow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 重新调用的样式(从旧MapCanvas复用) ── */
|
||||
.kpi-detail-card {
|
||||
border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px;
|
||||
}
|
||||
.kpi-detail-red { border-left: 4px solid #f56c6c; }
|
||||
.kpi-detail-yellow { border-left: 4px solid #e6a23c; }
|
||||
.kpi-detail-green { border-left: 4px solid #67c23a; }
|
||||
.kpi-detail-gray { border-left: 4px solid #ccc; }
|
||||
.kpi-detail-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.kpi-detail-badge { font-size: 14px; }
|
||||
.kpi-detail-code { font-size: 11px; color: #999; font-family: monospace; }
|
||||
.kpi-detail-name { font-weight: 600; font-size: 14px; }
|
||||
.kpi-detail-values { padding-left: 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.kdv-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.kdv-label { color: #999; width: 56px; }
|
||||
.kdv-val { font-weight: 500; }
|
||||
|
||||
.plan-popup-card {
|
||||
border: 1px solid #eee; border-radius: 8px; padding: 10px 12px; margin-bottom: 6px;
|
||||
}
|
||||
.plan-popup-card:hover { background: #fafafa; }
|
||||
.plan-popup-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.plan-popup-status {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
|
||||
}
|
||||
.pps-pending { background: #f5f7fa; color: #909399; }
|
||||
.pps-in_progress { background: #fdf6ec; color: #e6a23c; }
|
||||
.pps-completed { background: #f0f9eb; color: #67c23a; }
|
||||
.pps-cancelled { background: #f5f7fa; color: #bbb; }
|
||||
.plan-popup-title { font-weight: 500; font-size: 13px; }
|
||||
.plan-popup-meta { display: flex; gap: 12px; font-size: 11px; color: #999; padding-left: 4px; }
|
||||
|
||||
/* ── 自定义弹窗(从旧MapCanvas复用) ── */
|
||||
.mc-dialog-overlay {
|
||||
position: fixed; top:0; left:0; right:0; bottom:0;
|
||||
background:rgba(0,0,0,0.5); z-index:9999;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.mc-dialog-box {
|
||||
background:#fff; border-radius:12px; width:520px;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.18); overflow:hidden;
|
||||
}
|
||||
.mc-dialog-header {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
padding:16px 20px; border-bottom:1px solid #f0f0f0;
|
||||
font-weight:600; font-size:16px;
|
||||
}
|
||||
.mc-dialog-close {
|
||||
background:none; border:none; font-size:22px; color:#999;
|
||||
cursor:pointer; padding:0 4px; line-height:1;
|
||||
}
|
||||
.mc-dialog-close:hover { color:#333; }
|
||||
.mc-dialog-body { padding:20px; }
|
||||
.mc-dialog-footer {
|
||||
display:flex; justify-content:flex-end; gap:10px;
|
||||
padding:12px 20px; border-top:1px solid #f0f0f0;
|
||||
}
|
||||
.mc-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.mc-table th, .mc-table td { padding:10px 12px; text-align:left; border-bottom:1px solid #f0f0f0; }
|
||||
.mc-table th { background:#fafafa; font-weight:600; color:#666; font-size:12px; }
|
||||
.mc-table tr:hover td { background:#f5f7fa; }
|
||||
.mc-btn {
|
||||
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
|
||||
background:#fff; color:#333; font-size:14px; cursor:pointer;
|
||||
}
|
||||
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
|
||||
.mc-btn-sm { padding:5px 12px; font-size:12px; }
|
||||
.mc-btn-warn { color:#e6a23c; border-color:#e6a23c; }
|
||||
.mc-btn-warn:hover { background:#e6a23c; color:#fff; }
|
||||
.mc-btn:hover { opacity:0.85; }
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="review-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="review-header">
|
||||
<div class="header-left">
|
||||
<el-button text @click="$router.push('/maps')">← 返回战略地图</el-button>
|
||||
<el-button text type="primary" @click="goCanvas" v-if="selectedMap">🗺️ 打开画布</el-button>
|
||||
<h3>{{ mapTitle }} · 战略回顾</h3>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadReview">
|
||||
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<el-radio-group v-model="period" size="small">
|
||||
<el-radio-button value="month">本月</el-radio-button>
|
||||
<el-radio-button value="quarter">本季</el-radio-button>
|
||||
<el-radio-button value="year">本年</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-wrap">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<template v-if="!loading && summary">
|
||||
<!-- 总体得分卡片 -->
|
||||
<div class="summary-cards">
|
||||
<div class="s-card overall">
|
||||
<div class="s-card-top">
|
||||
<span class="score">{{ summary.health_score }}%</span>
|
||||
<span class="score-label">整体健康度</span>
|
||||
</div>
|
||||
<div class="s-card-btm">
|
||||
<span class="stat-item green">🟢 {{ summary.green }}个达成</span>
|
||||
<span class="stat-item yellow">🟡 {{ summary.yellow }}个预警</span>
|
||||
<span class="stat-item red">🔴 {{ summary.red }}个异常</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 四维度矩阵 -->
|
||||
<div class="dim-matrix">
|
||||
<div v-for="dim in dimensions" :key="dim.key" class="dim-col" :style="{ borderLeft: '4px solid ' + dim.color }">
|
||||
<div class="dim-head">
|
||||
<span class="dim-icon">{{ dim.icon }}</span>
|
||||
<span class="dim-name">{{ dim.name }}</span>
|
||||
</div>
|
||||
<div class="dim-body">
|
||||
<div v-for="obj in dim.objectives" :key="obj.name"
|
||||
class="obj-row"
|
||||
:class="'obj-' + obj.level"
|
||||
@click="showObjKpis(obj)">
|
||||
<span class="obj-badge">{{ badgeIcon(obj.level) }}</span>
|
||||
<div class="obj-info">
|
||||
<span class="obj-name">{{ obj.name }}</span>
|
||||
<span v-if="obj.has_data" class="obj-ratio">
|
||||
<template v-for="k in obj.kpis.slice(0,2)" :key="k.kpi_code">
|
||||
{{ fmtValue(k.actual_value, k.kpi_code) }}/{{ k.target_value ?? '-' }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🔴 需重点关注 -->
|
||||
<div v-if="focusItems.length > 0" class="focus-section">
|
||||
<h4 class="focus-title">🔴 需重点关注</h4>
|
||||
<div v-for="item in focusItems" :key="item.name" class="focus-card" :class="'focus-' + item.level" @click="showObjKpis(item)">
|
||||
<div class="focus-head">
|
||||
<span class="focus-level">{{ item.level === 'red' ? '紧急' : '预警' }}</span>
|
||||
<span class="focus-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="focus-kpis">
|
||||
<div v-for="k in item.kpis" :key="k.kpi_code" class="fkpi-row">
|
||||
<span class="fkpi-name">{{ k.kpi_name }}</span>
|
||||
<span class="fkpi-val" :style="{ color: k.level === 'red' ? '#f56c6c' : '#e6a23c' }">
|
||||
{{ fmtValue(k.actual_value, k.kpi_code) }} / {{ k.target_value ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 关联的改善行动 -->
|
||||
<div v-if="getPlansByKpi(item.kpis)" class="focus-plans">
|
||||
<div v-for="p in getPlansByKpi(item.kpis)" :key="p.id" class="fplan-row" :class="'plan-' + planStatus(p)">
|
||||
<span class="fplan-status">{{ planIcon(p) }}</span>
|
||||
<span class="fplan-title">{{ p.title }}</span>
|
||||
<span class="fplan-assignee">{{ p.assignee }}</span>
|
||||
<span v-if="p.due_date && p.status !== 'completed'" class="fplan-due">{{ dueText(p) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📋 改善行动进度 -->
|
||||
<div v-if="actionPlans.length > 0" class="plans-section">
|
||||
<h4 class="plans-title">📋 改善行动进度</h4>
|
||||
<div class="plans-stats">
|
||||
<el-tag>{{ pendingCount }} 待处理</el-tag>
|
||||
<el-tag type="warning">{{ inProgressCount }} 进行中</el-tag>
|
||||
<el-tag type="success">{{ doneCount }} 已完成</el-tag>
|
||||
<el-tag type="danger">{{ overdueCount }} 逾期</el-tag>
|
||||
</div>
|
||||
<el-table :data="actionPlans" size="small" style="width:100%" @row-click="(r:any)=>$router.push('/action-plans?kpi='+r.kpi_id)">
|
||||
<el-table-column prop="title" label="行动标题" min-width="200" />
|
||||
<el-table-column prop="assignee" label="负责人" width="120" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="planTagType(row)" size="small">{{ planLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress || 0" :status="row.status==='completed'?'success':undefined" :stroke-width="12" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截止" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: isOverdue(row) ? '#f56c6c' : '#666' }">{{ row.due_date ? row.due_date.slice(0,10) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- KPI详情弹出 -->
|
||||
<el-dialog v-model="showKpiDialog" :title="selectedObjName" width="600">
|
||||
<div v-for="k in selectedKpis" :key="k.kpi_code" class="kpi-detail-row" :class="'kpi-' + k.level">
|
||||
<div class="kpi-detail-head">
|
||||
<span class="kpi-detail-badge">{{ badgeIcon(k.level) }}</span>
|
||||
<span class="kpi-detail-name">{{ k.kpi_name }} ({{ k.kpi_code }})</span>
|
||||
</div>
|
||||
<div class="kpi-detail-body">
|
||||
<div class="kpi-detail-item">
|
||||
<span class="label">实际值</span>
|
||||
<span class="val">{{ fmtValue(k.actual_value, k.kpi_code) }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-item">
|
||||
<span class="label">目标值</span>
|
||||
<span class="val">{{ k.target_value ?? '-' }} {{ k.unit }}</span>
|
||||
</div>
|
||||
<div class="kpi-detail-item" v-if="k.actual_value != null && k.target_value">
|
||||
<span class="label">达成率</span>
|
||||
<span class="val" :style="{ color: k.level === 'red' ? '#f56c6c' : 'inherit' }">
|
||||
{{ (k.actual_value / k.target_value * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<el-button size="small" @click="$router.push('/kpis/'+k.kpi_id)" style="margin-top:8px;">查看KPI详情 →</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { ElMessage } from "element-plus"
|
||||
import api from "../api/index"
|
||||
|
||||
const route = useRoute()
|
||||
const $router = useRouter()
|
||||
|
||||
// ── 状态 ──
|
||||
const maps = ref<any[]>([])
|
||||
const selectedMap = ref<number | null>(null)
|
||||
const mapTitle = ref("")
|
||||
const loading = ref(false)
|
||||
const period = ref("month")
|
||||
const summary = ref<any>(null)
|
||||
const dimensions = ref<any[]>([])
|
||||
const focusItems = ref<any[]>([])
|
||||
const actionPlans = ref<any[]>([])
|
||||
const showKpiDialog = ref(false)
|
||||
const selectedObjName = ref("")
|
||||
const selectedKpis = ref<any[]>([])
|
||||
|
||||
// ── 计算属性 ──
|
||||
const pendingCount = () => actionPlans.value.filter((p:any) => p.status === 'pending').length
|
||||
const inProgressCount = () => actionPlans.value.filter((p:any) => p.status === 'in_progress').length
|
||||
const doneCount = () => actionPlans.value.filter((p:any) => p.status === 'completed').length
|
||||
const overdueCount = () => actionPlans.value.filter((p:any) => p.status !== 'completed' && isOverdue(p)).length
|
||||
|
||||
// ── 加载 ──
|
||||
async function loadReview() {
|
||||
if (!selectedMap.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
|
||||
mapTitle.value = r.title
|
||||
summary.value = r.summary
|
||||
dimensions.value = r.dimensions || []
|
||||
focusItems.value = r.focus_items || []
|
||||
actionPlans.value = r.action_plans || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error("加载战略回顾数据失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具函数 ──
|
||||
function badgeIcon(level: string) {
|
||||
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
|
||||
}
|
||||
|
||||
function fmtValue(val: any, code: string) {
|
||||
if (val == null) return '-'
|
||||
if (typeof val === 'number') {
|
||||
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
function showObjKpis(obj: any) {
|
||||
selectedObjName.value = obj.name
|
||||
selectedKpis.value = obj.kpis || []
|
||||
showKpiDialog.value = true
|
||||
}
|
||||
|
||||
function getPlansByKpi(kpis: any[]) {
|
||||
if (!kpis || !actionPlans.value.length) return null
|
||||
const kpiIds = kpis.map((k:any) => k.kpi_id)
|
||||
return actionPlans.value.filter((p:any) => kpiIds.includes(p.kpi_id))
|
||||
}
|
||||
|
||||
function planStatus(p: any) {
|
||||
if (p.status === 'completed') return 'done'
|
||||
if (isOverdue(p)) return 'overdue'
|
||||
if (p.status === 'in_progress') return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function planIcon(p: any) {
|
||||
if (p.status === 'completed') return '✅'
|
||||
if (isOverdue(p)) return '🔴'
|
||||
if (p.status === 'in_progress') return '🔄'
|
||||
return '⏳'
|
||||
}
|
||||
|
||||
function planLabel(p: any) {
|
||||
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
|
||||
return labels[p.status] || p.status
|
||||
}
|
||||
|
||||
function planTagType(p: any) {
|
||||
if (p.status === 'completed') return 'success'
|
||||
if (isOverdue(p)) return 'danger'
|
||||
if (p.status === 'in_progress') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function isOverdue(p: any) {
|
||||
if (p.status === 'completed' || !p.due_date) return false
|
||||
return new Date(p.due_date) < new Date()
|
||||
}
|
||||
|
||||
function dueText(p: any) {
|
||||
if (!p.due_date) return ''
|
||||
const due = new Date(p.due_date)
|
||||
const now = new Date()
|
||||
const diff = Math.ceil((due.getTime() - now.getTime()) / 86400000)
|
||||
if (diff < 0) return `逾期${Math.abs(diff)}天`
|
||||
if (diff === 0) return '今日截止'
|
||||
return `剩余${diff}天`
|
||||
}
|
||||
|
||||
function goCanvas() {
|
||||
if (selectedMap.value) {
|
||||
$router.push('/maps/canvas/' + selectedMap.value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await api.get('/maps')
|
||||
maps.value = r.data || []
|
||||
const id = route.params.id ? Number(route.params.id) : (maps.value[0]?.id || null)
|
||||
if (id) {
|
||||
selectedMap.value = id
|
||||
await loadReview()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-shrink: 0; }
|
||||
.review-header .header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.review-header h3 { margin: 0; font-size: 18px; }
|
||||
.review-header .header-right { display: flex; align-items: center; }
|
||||
.loading-wrap { padding: 40px; }
|
||||
.summary-cards { display: flex; gap: 16px; margin-bottom: 20px; }
|
||||
.s-card { padding: 20px; border-radius: 12px; flex: 1; }
|
||||
.s-card.overall { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
||||
.s-card-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
|
||||
.score { font-size: 36px; font-weight: 700; }
|
||||
.score-label { font-size: 14px; opacity: 0.8; }
|
||||
.s-card-btm { display: flex; gap: 16px; font-size: 13px; }
|
||||
.stat-item { opacity: 0.9; }
|
||||
.dim-matrix { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
|
||||
.dim-col { background: #fff; border-radius: 8px; padding: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.dim-head { font-size: 14px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.dim-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.obj-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; cursor: pointer; transition: background 0.2s; font-size: 13px; }
|
||||
.obj-row:hover { background: #f5f7fa; }
|
||||
.obj-red { background: #fef0f0; }
|
||||
.obj-yellow { background: #fdf6ec; }
|
||||
.obj-green { background: #f0f9eb; }
|
||||
.obj-gray { background: #fafafa; }
|
||||
.obj-badge { font-size: 12px; }
|
||||
.obj-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.obj-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.obj-ratio { font-size: 11px; color: #999; }
|
||||
.focus-section { margin-bottom: 24px; }
|
||||
.focus-title { font-size: 16px; margin: 0 0 12px 0; }
|
||||
.focus-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; }
|
||||
.focus-red { border-left: 4px solid #f56c6c; }
|
||||
.focus-yellow { border-left: 4px solid #e6a23c; }
|
||||
.focus-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.focus-level { font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
|
||||
.focus-red .focus-level { background: #fef0f0; color: #f56c6c; }
|
||||
.focus-yellow .focus-level { background: #fdf6ec; color: #e6a23c; }
|
||||
.focus-name { font-weight: 600; font-size: 14px; }
|
||||
.focus-kpis { padding-left: 20px; margin-bottom: 6px; }
|
||||
.fkpi-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
|
||||
.fkpi-name { color: #666; }
|
||||
.fkpi-val { font-weight: 500; }
|
||||
.focus-plans { padding-left: 20px; margin-top: 4px; border-top: 1px solid #f0f0f0; padding-top: 6px; }
|
||||
.fplan-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; }
|
||||
.fplan-status { flex-shrink: 0; }
|
||||
.fplan-title { flex: 1; }
|
||||
.fplan-assignee { color: #999; }
|
||||
.fplan-due { color: #f56c6c; font-size: 11px; }
|
||||
.plans-section { margin-bottom: 24px; }
|
||||
.plans-title { font-size: 16px; margin: 0 0 12px 0; }
|
||||
.plans-stats { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.kpi-detail-row { border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
|
||||
.kpi-red { border-left: 4px solid #f56c6c; }
|
||||
.kpi-yellow { border-left: 4px solid #e6a23c; }
|
||||
.kpi-green { border-left: 4px solid #67c23a; }
|
||||
.kpi-gray { border-left: 4px solid #ccc; }
|
||||
.kpi-detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.kpi-detail-name { font-weight: 600; font-size: 14px; }
|
||||
.kpi-detail-body { padding-left: 24px; }
|
||||
.kpi-detail-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
|
||||
.kpi-detail-item .label { color: #999; width: 60px; }
|
||||
.kpi-detail-item .val { font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预测模拟</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<!-- Tab 1: CVP本量利分析 -->
|
||||
<el-tab-pane label="本量利分析" name="cvp">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="cvpForm" label-width="140px">
|
||||
<el-form-item label="产品单价"><el-input-number v-model="cvpForm.unit_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="单位变动成本"><el-input-number v-model="cvpForm.unit_variable_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="固定成本"><el-input-number v-model="cvpForm.fixed_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="实际销量(可选)"><el-input-number v-model="cvpForm.actual_volume" :min="0" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="目标利润(可选)"><el-input-number v-model="cvpForm.target_profit" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doCvp" :loading="cvpLoading">计算</el-button>
|
||||
<el-button @click="cvpForm = { unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 }">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="cvpResult.bep_units">
|
||||
<template #header>分析结果</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销量</div><div class="r-value">{{ cvpResult.bep_units }} 件</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销售额</div><div class="r-value">{{ formatMoney(cvpResult.bep_revenue) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">边际贡献率</div><div class="r-value blue">{{ cvpResult.contribution_ratio }}%</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;">
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_units !== undefined"><div class="result-card"><div class="r-label">安全边际(量)</div><div class="r-value green">{{ cvpResult.safety_margin_units }} 件</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_ratio !== undefined"><div class="result-card"><div class="r-label">安全边际率</div><div class="r-value" :class="safetyColor(cvpResult.safety_margin_ratio)">{{ cvpResult.safety_margin_ratio }}%</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.actual_profit !== undefined"><div class="result-card"><div class="r-label">当前利润</div><div class="r-value orange">{{ formatMoney(cvpResult.actual_profit) }}</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;" v-if="cvpResult.target_units">
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需销量</div><div class="r-value purple">{{ cvpResult.target_units }} 件</div></div></el-col>
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需收入</div><div class="r-value purple">{{ formatMoney(cvpResult.target_revenue) }}</div></div></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!cvpLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 投资决策 -->
|
||||
<el-tab-pane label="投资决策" name="investment">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="invForm" label-width="140px">
|
||||
<el-form-item label="初始投资"><el-input-number v-model="invForm.initial_investment" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="折现率(%)"><el-input-number v-model="invForm.discount_rate" :min="0" :max="100" :precision="1" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="现金流(每年)">
|
||||
<div v-for="(cf, idx) in invForm.cash_flows" :key="idx" style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<span style="line-height:32px;width:80px;">第{{ idx+1 }}年</span>
|
||||
<el-input-number v-model="invForm.cash_flows[idx]" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-button v-if="invForm.cash_flows.length > 1" size="small" type="danger" @click="invForm.cash_flows.splice(idx, 1)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="invForm.cash_flows.push(0)">+ 添加年份</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doInvestment" :loading="invLoading">计算</el-button>
|
||||
<el-button @click="fillInvExample">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="invResult.npv_analysis">
|
||||
<template #header>分析结果</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">净现值 (NPV)</div><div class="r-value" :class="invResult.npv_analysis.npv > 0 ? 'green' : 'red'">{{ formatMoney(invResult.npv_analysis.npv) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">内部收益率 (IRR)</div><div class="r-value blue">{{ invResult.irr_analysis.irr }}%</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">盈利指数 (PI)</div><div class="r-value purple">{{ invResult.npv_analysis.profitability_index }}</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">静态回收期</div><div class="r-value">{{ invResult.irr_analysis.payback_period || '--' }} 年</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">动态回收期</div><div class="r-value">{{ invResult.irr_analysis.discounted_payback_period || '>N期' }} 年</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">可行性</div><div class="r-value" :class="invResult.npv_analysis.is_viable ? 'green' : 'red'">{{ invResult.npv_analysis.is_viable ? '✅ 可行' : '❌ 不可行' }}</div></div></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!invLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 敏感性分析 -->
|
||||
<el-tab-pane label="敏感性分析" name="sensitivity">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="sensForm" label-width="140px">
|
||||
<el-form-item label="基准收入"><el-input-number v-model="sensForm.base_revenue" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="基准成本"><el-input-number v-model="sensForm.base_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="变动步长(%)"><el-input-number v-model="sensForm.step" :min="1" :max="50" style="width:200px;" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doSensitivity" :loading="sensLoading">计算</el-button>
|
||||
<el-button @click="sensForm = { base_revenue: 1000000, base_cost: 700000, step: 5 }">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="sensResult.base_revenue">
|
||||
<template #header>
|
||||
敏感性分析结果 — 基准利润:{{ formatMoney(sensResult.base_profit) }}
|
||||
</template>
|
||||
<el-table :data="sensResult.factors" border stripe size="small" style="width:100%;" max-height="400">
|
||||
<el-table-column prop="change_pct" label="变动%" width="70" align="center">
|
||||
<template #default="{ row }"><span :style="{ color: row.change_pct > 0 ? '#f56c6c' : '#67c23a', fontWeight:600 }">{{ row.change_pct > 0 ? '+' : '' }}{{ row.change_pct }}%</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="revenue_change_profit" label="收入变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.revenue_change_profit) }}</template></el-table-column>
|
||||
<el-table-column prop="revenue_sensitivity" label="收入敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.revenue_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.revenue_sensitivity > 0 ? '+' : '' }}{{ row.revenue_sensitivity }}%</span></template></el-table-column>
|
||||
<el-table-column prop="cost_change_profit" label="成本变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.cost_change_profit) }}</template></el-table-column>
|
||||
<el-table-column prop="cost_sensitivity" label="成本敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.cost_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.cost_sensitivity > 0 ? '+' : '' }}{{ row.cost_sensitivity }}%</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!sensLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 情景模拟 -->
|
||||
<el-tab-pane label="情景模拟" name="scenario">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入三个情景</template>
|
||||
<el-form label-width="120px">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="success" size="small">乐观情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.optimistic.revenue" placeholder="收入" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.optimistic.cost" placeholder="成本" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="warning" size="small">中性情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.base.revenue" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.base.cost" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="danger" size="small">悲观情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.pessimistic.revenue" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.pessimistic.cost" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="doScenario" :loading="scenarioLoading">模拟</el-button>
|
||||
<el-button @click="fillScenarioExample">填充示例</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="scenarioResult.scenarios">
|
||||
<template #header>
|
||||
模拟结果
|
||||
<span style="margin-left:12px;font-size:12px;color:#999;">
|
||||
期望利润:{{ formatMoney(scenarioResult.expected_profit) }}
|
||||
| 标准差:{{ formatMoney(scenarioResult.std_deviation) }}
|
||||
</span>
|
||||
</template>
|
||||
<el-table :data="scenarioResult.scenarios" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="scenario" label="情景" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.scenario === '乐观' ? 'success' : row.scenario === '中性' ? 'warning' : 'danger'" size="small">{{ row.scenario }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="revenue" label="收入" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.revenue) }}</template></el-table-column>
|
||||
<el-table-column prop="cost" label="成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.cost) }}</template></el-table-column>
|
||||
<el-table-column prop="profit" label="利润" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.profit >= 0 ? '#67c23a' : '#f56c6c', fontWeight:600 }">{{ formatMoney(row.profit) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="profit_margin" label="利润率" width="80" align="right"><template #default="{ row }">{{ row.profit_margin }}%</template></el-table-column>
|
||||
<el-table-column prop="deviation_from_base" label="偏差" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.deviation_from_base > 0 ? '#67c23a' : '#f56c6c' }">{{ row.deviation_from_base > 0 ? '+' : '' }}{{ formatMoney(row.deviation_from_base) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviation_pct" label="偏差%" width="80" align="right"><template #default="{ row }">{{ row.deviation_pct }}%</template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!scenarioLoading" description="输入三个情景后点击「模拟」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { predictApi } from '../api/index'
|
||||
|
||||
const activeTab = ref('cvp')
|
||||
|
||||
function formatMoney(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function safetyColor(r: number) { return r > 25 ? 'green' : r > 10 ? 'orange' : 'red' }
|
||||
|
||||
// ── CVP ──
|
||||
const cvpLoading = ref(false)
|
||||
const cvpForm = ref({ unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 })
|
||||
const cvpResult = ref<any>({})
|
||||
|
||||
async function doCvp() {
|
||||
cvpLoading.value = true
|
||||
try {
|
||||
cvpResult.value = await predictApi.cvp(cvpForm.value) as any
|
||||
} catch { ElMessage.error('CVP计算失败') }
|
||||
cvpLoading.value = false
|
||||
}
|
||||
|
||||
// ── 投资决策 ──
|
||||
const invLoading = ref(false)
|
||||
const invForm = ref({ initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] })
|
||||
const invResult = ref<any>({})
|
||||
|
||||
function fillInvExample() {
|
||||
invForm.value = { initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] }
|
||||
}
|
||||
|
||||
async function doInvestment() {
|
||||
invLoading.value = true
|
||||
try {
|
||||
invResult.value = await predictApi.investment(invForm.value) as any
|
||||
} catch { ElMessage.error('投资决策计算失败') }
|
||||
invLoading.value = false
|
||||
}
|
||||
|
||||
// ── 敏感性 ──
|
||||
const sensLoading = ref(false)
|
||||
const sensForm = ref({ base_revenue: 1000000, base_cost: 700000, step: 5 })
|
||||
const sensResult = ref<any>({})
|
||||
|
||||
async function doSensitivity() {
|
||||
sensLoading.value = true
|
||||
try {
|
||||
sensResult.value = await predictApi.sensitivity(sensForm.value) as any
|
||||
} catch { ElMessage.error('敏感性分析失败') }
|
||||
sensLoading.value = false
|
||||
}
|
||||
|
||||
// ── 情景 ──
|
||||
const scenarioLoading = ref(false)
|
||||
const scenarioForm = ref({
|
||||
optimistic: { revenue: 130, cost: 90 },
|
||||
base: { revenue: 100, cost: 100 },
|
||||
pessimistic: { revenue: 80, cost: 110 },
|
||||
})
|
||||
const scenarioResult = ref<any>({})
|
||||
|
||||
function fillScenarioExample() {
|
||||
scenarioForm.value = {
|
||||
optimistic: { revenue: 130, cost: 90 },
|
||||
base: { revenue: 100, cost: 100 },
|
||||
pessimistic: { revenue: 80, cost: 110 },
|
||||
}
|
||||
}
|
||||
|
||||
async function doScenario() {
|
||||
scenarioLoading.value = true
|
||||
try {
|
||||
scenarioResult.value = await predictApi.scenario(scenarioForm.value) as any
|
||||
} catch { ElMessage.error('情景模拟失败') }
|
||||
scenarioLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-card { background: #f5f7fa; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.r-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
|
||||
.r-value { font-size: 22px; font-weight: bold; color: #303133; }
|
||||
.r-value.green { color: #67c23a; }
|
||||
.r-value.blue { color: #409eff; }
|
||||
.r-value.orange { color: #e6a23c; }
|
||||
.r-value.red { color: #f56c6c; }
|
||||
.r-value.purple { color: #8b5cf6; }
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<div class="report-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="report-header">
|
||||
<h3 style="margin:0;">📊 CMA管理报表</h3>
|
||||
<div class="header-right">
|
||||
<el-date-picker
|
||||
v-model="reportPeriod"
|
||||
type="month"
|
||||
placeholder="选择月份"
|
||||
value-format="YYYY-MM"
|
||||
size="small"
|
||||
style="width:140px"
|
||||
@change="onPeriodChange"
|
||||
/>
|
||||
<el-button text size="small" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 四个标签页 -->
|
||||
<el-tabs v-model="activeTab" type="border-card" class="report-tabs">
|
||||
|
||||
<!-- ════ 报表1:管理利润表 ════ -->
|
||||
<el-tab-pane label="💰 管理利润表" name="profit">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">基于管理会计视角的利润结构分析:收入 → 变动成本 → 边际贡献 → 固定成本 → 息税前利润</div>
|
||||
<el-table :data="profitItems" border stripe size="small" style="width:100%;margin-top:12px;" :summary-method="profitSummary" show-summary>
|
||||
<el-table-column prop="name" label="项目" width="200">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: row.is_total ? 700 : row.is_subtotal ? 600 : 400 }">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本期金额" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: row.is_total ? 700 : 400, color: (row.name?.includes('利润') || row.name?.includes('贡献')) && row.value != null && row.value < 0 ? '#f56c6c' : '#333' }">
|
||||
{{ row.value != null ? '¥ ' + row.value.toLocaleString() : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上期金额" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color:#999;">{{ row.prev_value != null ? '¥ ' + row.prev_value.toLocaleString() : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="环比变化" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.change_rate != null" :style="{ color: row.change_rate > 0 ? '#f56c6c' : row.change_rate < 0 ? '#67c23a' : '#999', fontWeight: 600 }">
|
||||
{{ row.change_rate > 0 ? '↑' : '↓' }}{{ Math.abs(row.change_rate).toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else style="color:#ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="占比" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.ratio != null" style="color:#666;">{{ row.ratio.toFixed(1) }}%</span>
|
||||
<span v-else style="color:#ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ════ 报表2:预算执行报告 ════ -->
|
||||
<el-tab-pane label="📋 预算执行报告" name="budget">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">各KPI预算 vs 实际差异分析。正值=超预算,负值=节约。</div>
|
||||
<div class="summary-row" style="margin-top:12px;">
|
||||
<div class="stat-card blue"><div class="stat-val">{{ budgetSummary.total }}</div><div class="stat-label">KPI总数</div></div>
|
||||
<div class="stat-card green"><div class="stat-val">{{ budgetSummary.with_budget }}</div><div class="stat-label">有预算</div></div>
|
||||
<div class="stat-card red"><div class="stat-val">{{ budgetSummary.over_budget }}</div><div class="stat-label">超预算</div></div>
|
||||
<div class="stat-card gray"><div class="stat-val">{{ budgetSummary.under_budget }}</div><div class="stat-label">节约</div></div>
|
||||
<div class="stat-card yellow"><div class="stat-val">{{ budgetSummary.normal }}</div><div class="stat-label">正常</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin:12px 0;">
|
||||
<el-select v-model="budgetDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadBudget">
|
||||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-select v-model="budgetLevel" placeholder="状态" clearable size="small" style="width:120px;" @change="loadBudget">
|
||||
<el-option label="超预算" value="red" />
|
||||
<el-option label="预警" value="yellow" />
|
||||
<el-option label="正常" value="normal" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="budgetItems" border stripe size="small" style="width:100%;" @sort-change="onBudgetSort">
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" sortable="custom" />
|
||||
<el-table-column prop="dimension" label="维度" width="90">
|
||||
<template #default="{ row }">{{ dimLabel(row.dimension) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际值" width="120" align="right" prop="actual_value" sortable="custom">
|
||||
<template #default="{ row }">{{ row.actual_value != null ? row.actual_value.toLocaleString() : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预算值" width="120" align="right" prop="budget_value" sortable="custom">
|
||||
<template #default="{ row }">{{ row.budget_value != null ? row.budget_value.toLocaleString() : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异额" width="120" align="right" prop="deviation_amount" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.deviation_amount > 0 ? '#f56c6c' : row.deviation_amount < 0 ? '#67c23a' : '#999' }">
|
||||
{{ row.deviation_amount != null ? (row.deviation_amount > 0 ? '+' : '') + row.deviation_amount.toLocaleString() : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异率" width="100" align="right" prop="deviation_rate" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.deviation_rate != null" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
|
||||
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(1) }}%
|
||||
</el-tag>
|
||||
<span v-else style="color:#ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ════ 报表3:KPI趋势报告 ════ -->
|
||||
<el-tab-pane label="📈 KPI趋势报告" name="trends">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">选定KPI的历史趋势分析,含目标线标记。</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
|
||||
<el-select v-model="trendDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadTrends">
|
||||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-select v-model="trendKpiId" placeholder="选择KPI" clearable filterable size="small" style="width:200px;" @change="loadTrends">
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="k.kpi_name" :value="k.id" />
|
||||
</el-select>
|
||||
<el-select v-model="trendMonths" size="small" style="width:100px;" @change="loadTrends">
|
||||
<el-option label="12期" :value="12" />
|
||||
<el-option label="24期" :value="24" />
|
||||
<el-option label="30期" :value="30" />
|
||||
</el-select>
|
||||
</div>
|
||||
<!-- 趋势图表 -->
|
||||
<div v-if="trendData.length > 0" class="trend-grid">
|
||||
<el-card v-for="t in trendData" :key="t.kpi_id" class="trend-card-2">
|
||||
<template #header>
|
||||
<div class="trend-hd">
|
||||
<span class="trend-name">{{ t.kpi_name }}</span>
|
||||
<span class="trend-unit">{{ t.unit }}</span>
|
||||
<span class="trend-stat">
|
||||
均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }}
|
||||
</span>
|
||||
<span class="trend-dir" :class="t.trend_dir">
|
||||
{{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div :ref="el => setTrendChartRef(t.kpi_id, el)" style="height:180px;"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-else description="请选择KPI查看趋势" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ════ 报表4:BSC评分卡 ════ -->
|
||||
<el-tab-pane label="🎯 四维度绩效评分卡" name="bsc">
|
||||
<div v-loading="loading">
|
||||
<div class="report-desc">基于BSC框架的四维度绩效评分,从已发布战略地图构建。</div>
|
||||
<div v-if="bscData.overall_score != null" style="margin-top:12px;">
|
||||
<!-- 总分 -->
|
||||
<div class="bsc-overall">
|
||||
<div class="bsc-score-ring" :style="{ borderColor: bscScoreColor(bscData.overall_score) }">
|
||||
<span class="bsc-score-val">{{ bscData.overall_score }}</span>
|
||||
<span class="bsc-score-label">综合评分</span>
|
||||
</div>
|
||||
<div class="bsc-map-info" v-if="bscData.map_title">
|
||||
基于:{{ bscData.map_title }} | 周期:{{ bscData.period }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 四维度 -->
|
||||
<div class="bsc-dims">
|
||||
<div v-for="dim in bscData.dimensions" :key="dim.key" class="bsc-dim-card">
|
||||
<div class="bsc-dim-head" :style="{ borderLeft: '4px solid ' + dim.color }">
|
||||
<span class="bsc-dim-icon">{{ dim.icon }}</span>
|
||||
<span class="bsc-dim-name">{{ dim.name }}</span>
|
||||
<span class="bsc-dim-score" :style="{ color: bscScoreColor(dim.score) }">{{ dim.score }}</span>
|
||||
</div>
|
||||
<div class="bsc-dim-body">
|
||||
<div v-for="obj in dim.objectives" :key="obj.name" class="bsc-obj-row">
|
||||
<div class="bsc-obj-name">{{ obj.name }}</div>
|
||||
<div class="bsc-obj-kpis" v-if="obj.kpis && obj.kpis.length > 0">
|
||||
<div v-for="k in obj.kpis.slice(0, 3)" :key="k.code" class="bsc-kpi-mini">
|
||||
<span class="bsc-kpi-dot" :class="'dot-' + k.level"></span>
|
||||
<span class="bsc-kpi-name">{{ k.name }}</span>
|
||||
<span class="bsc-kpi-score">{{ k.score ?? '-' }}</span>
|
||||
</div>
|
||||
<div v-if="obj.kpis.length > 3" class="bsc-kpi-more">+{{ obj.kpis.length - 3 }} 更多</div>
|
||||
</div>
|
||||
<div v-else class="bsc-obj-empty">暂无KPI数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无已发布的战略地图,请先在战略地图中发布" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
// ── 状态 ──
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('profit')
|
||||
const reportPeriod = ref('2026-06')
|
||||
|
||||
// API 配置
|
||||
const api = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
||||
api.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
if (token) config.headers.Authorization = `'Bearer ' + token`
|
||||
return config
|
||||
})
|
||||
|
||||
// ── 报表1:管理利润表 ──
|
||||
const profitItems = ref<any[]>([])
|
||||
|
||||
function profitSummary(param: any) {
|
||||
const sums: string[] = ['合计']
|
||||
const last = profitItems.value[profitItems.value.length - 1]
|
||||
if (last) {
|
||||
sums.push(last.value != null ? '¥ ' + last.value.toLocaleString() : '-')
|
||||
sums.push(last.prev_value != null ? '¥ ' + last.prev_value.toLocaleString() : '-')
|
||||
sums.push(last.change_rate != null ? (last.change_rate > 0 ? '↑' : '↓') + Math.abs(last.change_rate).toFixed(1) + '%' : '-')
|
||||
sums.push(last.ratio != null ? last.ratio.toFixed(1) + '%' : '-')
|
||||
} else {
|
||||
sums.push('-', '-', '-', '-')
|
||||
}
|
||||
return sums
|
||||
}
|
||||
|
||||
// ── 报表2:预算执行报告 ──
|
||||
const budgetSummary = ref({ total: 0, with_budget: 0, over_budget: 0, under_budget: 0, normal: 0 })
|
||||
const budgetItems = ref<any[]>([])
|
||||
const budgetDim = ref('')
|
||||
const budgetLevel = ref('')
|
||||
|
||||
// ── 报表3:KPI趋势报告 ──
|
||||
const trendDim = ref('')
|
||||
const trendKpiId = ref<number | null>(null)
|
||||
const trendMonths = ref(24)
|
||||
const trendData = ref<any[]>([])
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const trendChartRefs: Record<string, HTMLElement> = {}
|
||||
|
||||
function setTrendChartRef(id: number, el: any) {
|
||||
if (el) trendChartRefs['chart-' + id] = el
|
||||
}
|
||||
|
||||
// ── 报表4:BSC评分卡 ──
|
||||
const bscData = ref<any>({})
|
||||
|
||||
// ── 公共 ──
|
||||
const dimensions = [
|
||||
{ value: 'finance', label: '财务维度' },
|
||||
{ value: 'customer', label: '客户维度' },
|
||||
{ value: 'process', label: '内部流程' },
|
||||
{ value: 'learning', label: '学习成长' },
|
||||
]
|
||||
|
||||
function dimLabel(d: string): string {
|
||||
const map: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||||
return map[d] || d
|
||||
}
|
||||
|
||||
function bscScoreColor(score: number): string {
|
||||
if (score >= 80) return '#67c23a'
|
||||
if (score >= 60) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
function onPeriodChange() {
|
||||
loadAll()
|
||||
}
|
||||
|
||||
// ── 加载 ──
|
||||
async function loadProfit() {
|
||||
try {
|
||||
const r = await api.get('/reports/profit-summary', { params: { period: reportPeriod.value } })
|
||||
profitItems.value = (r as any).data?.items || []
|
||||
} catch { profitItems.value = [] }
|
||||
}
|
||||
|
||||
async function loadBudget() {
|
||||
try {
|
||||
const params: any = { period: reportPeriod.value }
|
||||
if (budgetDim.value) params.dimension = budgetDim.value
|
||||
if (budgetLevel.value) params.alert_level = budgetLevel.value
|
||||
const r = await api.get('/reports/budget-execution', { params })
|
||||
const d = (r as any).data || {}
|
||||
budgetSummary.value = d.summary || {}
|
||||
budgetItems.value = d.items || []
|
||||
} catch { budgetItems.value = [] }
|
||||
}
|
||||
|
||||
async function loadTrends() {
|
||||
try {
|
||||
const params: any = { months: trendMonths.value }
|
||||
if (trendKpiId.value) params.kpi_id = trendKpiId.value
|
||||
else if (trendDim.value) params.dimension = trendDim.value
|
||||
const r = await api.get('/reports/kpi-trends', { params })
|
||||
trendData.value = (r as any).data?.data || []
|
||||
nextTick(() => renderTrendCharts())
|
||||
} catch { trendData.value = [] }
|
||||
}
|
||||
|
||||
async function loadBsc() {
|
||||
try {
|
||||
const r = await api.get('/reports/bsc-scorecard', { params: { period: reportPeriod.value } })
|
||||
bscData.value = (r as any).data || {}
|
||||
} catch { bscData.value = {} }
|
||||
}
|
||||
|
||||
async function loadKpiOptions() {
|
||||
try {
|
||||
const r = await api.get('/kpis', { params: { page_size: 100 } })
|
||||
kpiOptions.value = (r as any).data?.data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
loading.value = true
|
||||
Promise.all([loadProfit(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
|
||||
}
|
||||
|
||||
// ── ECharts 渲染 ──
|
||||
function renderTrendCharts() {
|
||||
import('echarts').then(echarts => {
|
||||
trendData.value.forEach((t: any) => {
|
||||
const el = trendChartRefs['chart-' + t.kpi_id]
|
||||
if (!el) return
|
||||
const existing = echarts.getInstanceByDom(el)
|
||||
if (existing) existing.dispose()
|
||||
const chart = echarts.init(el)
|
||||
const dates = t.trend.map((d: any) => d.period)
|
||||
const values = t.trend.map((d: any) => d.value)
|
||||
const series: any[] = [{
|
||||
type: 'line', data: values, smooth: true, lineStyle: { width: 2 },
|
||||
areaStyle: { opacity: 0.1 }, symbol: 'circle', symbolSize: 4,
|
||||
name: '实际值',
|
||||
}]
|
||||
// 目标线
|
||||
if (t.target_value != null) {
|
||||
series.push({
|
||||
type: 'line', data: Array(dates.length).fill(t.target_value),
|
||||
lineStyle: { type: 'dashed', width: 1, color: '#f56c6c' },
|
||||
symbol: 'none', name: '目标值',
|
||||
})
|
||||
}
|
||||
chart.setOption({
|
||||
grid: { left: 50, right: 15, top: 10, bottom: 28 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 10, rotate: 45 } },
|
||||
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed', color: '#eee' } } },
|
||||
series,
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { show: true, bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── 排序 ──
|
||||
function onBudgetSort(sort: any) {
|
||||
if (!sort.prop || !sort.order) return
|
||||
const items = [...budgetItems.value]
|
||||
items.sort((a: any, b: any) => {
|
||||
const av = a[sort.prop] ?? 0
|
||||
const bv = b[sort.prop] ?? 0
|
||||
return sort.order === 'ascending' ? av - bv : bv - av
|
||||
})
|
||||
budgetItems.value = items
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadKpiOptions()
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-page { max-width: 1400px; margin: 0 auto; }
|
||||
.report-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.header-right { display: flex; gap: 8px; align-items: center; }
|
||||
.report-tabs { margin-bottom: 20px; }
|
||||
.report-desc { font-size: 13px; color: #888; padding: 8px 0; }
|
||||
|
||||
/* 统计卡片 */
|
||||
.summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-top: 3px solid #ccc; }
|
||||
.stat-card.blue { border-top-color: #409eff; }
|
||||
.stat-card.red { border-top-color: #f56c6c; }
|
||||
.stat-card.green { border-top-color: #67c23a; }
|
||||
.stat-card.yellow { border-top-color: #e6a23c; }
|
||||
.stat-card.gray { border-top-color: #909399; }
|
||||
.stat-val { font-size: 22px; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-label { font-size: 12px; color: #888; margin-top: 2px; }
|
||||
|
||||
/* 趋势卡片 */
|
||||
.trend-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-top: 12px; }
|
||||
.trend-card-2 { border-radius: 10px; }
|
||||
.trend-hd { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.trend-name { font-weight: 600; font-size: 14px; flex: 1; }
|
||||
.trend-unit { color: #999; font-size: 12px; }
|
||||
.trend-stat { font-size: 11px; color: #999; }
|
||||
.trend-dir { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
||||
.trend-dir.up { background: #fff1f0; color: #cf1322; }
|
||||
.trend-dir.down { background: #f6ffed; color: #389e0d; }
|
||||
.trend-dir.stable { background: #f5f5f5; color: #999; }
|
||||
|
||||
/* BSC评分卡 */
|
||||
.bsc-overall { text-align: center; padding: 20px 0; }
|
||||
.bsc-score-ring {
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
width: 120px; height: 120px; border-radius: 50%; border: 6px solid #67c23a;
|
||||
background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.bsc-score-val { font-size: 36px; font-weight: 700; color: #1a1a2e; line-height: 1.1; }
|
||||
.bsc-score-label { font-size: 12px; color: #999; margin-top: 2px; }
|
||||
.bsc-map-info { font-size: 12px; color: #999; margin-top: 8px; }
|
||||
.bsc-dims { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||
.bsc-dim-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); overflow: hidden; }
|
||||
.bsc-dim-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; background: #fafafa; }
|
||||
.bsc-dim-icon { font-size: 18px; }
|
||||
.bsc-dim-name { font-weight: 600; font-size: 14px; flex: 1; }
|
||||
.bsc-dim-score { font-size: 24px; font-weight: 700; }
|
||||
.bsc-dim-body { padding: 10px 14px; }
|
||||
.bsc-obj-row { margin-bottom: 10px; }
|
||||
.bsc-obj-name { font-size: 13px; font-weight: 500; color: #555; margin-bottom: 4px; }
|
||||
.bsc-obj-kpis { display: flex; flex-direction: column; gap: 3px; }
|
||||
.bsc-kpi-mini { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 6px; background: #f9f9f9; border-radius: 4px; }
|
||||
.bsc-kpi-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-green { background: #67c23a; }
|
||||
.dot-yellow { background: #e6a23c; }
|
||||
.dot-red { background: #f56c6c; }
|
||||
.dot-gray { background: #ddd; }
|
||||
.bsc-kpi-name { flex: 1; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bsc-kpi-score { font-weight: 600; color: #333; }
|
||||
.bsc-kpi-more { font-size: 11px; color: #409eff; text-align: center; padding: 2px; }
|
||||
.bsc-obj-empty { font-size: 11px; color: #ccc; text-align: center; padding: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="slider-captcha">
|
||||
<!-- 触发按钮(未验证时显示) -->
|
||||
<el-button
|
||||
v-if="!verified"
|
||||
type="warning"
|
||||
size="large"
|
||||
plain
|
||||
style="width:100%; margin-bottom:12px"
|
||||
:loading="loading"
|
||||
@click="openDialog"
|
||||
>
|
||||
⚠ 请完成安全验证
|
||||
</el-button>
|
||||
|
||||
<!-- 已验证 -->
|
||||
<div v-else class="verified-badge">
|
||||
✅ 安全验证通过
|
||||
</div>
|
||||
|
||||
<!-- 验证对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="安全验证"
|
||||
width="380px"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
top="20vh"
|
||||
>
|
||||
<div class="captcha-body">
|
||||
<!-- 图形验证码模式 -->
|
||||
<template v-if="captchaType === 'image'">
|
||||
<div class="image-captcha-wrapper">
|
||||
<img :src="captchaImage" class="captcha-img" alt="验证码" />
|
||||
<el-input
|
||||
v-model="imageAnswer"
|
||||
placeholder="输入验证码"
|
||||
size="large"
|
||||
maxlength="4"
|
||||
style="width:120px"
|
||||
/>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||||
<el-button type="primary" size="small" @click="verifyImageCaptcha">确认</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 滑块拼图模式 -->
|
||||
<template v-else>
|
||||
<div class="slider-wrapper">
|
||||
<div class="slider-bg">
|
||||
<img :src="sliderBg" alt="滑块背景" />
|
||||
<div
|
||||
class="slider-gap-marker"
|
||||
:style="{ left: sliderGapX + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="slider-piece-track">
|
||||
<div
|
||||
class="slider-piece"
|
||||
:style="{ left: sliderPos + 'px', background: dragging ? '#409eff' : '#67c23a' }"
|
||||
></div>
|
||||
<div
|
||||
class="slider-rail"
|
||||
ref="railRef"
|
||||
@mousedown.prevent="startDrag"
|
||||
@touchstart.prevent="startDrag"
|
||||
>
|
||||
<div class="slider-thumb" :style="{ left: sliderPos + 'px' }">
|
||||
{{ dragging ? '→' : '→' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="slider-hint">{{ dragging ? '松开完成验证' : '拖动滑块完成拼图' }}</p>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { securityApi } from '../api/index'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'verified', token: string): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const verified = ref(false)
|
||||
const captchaToken = ref('')
|
||||
const captchaType = ref<'image' | 'slider'>('slider')
|
||||
|
||||
// 图形验证码
|
||||
const captchaImage = ref('')
|
||||
const imageAnswer = ref('')
|
||||
const captchaId = ref('')
|
||||
|
||||
// 滑块拼图
|
||||
const sliderBg = ref('')
|
||||
const sliderGapX = ref(0)
|
||||
const sliderPos = ref(0)
|
||||
const dragging = ref(false)
|
||||
const railRef = ref<HTMLElement | null>(null)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartPos = ref(0)
|
||||
|
||||
// 容差像素
|
||||
const TOLERANCE = 6
|
||||
|
||||
async function fetchCaptcha() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.requestCaptcha(captchaType.value)
|
||||
captchaId.value = res.captcha_id
|
||||
if (captchaType.value === 'image') {
|
||||
captchaImage.value = res.image
|
||||
imageAnswer.value = ''
|
||||
} else {
|
||||
sliderBg.value = res.bg
|
||||
sliderGapX.value = res.gap_x
|
||||
sliderPos.value = 0
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取验证码失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function openDialog() {
|
||||
captchaType.value = Math.random() > 0.3 ? 'slider' : 'image'
|
||||
dialogVisible.value = true
|
||||
await fetchCaptcha()
|
||||
}
|
||||
|
||||
async function verifyImageCaptcha() {
|
||||
if (!imageAnswer.value || imageAnswer.value.length !== 4) {
|
||||
ElMessage.warning('请输入4位验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.verifyCaptcha({
|
||||
captcha_id: captchaId.value,
|
||||
answer: imageAnswer.value.toUpperCase(),
|
||||
captcha_type: 'image',
|
||||
})
|
||||
captchaToken.value = res.token
|
||||
verified.value = true
|
||||
dialogVisible.value = false
|
||||
emit('verified', res.token)
|
||||
ElMessage.success('验证通过')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||||
await fetchCaptcha()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent | TouchEvent) {
|
||||
dragging.value = true
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
dragStartX.value = clientX
|
||||
dragStartPos.value = sliderPos.value
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', endDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', endDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent | TouchEvent) {
|
||||
if (!dragging.value) return
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const rail = railRef.value
|
||||
if (!rail) return
|
||||
const railRect = rail.getBoundingClientRect()
|
||||
const max = railRect.width - 40
|
||||
let newPos = dragStartPos.value + (clientX - dragStartX.value)
|
||||
newPos = Math.max(0, Math.min(newPos, max))
|
||||
sliderPos.value = newPos
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!dragging.value) return
|
||||
dragging.value = false
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', endDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', endDrag)
|
||||
|
||||
// 验证滑块位置是否匹配缺口位置
|
||||
const rail = railRef.value
|
||||
if (!rail) return
|
||||
const railWidth = rail.getBoundingClientRect().width - 40
|
||||
const piecePos = (sliderPos.value / railWidth) * 280 // 映射到280px背景图宽度
|
||||
const gapX = sliderGapX.value
|
||||
if (Math.abs(piecePos - gapX) <= TOLERANCE) {
|
||||
// 验证通过 → 调用后端确认
|
||||
confirmSlider()
|
||||
} else {
|
||||
ElMessage.warning('位置不匹配,请再试一次')
|
||||
sliderPos.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSlider() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.verifyCaptcha({
|
||||
captcha_id: captchaId.value,
|
||||
answer: 'verified',
|
||||
captcha_type: 'slider',
|
||||
})
|
||||
captchaToken.value = res.token
|
||||
verified.value = true
|
||||
dialogVisible.value = false
|
||||
emit('verified', res.token)
|
||||
ElMessage.success('验证通过')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||||
sliderPos.value = 0
|
||||
await fetchCaptcha()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 对外暴露重置方法
|
||||
function reset() {
|
||||
verified.value = false
|
||||
captchaToken.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-body {
|
||||
text-align: center;
|
||||
}
|
||||
.image-captcha-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.captcha-img {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
height: 50px;
|
||||
}
|
||||
.captcha-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.slider-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.slider-bg {
|
||||
position: relative;
|
||||
margin: 0 auto 12px;
|
||||
width: 280px;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.slider-bg img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slider-gap-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid rgba(255, 0, 0, 0.5);
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.slider-piece-track {
|
||||
position: relative;
|
||||
margin: 0 auto 8px;
|
||||
width: 280px;
|
||||
}
|
||||
.slider-piece {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
z-index: 2;
|
||||
transition: none;
|
||||
}
|
||||
.slider-rail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
background: #ebeef5;
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||
}
|
||||
.slider-thumb:active { cursor: grabbing; }
|
||||
.slider-hint { color: #909399; font-size: 12px; margin: 0; }
|
||||
.verified-badge {
|
||||
color: #67c23a;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user