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:
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
|
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(project.router, prefix="/projects", tags=["项目管理"])
|
||||||
api_router.include_router(task.router, prefix="/tasks", tags=["任务管理"])
|
api_router.include_router(task.router, prefix="/tasks", tags=["任务管理"])
|
||||||
api_router.include_router(report.router, prefix="/reports", tags=["报告管理"])
|
api_router.include_router(report.router, prefix="/reports", tags=["报告管理"])
|
||||||
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["仪表盘"])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -1,14 +1,46 @@
|
|||||||
from fastapi import APIRouter, Depends, Response
|
from fastapi import APIRouter, Depends, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.user import User
|
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.api.deps import get_current_user
|
||||||
|
from app.services import report_service
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/projects/{project_id}/download")
|
||||||
def download_report(
|
def download_report(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class ClassificationProject(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=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")
|
tasks = relationship("ClassificationTask", back_populates="project", cascade="all, delete-orphan")
|
||||||
results = relationship("ClassificationResult", back_populates="project", cascade="all, delete-orphan")
|
results = relationship("ClassificationResult", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<el-icon size="28"><DataLine /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<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 class="stat-label">数据源</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,27 +98,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { use } from 'echarts/core'
|
import { use } from 'echarts/core'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { PieChart, BarChart, HeatmapChart } from 'echarts/charts'
|
import { PieChart, BarChart, HeatmapChart } from 'echarts/charts'
|
||||||
import { TooltipComponent, LegendComponent, GridComponent, VisualMapComponent } from 'echarts/components'
|
import { TooltipComponent, LegendComponent, GridComponent, VisualMapComponent } from 'echarts/components'
|
||||||
import VChart from 'vue-echarts'
|
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])
|
use([CanvasRenderer, PieChart, BarChart, HeatmapChart, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent])
|
||||||
|
|
||||||
const stats = reactive({
|
const stats = reactive({
|
||||||
dataSources: 12,
|
data_sources: 0,
|
||||||
tables: 3847,
|
tables: 0,
|
||||||
labeled: 152340,
|
columns: 0,
|
||||||
sensitive: 28931,
|
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' },
|
tooltip: { trigger: 'item' },
|
||||||
legend: { bottom: '0%', left: 'center' },
|
legend: { bottom: '0%', left: 'center' },
|
||||||
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'],
|
color: levelDist.value.map((item: any) => item.color || '#999'),
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
@@ -126,53 +134,55 @@ const levelOption = ref({
|
|||||||
avoidLabelOverlap: false,
|
avoidLabelOverlap: false,
|
||||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
data: [
|
data: levelDist.value.map((item: any) => ({
|
||||||
{ value: 35000, name: 'L1 公开级' },
|
value: item.count,
|
||||||
{ value: 62000, name: 'L2 内部级' },
|
name: `${item.code} ${item.name}`,
|
||||||
{ value: 48000, name: 'L3 敏感级' },
|
})),
|
||||||
{ value: 22000, name: 'L4 重要级' },
|
|
||||||
{ value: 6931, name: 'L5 核心级' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}))
|
||||||
|
|
||||||
const categoryOption = ref({
|
const categoryOption = computed(() => ({
|
||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
xAxis: { type: 'value' },
|
xAxis: { type: 'value' },
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: ['车辆信息', '理赔数据', '渠道数据', '财务数据', '监管报送', '内部管理', '保单数据', '客户数据'],
|
data: categoryDist.value.map((item: any) => item.name).reverse(),
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'bar',
|
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' },
|
itemStyle: { borderRadius: [0, 4, 4, 0], color: '#409eff' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}))
|
||||||
|
|
||||||
const heatmapOption = ref({
|
const heatmapOption = computed(() => {
|
||||||
tooltip: { position: 'top' },
|
const sources = [...new Set(heatmapData.value.map((item: any) => item.source_name))]
|
||||||
grid: { height: '50%', top: '10%' },
|
const levels = ['L1', 'L2', 'L3', 'L4', 'L5']
|
||||||
xAxis: { type: 'category', data: ['L1', 'L2', 'L3', 'L4', 'L5'], splitArea: { show: true } },
|
const data = heatmapData.value.map((item: any) => {
|
||||||
yAxis: { type: 'category', data: ['核心系统', '理赔系统', '保单系统', '财务系统', '渠道系统'], splitArea: { show: true } },
|
const x = levels.indexOf(item.level_code)
|
||||||
visualMap: { min: 0, max: 10000, calculable: true, orient: 'horizontal', left: 'center', bottom: '15%', inRange: { color: ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] } },
|
const y = sources.indexOf(item.source_name)
|
||||||
series: [{
|
return [x, y, item.count]
|
||||||
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]],
|
const maxCount = Math.max(...heatmapData.value.map((item: any) => item.count), 1)
|
||||||
label: { show: true },
|
|
||||||
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectList = ref([
|
return {
|
||||||
{ name: '2024年数据分类分级专项', status: 'labeling', progress: 68, planned_end: '2024-08-30' },
|
tooltip: { position: 'top' },
|
||||||
{ name: '核心系统敏感数据梳理', status: 'reviewing', progress: 92, planned_end: '2024-07-15' },
|
grid: { height: '50%', top: '10%' },
|
||||||
{ name: '新核心上线数据定级', status: 'scanning', progress: 25, planned_end: '2024-09-20' },
|
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) {
|
function statusType(status: string) {
|
||||||
const map: Record<string, any> = {
|
const map: Record<string, any> = {
|
||||||
@@ -200,23 +210,31 @@ function statusText(status: string) {
|
|||||||
return map[status] || status
|
return map[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProjects() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const res: any = await getProjects({ page: 1, page_size: 10 })
|
const [statsRes, distRes] = await Promise.all([
|
||||||
if (res?.data?.length) {
|
getDashboardStats(),
|
||||||
projectList.value = res.data.map((p: any) => ({
|
getDashboardDistribution(),
|
||||||
name: p.name,
|
])
|
||||||
status: p.status,
|
const s = (statsRes as any)?.data || {}
|
||||||
progress: p.stats?.total ? Math.round((p.stats.reviewed / p.stats.total) * 100) : 0,
|
Object.assign(stats, s)
|
||||||
planned_end: p.planned_end ? p.planned_end.slice(0, 10) : '--',
|
|
||||||
}))
|
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) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchProjects)
|
onMounted(fetchData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :md="12">
|
<el-col :xs="24" :md="12">
|
||||||
<div class="chart-card card-shadow">
|
<div class="chart-card card-shadow">
|
||||||
<div class="chart-title">敏感数据趋势(近7天)</div>
|
<div class="chart-title">分级数量统计</div>
|
||||||
<v-chart class="chart" :option="trendOption" autoresize />
|
<v-chart class="chart" :option="levelBarOption" autoresize />
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -43,116 +43,135 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { use } from 'echarts/core'
|
import { use } from 'echarts/core'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
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 { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
import { Download } from '@element-plus/icons-vue'
|
import { Download } from '@element-plus/icons-vue'
|
||||||
import { getProjects } from '@/api/project'
|
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 selectedProject = ref<number | undefined>(undefined)
|
||||||
const projects = ref<any[]>([])
|
const projects = ref<any[]>([])
|
||||||
|
|
||||||
const levelOption = ref({
|
const reportStats = ref<any>(null)
|
||||||
tooltip: { trigger: 'item' },
|
const projectProgress = ref<any[]>([])
|
||||||
legend: { bottom: '0%', left: 'center' },
|
|
||||||
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'],
|
const levelColors = ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399']
|
||||||
series: [
|
|
||||||
{
|
const levelOption = computed(() => {
|
||||||
type: 'pie',
|
const dist = reportStats.value?.level_distribution || []
|
||||||
radius: ['40%', '70%'],
|
return {
|
||||||
avoidLabelOverlap: false,
|
tooltip: { trigger: 'item' },
|
||||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
legend: { bottom: '0%', left: 'center' },
|
||||||
label: { show: false },
|
color: levelColors,
|
||||||
data: [
|
series: [
|
||||||
{ value: 35000, name: 'L1 公开级' },
|
{
|
||||||
{ value: 62000, name: 'L2 内部级' },
|
type: 'pie',
|
||||||
{ value: 48000, name: 'L3 敏感级' },
|
radius: ['40%', '70%'],
|
||||||
{ value: 22000, name: 'L4 重要级' },
|
avoidLabelOverlap: false,
|
||||||
{ value: 6931, name: 'L5 核心级' },
|
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' } },
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
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 },
|
yAxis: { type: 'value', max: 100 },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: [68, 92, 25, 45],
|
data: projectProgress.value.map((p: any) => p.progress),
|
||||||
itemStyle: { borderRadius: [4, 4, 0, 0], color: '#409eff' },
|
itemStyle: { borderRadius: [4, 4, 0, 0], color: '#409eff' },
|
||||||
label: { show: true, position: 'top', formatter: '{c}%' },
|
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({
|
const levelBarOption = computed(() => {
|
||||||
tooltip: { trigger: 'item' },
|
const dist = reportStats.value?.level_distribution || []
|
||||||
legend: { bottom: '0%', left: 'center' },
|
return {
|
||||||
color: ['#e6a23c', '#67c23a'],
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
series: [
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
{
|
xAxis: { type: 'category', data: dist.map((item: any) => `${item.code} ${item.name}`) },
|
||||||
type: 'pie',
|
yAxis: { type: 'value' },
|
||||||
radius: ['40%', '70%'],
|
series: [
|
||||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
{
|
||||||
label: { show: false },
|
type: 'bar',
|
||||||
data: [
|
data: dist.map((item: any) => item.count),
|
||||||
{ value: 124500, name: '自动识别' },
|
itemStyle: { borderRadius: [4, 4, 0, 0], color: (params: any) => levelColors[params.dataIndex] || '#409eff' },
|
||||||
{ value: 27840, name: '人工打标' },
|
label: { show: true, position: 'top' },
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
}
|
||||||
})
|
|
||||||
|
|
||||||
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)' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function downloadReport() {
|
function downloadReport() {
|
||||||
if (!selectedProject.value) return
|
if (!selectedProject.value) return
|
||||||
const token = localStorage.getItem('pdg_token')
|
doDownload(selectedProject.value)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProjects() {
|
async function fetchProjects() {
|
||||||
try {
|
try {
|
||||||
const res: any = await getProjects({ page: 1, page_size: 100 })
|
const res: any = await getProjects({ page: 1, page_size: 100 })
|
||||||
projects.value = res?.data || []
|
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) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchProjects)
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const res: any = await getReportStats()
|
||||||
|
reportStats.value = res?.data || null
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchProjects()
|
||||||
|
fetchStats()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
Reference in New Issue
Block a user