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 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=["仪表盘"])
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,54 +134,56 @@ 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({
|
||||
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)
|
||||
|
||||
return {
|
||||
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'] } },
|
||||
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: [[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]],
|
||||
data,
|
||||
label: { show: true },
|
||||
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
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' },
|
||||
])
|
||||
|
||||
function statusType(status: string) {
|
||||
const map: Record<string, any> = {
|
||||
created: 'info',
|
||||
@@ -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) => ({
|
||||
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.stats?.total ? Math.round((p.stats.reviewed / p.stats.total) * 100) : 0,
|
||||
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,24 +43,32 @@
|
||||
</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({
|
||||
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: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'],
|
||||
color: levelColors,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
@@ -68,33 +76,35 @@ 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: 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 = ref({
|
||||
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'],
|
||||
@@ -105,54 +115,63 @@ const sourceOption = ref({
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: [
|
||||
{ value: 124500, name: '自动识别' },
|
||||
{ value: 27840, name: '人工打标' },
|
||||
{ value: auto, name: '自动识别' },
|
||||
{ value: manual, name: '人工打标' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const trendOption = ref({
|
||||
tooltip: { trigger: 'axis' },
|
||||
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: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
|
||||
xAxis: { type: 'category', data: dist.map((item: any) => `${item.code} ${item.name}`) },
|
||||
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)' },
|
||||
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