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:
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user