Files
cma-management/frontend/src/components/KPIListView.vue
T

539 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>