Commit 8e389145 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 分页查询 orchestrator + 中断按钮 + 进度 + appendMode 行追加

把单次同步 runQuery 替换成分页 + 并发 worker 模式:

DataQualityView.vue 重写查询逻辑:
  onQuery()
    1) POST /start → COUNT + 编译规则 + 拿 session_id + total_pages
    2) PAGE_CONCURRENCY=3 个 worker 并发抢 queue([1..total_pages]),
       每个 worker /page 拉到 → appendPage() 按页号升序拼进 rows
    3) 收尾聚合 lastResult + toast

  onCancel()
    - /cancel 摘 session(best-effort)
    - AbortController.abort() 让在飞的 fetch 立即 reject
    - resetQueryStatus() 清空 sessionId / rows / lastResult / queryError

  进度展示:task-info-bar 顶部「查询中 N/M 页 · 命中 K 条」
  onBeforeUnmount:cancel + abort,避免组件销毁后 fetch 还在飞

ResultTable.vue 新增 appendMode prop:
  watch(() => props.rows.length, () => {
    if (!props.appendMode) currentPage.value = 1
  })
  默认 false 保持原行为(rows 替换 → 回第 1 页);
  append 模式下 rows 增量追加不重置页码

ResultTable 新增 row-key 模式:父组件传 rowKey 函数,
用后端注入的 __row_index 做稳定 key(append 模式下保证 Vue DOM 复用正确)

PAGE_SIZE = 500(与后端默认对齐)

