feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+538
View File
@@ -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>