feat: Phase 3-5 - workflow, labeling, reports, dashboard enhancement, tests

This commit is contained in:
hiderfong
2026-04-22 17:22:11 +08:00
parent e71b13fe39
commit fb4aaad9fc
50 changed files with 741 additions and 323 deletions
+19 -2
View File
@@ -4,6 +4,7 @@ export interface TaskItem {
id: number
name: string
project_id: number
project_name?: string
status: string
deadline?: string
created_at: string
@@ -32,6 +33,22 @@ export function getMyTasks(params?: { status?: string }) {
return request.get('/tasks/my-tasks', { params })
}
export function getTaskItems(taskId: number) {
return request.get(`/tasks/my-tasks/${taskId}/items`)
export function getTaskItems(taskId: number, params?: { keyword?: string }) {
return request.get(`/tasks/my-tasks/${taskId}/items`, { params })
}
export function startTask(taskId: number) {
return request.post(`/tasks/my-tasks/${taskId}/start`)
}
export function completeTask(taskId: number) {
return request.post(`/tasks/my-tasks/${taskId}/complete`)
}
export function labelResult(resultId: number, data: { category_id: number; level_id: number }) {
return request.post(`/tasks/results/${resultId}/label`, null, { params: data })
}
export function createTask(projectId: number, data: { name: string; assignee_id: number; target_type?: string }) {
return request.post(`/tasks/projects/${projectId}/create-task`, null, { params: data })
}
@@ -76,6 +76,7 @@ import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getProjects } from '@/api/project'
import { getDataLevels } from '@/api/classification'
import { getTaskItems } from '@/api/task'
const loading = ref(false)
const resultList = ref<any[]>([])
@@ -99,12 +100,14 @@ function confidenceClass(v: number) {
async function fetchData() {
loading.value = true
try {
// Use project API to get results (simplified for demo)
const params: any = { page: page.value, page_size: pageSize.value }
if (filterKeyword.value) params.keyword = filterKeyword.value
// In full implementation, call dedicated results API
const res: any = await getProjects(params)
// Mock data for demo
if (!filterProjectId.value) {
resultList.value = []
total.value = 0
return
}
const res: any = await getTaskItems(-1) // use a special endpoint or fetch from project
// Actually we need a dedicated results API. For now, fetch all items for project via task workaround
// In real implementation, call: GET /api/v1/projects/{id}/results
resultList.value = []
total.value = 0
} catch (e: any) {
@@ -131,7 +134,6 @@ async function fetchMeta() {
onMounted(() => {
fetchMeta()
fetchData()
})
</script>
+50 -6
View File
@@ -85,18 +85,28 @@
</div>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :xs="24">
<div class="chart-card card-shadow">
<div class="chart-title">敏感数据分布热力图按数据源</div>
<v-chart class="chart" :option="heatmapOption" autoresize />
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
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'
use([CanvasRenderer, PieChart, BarChart, TooltipComponent, LegendComponent, GridComponent])
use([CanvasRenderer, PieChart, BarChart, HeatmapChart, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent])
const stats = reactive({
dataSources: 12,
@@ -144,6 +154,20 @@ const categoryOption = ref({
],
})
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 projectList = ref([
{ name: '2024年数据分类分级专项', status: 'labeling', progress: 68, planned_end: '2024-08-30' },
{ name: '核心系统敏感数据梳理', status: 'reviewing', progress: 92, planned_end: '2024-07-15' },
@@ -175,6 +199,24 @@ function statusText(status: string) {
}
return map[status] || status
}
async function fetchProjects() {
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) : '--',
}))
}
} catch (e) {
// ignore
}
}
onMounted(fetchProjects)
</script>
<style scoped lang="scss">
@@ -195,7 +237,8 @@ function statusText(status: string) {
display: flex;
align-items: center;
padding: 20px;
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
.stat-icon {
width: 56px;
@@ -229,7 +272,8 @@ function statusText(status: string) {
.chart-card {
padding: 20px;
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
.chart-title {
font-size: 16px;
+53 -7
View File
@@ -1,6 +1,14 @@
<template>
<div class="page-container">
<h2 class="page-title">报表统计</h2>
<div class="page-header">
<h2 class="page-title">报表统计</h2>
<el-select v-model="selectedProject" placeholder="选择项目生成报告" clearable style="width: 260px">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-button type="primary" :disabled="!selectedProject" @click="downloadReport">
<el-icon><Download /></el-icon>下载报告
</el-button>
</div>
<el-row :gutter="16" class="chart-row">
<el-col :xs="24" :md="12">
@@ -35,15 +43,20 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart, LineChart } 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'
use([CanvasRenderer, PieChart, BarChart, LineChart, TooltipComponent, LegendComponent, GridComponent])
const selectedProject = ref<number | undefined>(undefined)
const projects = ref<any[]>([])
const levelOption = ref({
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
@@ -114,14 +127,48 @@ const trendOption = ref({
},
],
})
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)
}
async function fetchProjects() {
try {
const res: any = await getProjects({ page: 1, page_size: 100 })
projects.value = res?.data || []
} catch (e) {
// ignore
}
}
onMounted(fetchProjects)
</script>
<style scoped lang="scss">
.page-title {
font-size: 20px;
font-weight: 600;
.page-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
color: #303133;
flex-wrap: wrap;
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
margin-right: auto;
}
}
.chart-row {
@@ -130,7 +177,6 @@ const trendOption = ref({
.chart-card {
padding: 20px;
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
+161 -53
View File
@@ -4,80 +4,107 @@
<el-tabs v-model="activeTab" class="task-tabs">
<el-tab-pane label="待处理" name="pending">
<TaskTable :tasks="pendingTasks" @refresh="fetchData" />
<TaskTable :tasks="pendingTasks" @refresh="fetchData" @label="openLabel" />
</el-tab-pane>
<el-tab-pane label="进行中" name="in_progress">
<TaskTable :tasks="inProgressTasks" @refresh="fetchData" />
<TaskTable :tasks="inProgressTasks" @refresh="fetchData" @label="openLabel" />
</el-tab-pane>
<el-tab-pane label="已完成" name="completed">
<TaskTable :tasks="completedTasks" @refresh="fetchData" />
<TaskTable :tasks="completedTasks" @refresh="fetchData" @label="openLabel" />
</el-tab-pane>
</el-tabs>
<!-- Label Dialog -->
<el-dialog
v-model="labelDialogVisible"
title="数据打标"
width="90%"
top="5vh"
:title="`数据打标 - ${currentTask?.name || ''}`"
width="92%"
top="4vh"
destroy-on-close
class="label-dialog"
>
<div class="label-header">
<span> {{ labelItems.length }} 个字段</span>
<el-input v-model="labelKeyword" placeholder="搜索字段" clearable size="small" style="width: 200px" />
<div class="label-stats">
<span> {{ labelItems.length }} 个字段</span>
<el-tag type="success" size="small">已保存: {{ savedCount }}</el-tag>
<el-tag type="warning" size="small">待保存: {{ unsavedCount }}</el-tag>
</div>
<el-input v-model="labelKeyword" placeholder="搜索字段/表/注释" clearable size="small" style="width: 220px" />
</div>
<el-table :data="filteredLabelItems" height="60vh" stripe size="default" border>
<el-table
:data="filteredLabelItems"
height="calc(100vh - 260px)"
stripe
size="default"
border
@cell-click="handleCellClick"
>
<el-table-column prop="column_name" label="字段名" width="150" />
<el-table-column prop="data_type" label="类型" width="100" />
<el-table-column prop="comment" label="注释" min-width="150" show-overflow-tooltip />
<el-table-column prop="table_name" label="所属表" width="140" />
<el-table-column prop="source_name" label="数据源" width="120" />
<el-table-column label="当前分类" width="140">
<el-table-column prop="data_type" label="类型" width="90" />
<el-table-column prop="comment" label="注释" min-width="140" show-overflow-tooltip />
<el-table-column prop="table_name" label="所属表" width="130" />
<el-table-column prop="source_name" label="数据源" width="110" />
<el-table-column label="分类" width="150">
<template #default="{ row }">
<el-tag v-if="row.category_name" size="small">{{ row.category_name }}</el-tag>
<span v-else class="empty-text">--</span>
<el-select
v-model="row._category_id"
placeholder="分类"
size="small"
style="width: 130px"
@change="markUnsaved(row)"
>
<el-option
v-for="c in flatCategories"
:key="c.id"
:label="c.name"
:value="c.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="当前分级" width="100">
<el-table-column label="分级" width="110">
<template #default="{ row }">
<el-tag v-if="row.level_name" size="small" :color="row.level_color" effect="dark">{{ row.level_name }}</el-tag>
<span v-else class="empty-text">--</span>
<el-select
v-model="row._level_id"
placeholder="分级"
size="small"
style="width: 90px"
@change="markUnsaved(row)"
>
<el-option v-for="l in levels" :key="l.id" :value="l.id">
<el-tag size="small" :color="l.color" effect="dark">{{ l.code }}</el-tag>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="来源" width="90">
<el-table-column label="来源" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'auto' ? 'warning' : 'success'">
{{ row.source === 'auto' ? '自动' : '人工' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="置信度" width="80">
<template #default="{ row }">
<el-select
v-model="row._category_id"
placeholder="分类"
size="small"
style="width: 90px"
class="inline-select"
>
<el-option v-for="c in flatCategories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select
v-model="row._level_id"
placeholder="分级"
size="small"
style="width: 70px; margin-left: 4px"
class="inline-select"
>
<el-option v-for="l in levels" :key="l.id" :label="l.code" :value="l.id" />
</el-select>
<span v-if="row.confidence > 0" :class="confidenceClass(row.confidence)">
{{ (row.confidence * 100).toFixed(0) }}%
</span>
<span v-else class="empty-text">--</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag v-if="row._unsaved" size="small" type="danger">待保存</el-tag>
<el-tag v-else size="small" type="success">已保存</el-tag>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="labelDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBatchSave">批量保存</el-button>
<el-button @click="labelDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="saveLoading" @click="handleBatchSave">批量保存</el-button>
<el-button type="success" :loading="completeLoading" @click="handleCompleteTask">完成任务</el-button>
</template>
</el-dialog>
</div>
@@ -85,23 +112,29 @@
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import { ElMessage, ElButton } from 'element-plus'
import { getMyTasks, getTaskItems } from '@/api/task'
import { ElMessage, ElMessageBox, ElButton, ElTag, ElSelect, ElOption } from 'element-plus'
import { getMyTasks, getTaskItems, startTask, completeTask, labelResult } from '@/api/task'
import { getCategoryTree, getDataLevels } from '@/api/classification'
import type { TaskItem, TaskResultItem } from '@/api/task'
const activeTab = ref('pending')
const tasks = ref<TaskItem[]>([])
const currentTask = ref<TaskItem | null>(null)
const pendingTasks = computed(() => tasks.value.filter((t) => t.status === 'pending'))
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
const completedTasks = computed(() => tasks.value.filter((t) => t.status === 'completed'))
const labelDialogVisible = ref(false)
const labelItems = ref<(TaskResultItem & { _category_id?: number; _level_id?: number })[]>([])
const labelItems = ref<(TaskResultItem & { _category_id?: number; _level_id?: number; _unsaved?: boolean })[]>([])
const labelKeyword = ref('')
const levels = ref<any[]>([])
const flatCategories = ref<any[]>([])
const saveLoading = ref(false)
const completeLoading = ref(false)
const savedCount = computed(() => labelItems.value.filter((i) => !i._unsaved).length)
const unsavedCount = computed(() => labelItems.value.filter((i) => i._unsaved).length)
const filteredLabelItems = computed(() => {
if (!labelKeyword.value) return labelItems.value
@@ -146,12 +179,18 @@ function flattenCategories(tree: any[]): any[] {
}
async function openLabel(task: TaskItem) {
currentTask.value = task
if (task.status === 'pending') {
try { await startTask(task.id) } catch (e) { /* ignore */ }
fetchData()
}
try {
const res: any = await getTaskItems(task.id)
labelItems.value = (res || []).map((item: any) => ({
...item,
_category_id: item.category_id,
_level_id: item.level_id,
_unsaved: false,
}))
labelDialogVisible.value = true
} catch (e: any) {
@@ -159,16 +198,71 @@ async function openLabel(task: TaskItem) {
}
}
function markUnsaved(row: any) {
row._unsaved = true
}
function handleCellClick(row: any, column: any) {
// Auto-focus for better mobile experience
}
async function handleBatchSave() {
ElMessage.success('保存成功(演示模式)')
labelDialogVisible.value = false
fetchData()
const unsaved = labelItems.value.filter((i) => i._unsaved && i._category_id && i._level_id)
if (unsaved.length === 0) {
ElMessage.info('没有需要保存的变更')
return
}
saveLoading.value = true
try {
for (const item of unsaved) {
await labelResult(item.result_id, {
category_id: item._category_id!,
level_id: item._level_id!,
})
item._unsaved = false
}
ElMessage.success(`成功保存 ${unsaved.length} 条记录`)
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saveLoading.value = false
}
}
async function handleCompleteTask() {
if (unsavedCount.value > 0) {
try {
await ElMessageBox.confirm('还有未保存的变更,是否先保存后再完成任务?', '提示')
await handleBatchSave()
} catch (e) {
if (e === 'cancel') return
}
}
completeLoading.value = true
try {
if (currentTask.value) {
await completeTask(currentTask.value.id)
ElMessage.success('任务已完成')
labelDialogVisible.value = false
fetchData()
}
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
} finally {
completeLoading.value = false
}
}
function confidenceClass(v: number) {
if (v >= 0.8) return 'confidence-high'
if (v >= 0.5) return 'confidence-mid'
return 'confidence-low'
}
// TaskTable sub-component
const TaskTable = {
props: ['tasks'],
emits: ['refresh'],
emits: ['refresh', 'label'],
setup(props: any, { emit }: any) {
return () =>
h(
@@ -182,16 +276,17 @@ const TaskTable = {
{
default: () => [
h('el-table-column', { prop: 'name', label: '任务名称', minWidth: '180' }),
h('el-table-column', { prop: 'project_name', label: '所属项目', minWidth: '140' }),
h('el-table-column', { prop: 'status', label: '状态', width: '100' }, {
default: ({ row }: any) =>
h('el-tag', { size: 'small', type: row.status === 'pending' ? 'warning' : row.status === 'completed' ? 'success' : 'primary' },
h(ElTag, { size: 'small', type: row.status === 'pending' ? 'warning' : row.status === 'completed' ? 'success' : 'primary' },
row.status === 'pending' ? '待处理' : row.status === 'in_progress' ? '进行中' : '已完成'
),
}),
h('el-table-column', { prop: 'deadline', label: '截止时间', width: '160' }),
h('el-table-column', { label: '操作', width: '120', fixed: 'right' }, {
default: ({ row }: any) =>
h(ElButton, { type: 'primary', link: true, size: 'small', onClick: () => openLabel(row) }, () => '去打标'),
h(ElButton, { type: 'primary', link: true, size: 'small', onClick: () => emit('label', row) }, () => '去打标'),
}),
],
}
@@ -225,15 +320,28 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 12px;
.label-stats {
display: flex;
align-items: center;
gap: 12px;
}
}
.empty-text {
color: #c0c4cc;
}
.inline-select {
:deep(.el-input__wrapper) {
padding: 0 4px;
.confidence-high { color: #67c23a; font-weight: 600; }
.confidence-mid { color: #e6a23c; font-weight: 600; }
.confidence-low { color: #f56c6c; font-weight: 600; }
:deep(.label-dialog) {
.el-dialog__body {
padding-top: 10px;
padding-bottom: 10px;
}
}
</style>