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
+134
View File
@@ -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>