无单测(前端无测试基础设施,手动 e2e 验证见 work-logs/2026-08-21.md)
parent 5fc62e5b
...@@ -143,6 +143,9 @@ const props = defineProps({ ...@@ -143,6 +143,9 @@ const props = defineProps({
defaultPageSize: { type: Number, default: 5 }, defaultPageSize: { type: Number, default: 5 },
// 行 key:默认用 index;父组件可传 (row, i) => string 走更稳定的 key(如 row.id) // 行 key:默认用 index;父组件可传 (row, i) => string 走更稳定的 key(如 row.id)
rowKey: { type: Function, default: (_row, i) => i }, rowKey: { type: Function, default: (_row, i) => i },
// 分页 append 模式(2026-08-21):默认 false(rows 整体替换 → length 变化回到第 1 页)
// 设为 true 时 rows.length 变化不重置页码 —— 用于「分页拉取 + 增量追加」场景
appendMode: { type: Boolean, default: false },
}) })
const emit = defineEmits(['page-change']) const emit = defineEmits(['page-change'])
...@@ -171,7 +174,10 @@ const rangeStart = computed(() => props.rows.length === 0 ? 0 : (currentPage.val ...@@ -171,7 +174,10 @@ const rangeStart = computed(() => props.rows.length === 0 ? 0 : (currentPage.val
const rangeEnd = computed(() => Math.min(currentPage.value * pageSize.value, props.rows.length)) const rangeEnd = computed(() => Math.min(currentPage.value * pageSize.value, props.rows.length))
// rows 长度变化 → 回到第 1 页(避免翻页中途被切走,停在空页) // rows 长度变化 → 回到第 1 页(避免翻页中途被切走,停在空页)
watch(() => props.rows.length, () => { currentPage.value = 1 }) // 2026-08-21:append 模式下不重置 —— 新页到达时用户已经看过的页别被强制拉回首屏
watch(() => props.rows.length, () => {
if (!props.appendMode) currentPage.value = 1
})
function onPageSizeChange() { function onPageSizeChange() {
currentPage.value = 1 currentPage.value = 1
......
<!--
数据查询 / 校验页(分页版,2026-08-21 重构)
改动要点:
- 改用「分页拉取 + 并发 worker + 进度展示 + 中断按钮」
- 后端走 POST /api/queries/{start,page,cancel} 三端点(详见 routers/queries.py)
- 前端并发数 PAGE_CONCURRENCY = 3(页面内并发 3 个 /page 请求),避免 DB 端被压垮
- AbortController.signal 同时干两件事:
1. fetch 主动 reject(节省带宽)
2. 后端 /cancel 摘掉 session(后续 /page 立刻 404 短路)
- resultFields 在 /start 时一次返回,Orchestrator 不再多次获取
- row-key 用后端注入的 __row_index,append 模式下保证 Vue DOM 复用正确
-->
<template> <template>
<!-- 查询区 --> <!-- 查询区 -->
<div class="q-card" style="padding: 14px 16px;"> <div class="q-card" style="padding: 14px 16px;">
...@@ -18,6 +31,13 @@ ...@@ -18,6 +31,13 @@
:disabled="!selectedTask || runningQuery" :disabled="!selectedTask || runningQuery"
@click="onQuery" @click="onQuery"
>{{ runningQuery ? '查询中…' : '查询' }}</button> >{{ runningQuery ? '查询中…' : '查询' }}</button>
<!-- 中断按钮:仅查询中可见 -->
<button
v-if="runningQuery"
class="btn btn-danger"
:disabled="cancelling"
@click="onCancel"
>{{ cancelling ? '中断中…' : '中断' }}</button>
</div> </div>
</div> </div>
</div> </div>
...@@ -25,12 +45,14 @@ ...@@ -25,12 +45,14 @@
<div class="task-info-bar" v-if="selectedTask"> <div class="task-info-bar" v-if="selectedTask">
<el-icon><InfoFilled /></el-icon> <el-icon><InfoFilled /></el-icon>
<span>当前任务:<b>{{ selectedTask }}</b></span> <span>当前任务:<b>{{ selectedTask }}</b></span>
<span class="meta" v-if="lastResult"> <span class="meta" v-if="runningQuery">
查询中
<b>{{ scannedPages }}</b> / <b>{{ totalPages }}</b> 页
· 命中不合规 <b style="color:#f56c6c;">{{ rows.length }}</b> 条
</span>
<span class="meta" v-else-if="lastResult">
全表扫描 <b>{{ lastResult.scanned }}</b> 条 全表扫描 <b>{{ lastResult.scanned }}</b> 条
· 命中不合规 <b style="color:#f56c6c;">{{ lastResult.bad_rows }}</b> 条 · 命中不合规 <b style="color:#f56c6c;">{{ lastResult.bad_rows }}</b> 条
<template v-if="lastResult.truncated">
· 表内仅展示前 <b>{{ lastResult.rows.length }}</b> 条
</template>
</span> </span>
<span class="meta" v-else style="color:#909399;">尚未查询</span> <span class="meta" v-else style="color:#909399;">尚未查询</span>
</div> </div>
...@@ -61,6 +83,8 @@ ...@@ -61,6 +83,8 @@
:rows="rows" :rows="rows"
:page-sizes="[5, 10, 20, 50]" :page-sizes="[5, 10, 20, 50]"
:default-page-size="5" :default-page-size="5"
:append-mode="true"
:row-key="rowKeyFn"
@page-change="onPageChange" @page-change="onPageChange"
/> />
</div> </div>
...@@ -75,12 +99,12 @@ ...@@ -75,12 +99,12 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch, onMounted } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { fieldsByType, violationRows } from '@/data/mockData' // violationRows 暂留:mock 表头;等真接通后用 resultFields import { fieldsByType, violationRows } from '@/data/mockData' // violationRows 暂留:mock 表头;等真接通后用 resultFields
import { listTasks } from '@/api/tasks' import { listTasks } from '@/api/tasks'
import { runQuery } from '@/api/queries' import { startQuery, fetchPage, cancelQuery } from '@/api/queries'
import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue' import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue'
import ResultTable from '@/components/ResultTable.vue' import ResultTable from '@/components/ResultTable.vue'
...@@ -138,62 +162,159 @@ onMounted(loadTasksForCurrentType) ...@@ -138,62 +162,159 @@ onMounted(loadTasksForCurrentType)
const visibleFields = ref(fields.value.map((f) => f.key)) const visibleFields = ref(fields.value.map((f) => f.key))
watch(fields, (fs) => { visibleFields.value = fs.map((f) => f.key) }) watch(fields, (fs) => { visibleFields.value = fs.map((f) => f.key) })
// ── 查询 / 校验 ── // ── 分页查询 orchestrator(2026-08-21 重构) ──
const PAGE_CONCURRENCY = 3 // 并发 /page 数;> 3 可能压垮 DB
const PAGE_SIZE = 500 // 每页行数,与后端默认对齐
const runningQuery = ref(false) const runningQuery = ref(false)
const lastResult = ref(null) // 最近一次 runQuery 返回(行 + 计数) const cancelling = ref(false)
const queryError = ref('') // 顶部错误横幅 const queryAbort = ref(null) // AbortController 实例
const rows = ref([]) // 喂给 ResultTable 的行 const sessionId = ref(null)
const resultFields = ref([]) // 查询后用真实 task.field_list 覆盖表格表头 const totalPages = ref(0)
const totalRows = ref(0)
const scannedPages = ref(0)
const scannedRows = ref(0)
const receivedPages = ref(new Map()) // page_no → bad_rows[](按页号缓存)
const lastResult = ref(null) // 最近一次查询结果(给顶部信息条用)
const queryError = ref('')
const rows = ref([]) // 喂给 ResultTable 的行
const resultFields = ref([])
// 行 key:用后端注入的全局行号(append 模式下保证 Vue DOM 复用正确)
const rowKeyFn = (row) => row.__row_index
// 把一页结果合并进 rows:按页号升序拼,保证索引稳定
function appendPage(page) {
receivedPages.value.set(page.page_no, page.bad_rows)
const ordered = [...receivedPages.value.keys()].sort((a, b) => a - b)
rows.value = ordered.flatMap((n) => receivedPages.value.get(n) || [])
scannedPages.value = receivedPages.value.size
scannedRows.value += page.scanned_delta
}
function resetQueryStatus() {
sessionId.value = null
totalPages.value = 0
totalRows.value = 0
scannedPages.value = 0
scannedRows.value = 0
receivedPages.value = new Map()
rows.value = []
lastResult.value = null
queryError.value = ''
}
async function abortInFlight() {
// 取消上一轮还在飞的查询(用户连续点 / 切换任务时)
if (sessionId.value) {
try { await cancelQuery(sessionId.value) } catch (_) { /* best-effort */ }
}
queryAbort.value?.abort()
queryAbort.value = null
}
async function onQuery() { async function onQuery() {
// 找到选中的 task.id
const t = taskOptions.value.find((x) => x.name === selectedTask.value) const t = taskOptions.value.find((x) => x.name === selectedTask.value)
if (!t) { if (!t) {
ElMessage.warning('请选择有效任务') ElMessage.warning('请选择有效任务')
return return
} }
await abortInFlight()
resetQueryStatus()
const ac = new AbortController()
queryAbort.value = ac
runningQuery.value = true runningQuery.value = true
queryError.value = ''
try { try {
const r = await runQuery(t.id) // 1) /start:COUNT + 编译规则 + 建 session
if (!r.ok) { const start = await startQuery(t.id, { pageSize: PAGE_SIZE, signal: ac.signal })
// 后端明确返回失败:清空表 + 顶部横幅 + toast if (!start.ok) {
queryError.value = r.message || '查询失败' queryError.value = start.message || '查询失败'
ElMessage.error(queryError.value) ElMessage.error(queryError.value)
rows.value = []
lastResult.value = null
return return
} }
// 成功:行 + 表头 sessionId.value = start.session_id
rows.value = (r.rows || []).map((row) => ({ ...row, checked: false })) totalPages.value = start.total_pages
resultFields.value = (r.field_list || []).map((f) => ({ totalRows.value = start.total_rows
resultFields.value = (start.field_list || []).map((f) => ({
key: f.field_key, key: f.field_key,
cn: f.field_key, // 后端没存中文名,先用英文 key 占位 cn: f.field_key,
en: '', en: '',
comment: f.field_comment || '', // 来自数据源 COLUMN_COMMENT → 表头 tooltip comment: f.field_comment || '',
rules: f.rules || 0, // 数字 → 表头显示「⚠ N规则」 rules: f.rules || 0,
ruleList: f.rule_list || [], // 规则明细(含 desc)→ 表头「⚠ N规则」hover 出规则列表 ruleList: f.rule_list || [],
showDefault: f.show_default !== false, // 任务配置「默认展示」未勾选 → 结果表不展示该列 showDefault: f.show_default !== false,
})) }))
lastResult.value = r
if (r.truncated) { if (start.total_pages === 0) {
// 全表扫描的计数是准的,但表里只装得下前 max_rows 条 → 顶部常驻提示 // COUNT=0:表里没数据
queryError.value = r.message lastResult.value = { scanned: 0, bad_rows: 0, rows: [] }
ElMessage.warning(r.message)
} else if (r.scanned === 0) {
ElMessage.warning('该表无数据(0 行)') ElMessage.warning('该表无数据(0 行)')
} else if (r.bad_rows === 0) { return
ElMessage.success(`全表扫描 ${r.scanned} 条,全部合规`) }
// 2) 并发 worker 拉所有页(PAGE_CONCURRENCY 并发)
const queue = Array.from({ length: start.total_pages }, (_, i) => i + 1)
const workers = Array.from(
{ length: Math.min(PAGE_CONCURRENCY, queue.length) },
() => worker(queue, ac.signal),
)
await Promise.all(workers)
if (ac.signal.aborted) return // 中断流程不弹成功 toast
// 3) 收尾:聚合总数 + toast
const totalBad = rows.value.length
lastResult.value = {
scanned: totalRows.value,
bad_rows: totalBad,
rows: rows.value,
}
if (totalBad === 0) {
ElMessage.success(`全表扫描 ${totalRows.value} 条,全部合规`)
} else { } else {
ElMessage.success(`全表扫描 ${r.scanned} 条,命中 ${r.bad_rows} 条不合规`) ElMessage.success(`全表扫描 ${totalRows.value} 条,命中 ${totalBad} 条不合规`)
} }
} catch (e) { } catch (e) {
queryError.value = `请求失败:${e.message}` if (e.name === 'AbortError' || ac.signal.aborted) return
queryError.value = `请求失败:${e.message || e}`
ElMessage.error(queryError.value) ElMessage.error(queryError.value)
rows.value = []
lastResult.value = null
} finally { } finally {
runningQuery.value = false runningQuery.value = false
queryAbort.value = null
}
// worker:从 queue 拿一个页号去拉,拉到就追加
async function worker(q, signal) {
while (q.length && !signal.aborted) {
const pageNo = q.shift()
try {
const page = await fetchPage(sessionId.value, pageNo, { signal })
appendPage(page)
} catch (e) {
if (e.name === 'AbortError') return
// 单页拉失败:记错误但不让整个查询崩;让其它页继续拉
queryError.value = `page=${pageNo} 拉取失败:${e.message || e}`
console.warn(queryError.value)
}
}
}
}
async function onCancel() {
if (!sessionId.value) return
cancelling.value = true
try {
try { await cancelQuery(sessionId.value) } catch (_) { /* best-effort */ }
queryAbort.value?.abort() // 让在飞的 fetch 立即 reject
resetQueryStatus()
ElMessage.info(`已中断(已扫 ${scannedPages.value}/${totalPages.value} 页)`)
} finally {
runningQuery.value = false
cancelling.value = false
queryAbort.value = null
} }
} }
...@@ -234,6 +355,14 @@ function onSaveFields(list) { ...@@ -234,6 +355,14 @@ function onSaveFields(list) {
ElMessage.success('已保存展示字段') ElMessage.success('已保存展示字段')
} }
const fieldDrawerOpen = ref(false) const fieldDrawerOpen = ref(false)
// 组件卸载:清理可能还在飞的请求 + 摘掉 session
onBeforeUnmount(() => {
if (sessionId.value) {
cancelQuery(sessionId.value).catch(() => {})
}
queryAbort.value?.abort()
})
</script> </script>
<style scoped> <style scoped>
...@@ -249,4 +378,4 @@ const fieldDrawerOpen = ref(false) ...@@ -249,4 +378,4 @@ const fieldDrawerOpen = ref(false)
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
} }
</style> </style>
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment