feat: dashboard & report APIs with real DB stats

Backend:
- Add /dashboard/stats API (data_sources, tables, columns, labeled, sensitive, projects)
- Add /dashboard/distribution API (level/cat/source distribution, project progress, heatmap)
- Add /reports/stats API (total/auto/manual/reviewed counts + level distribution)
- Fix report download: add template relationship to ClassificationProject
- All stats computed from real DB queries

Frontend:
- Dashboard.vue: replace all hardcoded data with API-driven computed charts
- Report.vue: replace all hardcoded data with API-driven charts
- Add dashboard.ts and report.ts API clients
This commit is contained in:
hiderfong
2026-04-23 11:10:16 +08:00
parent 86c487ae40
commit 3b50ccc7e1
8 changed files with 396 additions and 124 deletions
+57
View File
@@ -0,0 +1,57 @@
import request from './request'
export interface DashboardStats {
data_sources: number
tables: number
columns: number
labeled: number
sensitive: number
projects: number
}
export interface LevelDistItem {
name: string
code: string
color: string
count: number
}
export interface CategoryDistItem {
name: string
count: number
}
export interface SourceDistItem {
source: string
count: number
}
export interface ProjectProgressItem {
id: number
name: string
status: string
progress: number
planned_end: string | null
}
export interface HeatmapItem {
source_name: string
level_code: string
count: number
}
export interface DashboardDistribution {
level_distribution: LevelDistItem[]
category_distribution: CategoryDistItem[]
source_distribution: SourceDistItem[]
project_progress: ProjectProgressItem[]
heatmap: HeatmapItem[]
}
export function getDashboardStats() {
return request.get('/dashboard/stats')
}
export function getDashboardDistribution() {
return request.get('/dashboard/distribution')
}
+31
View File
@@ -0,0 +1,31 @@
import request from './request'
export interface ReportStats {
total: number
auto: number
manual: number
reviewed: number
level_distribution: {
name: string
code: string
count: number
}[]
}
export function getReportStats() {
return request.get('/reports/stats')
}
export function downloadReport(projectId: number) {
const token = localStorage.getItem('pdg_token')
const url = `/api/v1/reports/projects/${projectId}/download`
const a = document.createElement('a')
a.href = url
a.download = `report_project_${projectId}.docx`
if (token) {
a.setAttribute('data-token', token)
}
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
+68 -50
View File
@@ -9,7 +9,7 @@
<el-icon size="28"><DataLine /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.dataSources }}</div>
<div class="stat-value">{{ stats.data_sources }}</div>
<div class="stat-label">数据源</div>
</div>
</div>
@@ -98,27 +98,35 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart, HeatmapChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent, VisualMapComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getProjects } from '@/api/project'
import { DataLine, FolderOpened, DocumentChecked, Warning } from '@element-plus/icons-vue'
import { getDashboardStats, getDashboardDistribution } from '@/api/dashboard'
use([CanvasRenderer, PieChart, BarChart, HeatmapChart, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent])
const stats = reactive({
dataSources: 12,
tables: 3847,
labeled: 152340,
sensitive: 28931,
data_sources: 0,
tables: 0,
columns: 0,
labeled: 0,
sensitive: 0,
projects: 0,
})
const levelOption = ref({
const levelDist = ref<any[]>([])
const categoryDist = ref<any[]>([])
const projectList = ref<any[]>([])
const heatmapData = ref<any[]>([])
const levelOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'],
color: levelDist.value.map((item: any) => item.color || '#999'),
series: [
{
type: 'pie',
@@ -126,53 +134,55 @@ const levelOption = ref({
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: [
{ value: 35000, name: 'L1 公开级' },
{ value: 62000, name: 'L2 内部级' },
{ value: 48000, name: 'L3 敏感级' },
{ value: 22000, name: 'L4 重要级' },
{ value: 6931, name: 'L5 核心级' },
],
data: levelDist.value.map((item: any) => ({
value: item.count,
name: `${item.code} ${item.name}`,
})),
},
],
})
}))
const categoryOption = ref({
const categoryOption = computed(() => ({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: ['车辆信息', '理赔数据', '渠道数据', '财务数据', '监管报送', '内部管理', '保单数据', '客户数据'],
data: categoryDist.value.map((item: any) => item.name).reverse(),
},
series: [
{
type: 'bar',
data: [8200, 15600, 4300, 12100, 2800, 5600, 22400, 36700],
data: categoryDist.value.map((item: any) => item.count).reverse(),
itemStyle: { borderRadius: [0, 4, 4, 0], color: '#409eff' },
},
],
})
}))
const heatmapOption = ref({
tooltip: { position: 'top' },
grid: { height: '50%', top: '10%' },
xAxis: { type: 'category', data: ['L1', 'L2', 'L3', 'L4', 'L5'], splitArea: { show: true } },
yAxis: { type: 'category', data: ['核心系统', '理赔系统', '保单系统', '财务系统', '渠道系统'], splitArea: { show: true } },
visualMap: { min: 0, max: 10000, calculable: true, orient: 'horizontal', left: 'center', bottom: '15%', inRange: { color: ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] } },
series: [{
type: 'heatmap',
data: [[0,0,500],[0,1,1200],[0,2,800],[0,3,600],[0,4,400],[1,0,2000],[1,1,3500],[1,2,2800],[1,3,2200],[1,4,1800],[2,0,1500],[2,1,4200],[2,2,3100],[2,3,2600],[2,4,1400],[3,0,800],[3,1,5800],[3,2,2100],[3,3,1900],[3,4,1200],[4,0,200],[4,1,900],[4,2,400],[4,3,300],[4,4,100]],
label: { show: true },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
}]
})
const heatmapOption = computed(() => {
const sources = [...new Set(heatmapData.value.map((item: any) => item.source_name))]
const levels = ['L1', 'L2', 'L3', 'L4', 'L5']
const data = heatmapData.value.map((item: any) => {
const x = levels.indexOf(item.level_code)
const y = sources.indexOf(item.source_name)
return [x, y, item.count]
})
const maxCount = Math.max(...heatmapData.value.map((item: any) => item.count), 1)
const projectList = ref([
{ name: '2024年数据分类分级专项', status: 'labeling', progress: 68, planned_end: '2024-08-30' },
{ name: '核心系统敏感数据梳理', status: 'reviewing', progress: 92, planned_end: '2024-07-15' },
{ name: '新核心上线数据定级', status: 'scanning', progress: 25, planned_end: '2024-09-20' },
])
return {
tooltip: { position: 'top' },
grid: { height: '50%', top: '10%' },
xAxis: { type: 'category', data: levels, splitArea: { show: true } },
yAxis: { type: 'category', data: sources, splitArea: { show: true } },
visualMap: { min: 0, max: maxCount, calculable: true, orient: 'horizontal', left: 'center', bottom: '15%', inRange: { color: ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] } },
series: [{
type: 'heatmap',
data,
label: { show: true },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
}]
}
})
function statusType(status: string) {
const map: Record<string, any> = {
@@ -200,23 +210,31 @@ function statusText(status: string) {
return map[status] || status
}
async function fetchProjects() {
async function fetchData() {
try {
const res: any = await getProjects({ page: 1, page_size: 10 })
if (res?.data?.length) {
projectList.value = res.data.map((p: any) => ({
name: p.name,
status: p.status,
progress: p.stats?.total ? Math.round((p.stats.reviewed / p.stats.total) * 100) : 0,
planned_end: p.planned_end ? p.planned_end.slice(0, 10) : '--',
}))
}
const [statsRes, distRes] = await Promise.all([
getDashboardStats(),
getDashboardDistribution(),
])
const s = (statsRes as any)?.data || {}
Object.assign(stats, s)
const d = (distRes as any)?.data || {}
levelDist.value = d.level_distribution || []
categoryDist.value = d.category_distribution || []
projectList.value = (d.project_progress || []).map((p: any) => ({
name: p.name,
status: p.status,
progress: p.progress,
planned_end: p.planned_end ? p.planned_end.slice(0, 10) : '--',
}))
heatmapData.value = d.heatmap || []
} catch (e) {
// ignore
}
}
onMounted(fetchProjects)
onMounted(fetchData)
</script>
<style scoped lang="scss">
+91 -72
View File
@@ -34,8 +34,8 @@
</el-col>
<el-col :xs="24" :md="12">
<div class="chart-card card-shadow">
<div class="chart-title">敏感数据趋势近7天</div>
<v-chart class="chart" :option="trendOption" autoresize />
<div class="chart-title">分级数量统计</div>
<v-chart class="chart" :option="levelBarOption" autoresize />
</div>
</el-col>
</el-row>
@@ -43,116 +43,135 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart, LineChart } from 'echarts/charts'
import { PieChart, BarChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { Download } from '@element-plus/icons-vue'
import { getProjects } from '@/api/project'
import { getReportStats, downloadReport as doDownload } from '@/api/report'
use([CanvasRenderer, PieChart, BarChart, LineChart, TooltipComponent, LegendComponent, GridComponent])
use([CanvasRenderer, PieChart, BarChart, TooltipComponent, LegendComponent, GridComponent])
const selectedProject = ref<number | undefined>(undefined)
const projects = ref<any[]>([])
const levelOption = ref({
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'],
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: [
{ value: 35000, name: 'L1 公开级' },
{ value: 62000, name: 'L2 内部级' },
{ value: 48000, name: 'L3 敏感级' },
{ value: 22000, name: 'L4 重要级' },
{ value: 6931, name: 'L5 核心级' },
],
},
],
const reportStats = ref<any>(null)
const projectProgress = ref<any[]>([])
const levelColors = ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399']
const levelOption = computed(() => {
const dist = reportStats.value?.level_distribution || []
return {
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: levelColors,
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: dist.map((item: any) => ({
value: item.count,
name: `${item.code} ${item.name}`,
})),
},
],
}
})
const projectOption = ref({
const projectOption = computed(() => ({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: ['项目A', '项目B', '项目C', '项目D'] },
xAxis: { type: 'category', data: projectProgress.value.map((p: any) => p.name.slice(0, 8)) },
yAxis: { type: 'value', max: 100 },
series: [
{
type: 'bar',
data: [68, 92, 25, 45],
data: projectProgress.value.map((p: any) => p.progress),
itemStyle: { borderRadius: [4, 4, 0, 0], color: '#409eff' },
label: { show: true, position: 'top', formatter: '{c}%' },
},
],
}))
const sourceOption = computed(() => {
const total = reportStats.value?.total || 1
const auto = reportStats.value?.auto || 0
const manual = reportStats.value?.manual || 0
return {
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: ['#e6a23c', '#67c23a'],
series: [
{
type: 'pie',
radius: ['40%', '70%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: [
{ value: auto, name: '自动识别' },
{ value: manual, name: '人工打标' },
],
},
],
}
})
const sourceOption = ref({
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: ['#e6a23c', '#67c23a'],
series: [
{
type: 'pie',
radius: ['40%', '70%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: [
{ value: 124500, name: '自动识别' },
{ value: 27840, name: '人工打标' },
],
},
],
})
const trendOption = ref({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
yAxis: { type: 'value' },
series: [
{
type: 'line',
data: [120, 132, 101, 134, 90, 230, 210],
smooth: true,
itemStyle: { color: '#f56c6c' },
areaStyle: { color: 'rgba(245,108,108,0.1)' },
},
],
const levelBarOption = computed(() => {
const dist = reportStats.value?.level_distribution || []
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: dist.map((item: any) => `${item.code} ${item.name}`) },
yAxis: { type: 'value' },
series: [
{
type: 'bar',
data: dist.map((item: any) => item.count),
itemStyle: { borderRadius: [4, 4, 0, 0], color: (params: any) => levelColors[params.dataIndex] || '#409eff' },
label: { show: true, position: 'top' },
},
],
}
})
function downloadReport() {
if (!selectedProject.value) return
const token = localStorage.getItem('pdg_token')
const url = `/api/v1/reports/projects/${selectedProject.value}/download`
const a = document.createElement('a')
a.href = url
a.download = `report_project_${selectedProject.value}.docx`
if (token) {
a.setAttribute('data-token', token)
}
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
doDownload(selectedProject.value)
}
async function fetchProjects() {
try {
const res: any = await getProjects({ page: 1, page_size: 100 })
projects.value = res?.data || []
projectProgress.value = (res?.data || []).map((p: any) => ({
name: p.name,
progress: p.stats?.total ? Math.round((p.stats.reviewed / p.stats.total) * 100) : 0,
}))
} catch (e) {
// ignore
}
}
onMounted(fetchProjects)
async function fetchStats() {
try {
const res: any = await getReportStats()
reportStats.value = res?.data || null
} catch (e) {
// ignore
}
}
onMounted(() => {
fetchProjects()
fetchStats()
})
</script>
<style scoped lang="scss">