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
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, user, datasource, metadata, classification, project, task, report
from app.api.v1 import auth, user, datasource, metadata, classification, project, task, report, dashboard
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
@@ -11,3 +11,4 @@ api_router.include_router(classification.router, prefix="/classifications", tags
api_router.include_router(project.router, prefix="/projects", tags=["项目管理"])
api_router.include_router(task.router, prefix="/tasks", tags=["任务管理"])
api_router.include_router(report.router, prefix="/reports", tags=["报告管理"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["仪表盘"])
+113
View File
@@ -0,0 +1,113 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.core.database import get_db
from app.models.user import User
from app.models.metadata import DataSource, DataTable, DataColumn
from app.models.project import ClassificationResult, ClassificationProject
from app.models.classification import Category, DataLevel
from app.schemas.common import ResponseModel
from app.api.deps import get_current_user
router = APIRouter()
@router.get("/stats")
def get_dashboard_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Dashboard overview statistics based on real DB data."""
data_sources = db.query(DataSource).count()
tables = db.query(DataTable).count()
columns = db.query(DataColumn).count()
labeled = db.query(ClassificationResult).count()
sensitive = db.query(ClassificationResult).join(DataLevel).filter(
DataLevel.code.in_(['L4', 'L5'])
).count()
projects = db.query(ClassificationProject).count()
return ResponseModel(data={
"data_sources": data_sources,
"tables": tables,
"columns": columns,
"labeled": labeled,
"sensitive": sensitive,
"projects": projects,
})
@router.get("/distribution")
def get_dashboard_distribution(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Distribution data for charts based on real DB data."""
# Level distribution
level_dist = db.query(DataLevel.name, DataLevel.code, DataLevel.color, func.count(ClassificationResult.id)).\
join(ClassificationResult, DataLevel.id == ClassificationResult.level_id).\
group_by(DataLevel.id).\
order_by(DataLevel.sort_order).all()
# Category distribution
category_dist = db.query(Category.name, func.count(ClassificationResult.id)).\
join(ClassificationResult, Category.id == ClassificationResult.category_id).\
group_by(Category.id).\
order_by(func.count(ClassificationResult.id).desc()).limit(8).all()
# Source distribution
source_dist = db.query(ClassificationResult.source, func.count(ClassificationResult.id)).\
group_by(ClassificationResult.source).all()
# Project progress
projects = db.query(ClassificationProject).all()
project_progress = []
for p in projects:
total = db.query(ClassificationResult).filter(ClassificationResult.project_id == p.id).count()
reviewed = db.query(ClassificationResult).filter(
ClassificationResult.project_id == p.id,
ClassificationResult.status == 'reviewed',
).count()
project_progress.append({
"id": p.id,
"name": p.name,
"status": p.status,
"progress": round(reviewed / total * 100) if total else 0,
"planned_end": p.planned_end.isoformat() if p.planned_end else None,
})
# Heatmap: source vs level
sources = db.query(DataSource).order_by(DataSource.id).limit(8).all()
levels = db.query(DataLevel).order_by(DataLevel.sort_order).all()
heatmap = []
for si, source in enumerate(sources):
for li, level in enumerate(levels):
count = db.query(func.count(ClassificationResult.id)).\
join(DataColumn, ClassificationResult.column_id == DataColumn.id).\
join(DataTable, DataColumn.table_id == DataTable.id).\
join(DataSource, DataTable.database_id == DataSource.id).\
filter(DataSource.id == source.id, ClassificationResult.level_id == level.id).scalar()
heatmap.append({
"source_name": source.name,
"level_code": level.code,
"count": count or 0,
})
return ResponseModel(data={
"level_distribution": [
{"name": name, "code": code, "color": color, "count": count}
for name, code, color, count in level_dist
],
"category_distribution": [
{"name": name, "count": count}
for name, count in category_dist
],
"source_distribution": [
{"source": src, "count": count}
for src, count in source_dist
],
"project_progress": project_progress,
"heatmap": heatmap,
})
+33 -1
View File
@@ -1,14 +1,46 @@
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.core.database import get_db
from app.models.user import User
from app.services import report_service
from app.models.project import ClassificationResult, ClassificationProject
from app.models.classification import Category, DataLevel
from app.schemas.common import ResponseModel
from app.api.deps import get_current_user
from app.services import report_service
router = APIRouter()
@router.get("/stats")
def get_report_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Global report statistics."""
total = db.query(ClassificationResult).count()
auto = db.query(ClassificationResult).filter(ClassificationResult.source == 'auto').count()
manual = db.query(ClassificationResult).filter(ClassificationResult.source == 'manual').count()
reviewed = db.query(ClassificationResult).filter(ClassificationResult.status == 'reviewed').count()
# Level distribution
level_dist = db.query(DataLevel.name, DataLevel.code, func.count(ClassificationResult.id)).\
join(ClassificationResult, DataLevel.id == ClassificationResult.level_id).\
group_by(DataLevel.id).order_by(DataLevel.sort_order).all()
return ResponseModel(data={
"total": total,
"auto": auto,
"manual": manual,
"reviewed": reviewed,
"level_distribution": [
{"name": name, "code": code, "count": count}
for name, code, count in level_dist
],
})
@router.get("/projects/{project_id}/download")
def download_report(
project_id: int,
+1
View File
@@ -48,6 +48,7 @@ class ClassificationProject(Base):
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
template = relationship("ClassificationTemplate")
tasks = relationship("ClassificationTask", back_populates="project", cascade="all, delete-orphan")
results = relationship("ClassificationResult", back_populates="project", cascade="all, delete-orphan")
+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">