540 lines
21 KiB
Vue
540 lines
21 KiB
Vue
<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 = []
|
||
const eid = Number(localStorage.getItem('cma_entity_id') || 1)
|
||
api.get('/kpis', { params: { dimension: props.dimension, page_size: 100, entity_id: eid } })
|
||
.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>
|