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 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=["仪表盘"])
+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 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,
+1
View File
@@ -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")
+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)
}
+57 -39
View File
@@ -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,54 +134,56 @@ 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(() => {
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' }, tooltip: { position: 'top' },
grid: { height: '50%', top: '10%' }, grid: { height: '50%', top: '10%' },
xAxis: { type: 'category', data: ['L1', 'L2', 'L3', 'L4', 'L5'], splitArea: { show: true } }, xAxis: { type: 'category', data: levels, splitArea: { show: true } },
yAxis: { type: 'category', data: ['核心系统', '理赔系统', '保单系统', '财务系统', '渠道系统'], splitArea: { show: true } }, yAxis: { type: 'category', data: sources, splitArea: { show: true } },
visualMap: { min: 0, max: 10000, calculable: true, orient: 'horizontal', left: 'center', bottom: '15%', inRange: { color: ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] } }, visualMap: { min: 0, max: maxCount, calculable: true, orient: 'horizontal', left: 'center', bottom: '15%', inRange: { color: ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] } },
series: [{ series: [{
type: 'heatmap', 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 }, label: { show: true },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } } 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) { function statusType(status: string) {
const map: Record<string, any> = { const map: Record<string, any> = {
created: 'info', created: 'info',
@@ -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(),
])
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, name: p.name,
status: p.status, 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) : '--', 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">
+60 -41
View File
@@ -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,24 +43,32 @@
</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)
const projectProgress = ref<any[]>([])
const levelColors = ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399']
const levelOption = computed(() => {
const dist = reportStats.value?.level_distribution || []
return {
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' }, legend: { bottom: '0%', left: 'center' },
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'], color: levelColors,
series: [ series: [
{ {
type: 'pie', type: 'pie',
@@ -68,33 +76,35 @@ 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: dist.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 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 = 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' }, tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' }, legend: { bottom: '0%', left: 'center' },
color: ['#e6a23c', '#67c23a'], color: ['#e6a23c', '#67c23a'],
@@ -105,54 +115,63 @@ const sourceOption = ref({
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }, itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false }, label: { show: false },
data: [ data: [
{ value: 124500, name: '自动识别' }, { value: auto, name: '自动识别' },
{ value: 27840, name: '人工打标' }, { value: manual, name: '人工打标' },
], ],
}, },
], ],
}
}) })
const trendOption = ref({ const levelBarOption = computed(() => {
tooltip: { trigger: 'axis' }, const dist = reportStats.value?.level_distribution || []
return {
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: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] }, xAxis: { type: 'category', data: dist.map((item: any) => `${item.code} ${item.name}`) },
yAxis: { type: 'value' }, yAxis: { type: 'value' },
series: [ series: [
{ {
type: 'line', type: 'bar',
data: [120, 132, 101, 134, 90, 230, 210], data: dist.map((item: any) => item.count),
smooth: true, itemStyle: { borderRadius: [4, 4, 0, 0], color: (params: any) => levelColors[params.dataIndex] || '#409eff' },
itemStyle: { color: '#f56c6c' }, label: { show: true, position: 'top' },
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">