Commit f3a3ab93 authored by Data Governance Dev's avatar Data Governance Dev

fix(web3): in-flight 时禁止切换分组

用户反馈:邮箱分组跑完后切别的分组能看到老结果 → 上一节已修;
再继续叠加"in-flight 期间切分组→ 切回来自动续跑"方案后又出新 bug
(resultFields 与 rows/lastResult 串数据),放弃了复杂的续跑逻辑。

简化方案:watch(type) 头部拦截 runningQuery=true,
弹 ElMessage.warning + router.replace 回原 type,不做切。

- typeReverting 旗子防 router.replace 触发的二次 watch 循环
- 跑完后的 cacheByType / applySnapshot 保留(切走再切回仍能看历史结果)
- continueQuery + takeSnapshot.inFlight 元数据暂留(未来如恢复续跑逻辑可复用)
- build: ✓ built in 6.81s
parent 25cb466d
...@@ -98,8 +98,8 @@ ...@@ -98,8 +98,8 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
// mockData 已不引用:表头改为 resultFields(真任务字段),violationRows 同样由 /start 返回 // mockData 已不引用:表头改为 resultFields(真任务字段),violationRows 同样由 /start 返回
import { listTasks } from '@/api/tasks' import { listTasks } from '@/api/tasks'
...@@ -109,6 +109,7 @@ import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue' ...@@ -109,6 +109,7 @@ import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue'
import ResultTable from '@/components/ResultTable.vue' import ResultTable from '@/components/ResultTable.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
// 当前数据类型(来自路由 param) // 当前数据类型(来自路由 param)
const type = computed(() => route.params.type || 'id-card') const type = computed(() => route.params.type || 'id-card')
...@@ -138,6 +139,9 @@ async function loadTasksForCurrentType() { ...@@ -138,6 +139,9 @@ async function loadTasksForCurrentType() {
selectedTask.value = '' selectedTask.value = ''
return return
} }
// 缓存命中时 taskOptions 已经被 applySnapshot 填好,跳过接口避免切回时 loading 闪烁
// 代价是「新增/删除任务」要等下次刷新页面才能反映给用户 —— 对数据治理工具可接受
if (taskOptions.value.length > 0) return
loadingTasks.value = true loadingTasks.value = true
try { try {
const list = await listTasks({ group: groupName }) const list = await listTasks({ group: groupName })
...@@ -155,8 +159,132 @@ async function loadTasksForCurrentType() { ...@@ -155,8 +159,132 @@ async function loadTasksForCurrentType() {
loadingTasks.value = false loadingTasks.value = false
} }
} }
watch(type, loadTasksForCurrentType, { immediate: true }) // onMounted 不再调 loadTasksForCurrentType —— 末尾 watch(type, { immediate: true }) 已包揽
onMounted(loadTasksForCurrentType)
// ── 分组快照缓存(2026-08-24)──
// 切走再切回时保留上次结果:
// - taskOptions / selectedTask / rows / resultFields / visibleFields / lastResult / fieldDrawerOpen
// - 不缓存 session / 进度(in-flight 跨分组不连续;用户切回后想拿新结果点「查询」即可)
// - 用普通 Map 而不是 ref,cache 不参与渲染,只在 watch 里读写
const cacheByType = new Map()
function takeSnapshot(typeKey) {
// lastResult 兜底:appendPage 已经每页都更新,但 takeSnapshot 可能在第一次
// appendPage 跑前调用(极小窗口:用户刚点查询就立刻切走)→ lastResult 还是 null。
// 给一个 partial 兜底,让切回时 top 信息条不显示「尚未查询」。
const partial = runningQuery.value
? { scanned: scannedRows.value, bad_rows: rows.value.length, rows: rows.value }
: null
return {
selectedTask: selectedTask.value,
taskOptions: taskOptions.value,
rows: rows.value,
resultFields: resultFields.value,
visibleFields: visibleFields.value,
lastResult: lastResult.value || partial,
fieldDrawerOpen: fieldDrawerOpen.value,
// 续跑所需:in-flight 状态下用,新 session 重新拉剩下的页
inFlight: runningQuery.value ? {
taskId: taskOptions.value.find((t) => t.name === selectedTask.value)?.id,
pageSize: PAGE_SIZE,
// Map → entries 数组(Map 不能 JSON 序列化;后续 new Map(entries) 恢复)
receivedPages: [...receivedPages.value.entries()],
scannedPages: scannedPages.value,
scannedRows: scannedRows.value,
totalPages: totalPages.value,
totalRows: totalRows.value,
} : null,
}
}
function applySnapshot(snap) {
// queryError 总是清:每个分组的「拉取失败」横幅是独立的,
// 不该跟着快照带到目标分组 —— 切分组后用户期待「干净的新分组」
queryError.value = ''
if (!snap) {
// 首次进入这个分组:全部置空
selectedTask.value = ''
taskOptions.value = []
rows.value = []
resultFields.value = []
visibleFields.value = []
lastResult.value = null
fieldDrawerOpen.value = false
return
}
// 切回来:恢复上次离开时的状态
selectedTask.value = snap.selectedTask
taskOptions.value = snap.taskOptions
rows.value = snap.rows
resultFields.value = snap.resultFields
visibleFields.value = snap.visibleFields
lastResult.value = snap.lastResult
fieldDrawerOpen.value = snap.fieldDrawerOpen
// 恢复 in-flight 进度(fire-and-forget 启动续跑)
if (snap.inFlight) {
receivedPages.value = new Map(snap.inFlight.receivedPages)
scannedPages.value = snap.inFlight.scannedPages
scannedRows.value = snap.inFlight.scannedRows
totalPages.value = snap.inFlight.totalPages
totalRows.value = snap.inFlight.totalRows
// 不 await —— applySnapshot 是同步语义,让 watch(type) 不等续跑完成
continueQuery(snap.inFlight)
}
}
// 续跑(fire-and-forget):切回时如果上次 in-flight,自动起新 session 拉剩下的页
// 为什么不复用老 sessionId:watch(type) 头部的 abortInFlight 已经 cancelQuery 老 session,
// 后端会拒后续 /page 请求。只能重新 startQuery 拿新 sessionId。
// 为什么不直接调 onQuery:onQuery 会 resetQueryStatus 清空 receivedPages/rows,
// 重复拉已经收到的页,浪费带宽(部分 task 总扫描数上万)。
// 这里保留 receivedPages(已收到的页)+ 启动新 worker 只拉剩下的页。
async function continueQuery(snap) {
// snap: { taskId, pageSize, receivedPages, scannedPages, scannedRows, totalPages, totalRows }
const ac = new AbortController()
queryAbort.value = ac
runningQuery.value = true
try {
const start = await startQuery(snap.taskId, { pageSize: snap.pageSize, signal: ac.signal })
if (!start.ok) {
queryError.value = start.message || '续跑失败'
return
}
sessionId.value = start.session_id
totalPages.value = start.total_pages
totalRows.value = start.total_rows
// 已收到的页就不重拉了
const received = new Set(receivedPages.value.keys())
const queue = Array.from({ length: start.total_pages }, (_, i) => i + 1)
.filter((p) => !received.has(p))
if (queue.length === 0) {
finalize()
return
}
const workers = Array.from(
{ length: Math.min(PAGE_CONCURRENCY, queue.length) },
() => worker(queue, ac.signal),
)
await Promise.all(workers)
if (ac.signal.aborted) return
finalize()
} catch (e) {
if (e.name === 'AbortError' || ac.signal.aborted) return
queryError.value = `续跑失败:${e.message || e}`
} finally {
runningQuery.value = false
queryAbort.value = null
}
function finalize() {
const totalBad = rows.value.length
lastResult.value = {
scanned: totalRows.value,
bad_rows: totalBad,
rows: rows.value,
}
}
}
// ── 分页查询 orchestrator(2026-08-21 重构) ── // ── 分页查询 orchestrator(2026-08-21 重构) ──
const PAGE_CONCURRENCY = 3 // 并发 /page 数;> 3 可能压垮 DB const PAGE_CONCURRENCY = 3 // 并发 /page 数;> 3 可能压垮 DB
...@@ -182,27 +310,36 @@ const resultFields = ref([]) ...@@ -182,27 +310,36 @@ const resultFields = ref([])
// —— 必须在 resultFields 声明之后定义,否则 TDZ 报错(computed/watch 会立即执行) // —— 必须在 resultFields 声明之后定义,否则 TDZ 报错(computed/watch 会立即执行)
const fields = computed(() => resultFields.value) const fields = computed(() => resultFields.value)
const visibleFields = ref([]) const visibleFields = ref([])
watch(
resultFields, // 按 resultFields 当前内容,重置 visibleFields 为 showDefault=true 的列
(rf) => { // 2026-08-24:原来是 watch(resultFields) 自动触发,但切换分组恢复快照时
// 任务切换 / 新查询完成时,按任务的 show_default 默认勾选 // applySnapshot 把 resultFields.value 设回旧值,watch 会在 microtask 阶段把
visibleFields.value = (rf || []) // visibleFields 重置成「showDefault 过滤」,覆盖用户保存的选择。
// 改成显式调用:onQuery 拉完字段后调一次;applySnapshot 不调。
function resetVisibleFields() {
visibleFields.value = (resultFields.value || [])
.filter((f) => f.showDefault !== false) .filter((f) => f.showDefault !== false)
.map((f) => f.key) .map((f) => f.key)
}, }
{ immediate: true }, resetVisibleFields() // 首次:resultFields 还是 [],得到 []
)
// 行 key:用后端注入的全局行号(append 模式下保证 Vue DOM 复用正确) // 行 key:用后端注入的全局行号(append 模式下保证 Vue DOM 复用正确)
const rowKeyFn = (row) => row.__row_index const rowKeyFn = (row) => row.__row_index
// 把一页结果合并进 rows:按页号升序拼,保证索引稳定 // 把一页结果合并进 rows:按页号升序拼,保证索引稳定
// 顺便实时更新 lastResult(in-flight 期间顶部信息条也能看到「已扫 X / 已命中 Y」进度)
// 收尾时 onQuery / continueQuery 会用 totalRows 覆盖,scannedRows 字段不参与最终展示
function appendPage(page) { function appendPage(page) {
receivedPages.value.set(page.page_no, page.bad_rows) receivedPages.value.set(page.page_no, page.bad_rows)
const ordered = [...receivedPages.value.keys()].sort((a, b) => a - b) const ordered = [...receivedPages.value.keys()].sort((a, b) => a - b)
rows.value = ordered.flatMap((n) => receivedPages.value.get(n) || []) rows.value = ordered.flatMap((n) => receivedPages.value.get(n) || [])
scannedPages.value = receivedPages.value.size scannedPages.value = receivedPages.value.size
scannedRows.value += page.scanned_delta scannedRows.value += page.scanned_delta
lastResult.value = {
scanned: scannedRows.value,
bad_rows: rows.value.length,
rows: rows.value,
}
} }
function resetQueryStatus() { function resetQueryStatus() {
...@@ -228,6 +365,28 @@ function clearProgressOnly() { ...@@ -228,6 +365,28 @@ function clearProgressOnly() {
receivedPages.value = new Map() receivedPages.value = new Map()
} }
// worker:模块级函数(onQuery / continueQuery 都用得到)
// 从 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) {
// abort 三种识别:
// 1) e.name === 'AbortError' —— fetch 自己识别的 abort(最常见)
// 2) signal.aborted === true —— abortInFlight 已发,但 fetch 已经发出请求
// 后端拿到取消通知返回 404/410 等非 AbortError 错误,catch 里再兜一次
// 3) fetch throw TypeError + signal.aborted —— 部分浏览器实现
if (e.name === 'AbortError' || signal.aborted) return
// 单页拉失败:记错误但不让整个查询崩;让其它页继续拉
queryError.value = `page=${pageNo} 拉取失败:${e.message || e}`
console.warn(queryError.value)
}
}
}
async function abortInFlight() { async function abortInFlight() {
// 取消上一轮还在飞的查询(用户连续点 / 切换任务时) // 取消上一轮还在飞的查询(用户连续点 / 切换任务时)
if (sessionId.value) { if (sessionId.value) {
...@@ -275,6 +434,7 @@ async function onQuery() { ...@@ -275,6 +434,7 @@ async function onQuery() {
ruleList: f.rule_list || [], ruleList: f.rule_list || [],
showDefault: f.show_default !== false, showDefault: f.show_default !== false,
})) }))
resetVisibleFields() // 按新字段的 show_default 重置可见列
if (start.total_pages === 0) { if (start.total_pages === 0) {
// COUNT=0:表里没数据 // COUNT=0:表里没数据
...@@ -313,22 +473,6 @@ async function onQuery() { ...@@ -313,22 +473,6 @@ async function onQuery() {
runningQuery.value = false runningQuery.value = false
queryAbort.value = null 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() { async function onCancel() {
...@@ -422,6 +566,45 @@ function onSaveFields(list) { ...@@ -422,6 +566,45 @@ function onSaveFields(list) {
} }
const fieldDrawerOpen = ref(false) const fieldDrawerOpen = ref(false)
// 切换分组(左侧菜单):
// 0. 如果有 in-flight → 阻止切换 + 弹警告 + router.replace 回老 type(让用户先中断或等待)
// 1. 取消老 in-flight(兜底:用户没 in-flight 时也走一遍)
// 2. 把当前分组(oldType)的状态存进 cacheByType
// 3. 把目标分组(newType)的快照恢复出来;没有就置空
// 4. 加载 taskOptions(cache 命中时 loadTasksForCurrentType 会跳过接口)
// 2026-08-24 用户反馈 3:续跑逻辑跑出新 bug(rows 字段全 NULL + 备注列窜到别的 task)
// —— 用户决定走简化方案:in-flight 时直接禁止切分组,不再走 cacheByType 续跑
// 路线。原因是续跑要处理 schema 同步、sessionId 重拉、receivedPages 跨分组复用
// 等多个边界,继续叠加修复容易引入新 bug。
// 业务场景下用户完全可以接受:要么等跑完再切,要么主动点「中断」。
// typeReverting:router.replace 改 route 后 watch 会再触发一次(newType=oldType 短暂回到
// 原值),这个标志位让二次触发直接跳过,避免循环 router.replace
// 注意:放这里是因为 watch 内部引用了 abortInFlight / resultFields / visibleFields /
// fieldDrawerOpen —— 这些都得在 watch 注册前声明完,避免 TDZ
const typeReverting = ref(false)
watch(type, async (newType, oldType) => {
// router.replace 回退触发的二次 watch 跳过
if (typeReverting.value) {
typeReverting.value = false
return
}
// in-flight 时禁止切分组(避免续跑 + cacheByType 引入的复杂 bug)
if (runningQuery.value) {
ElMessage.warning(`任务「${selectedTask.value || '当前任务'}」正在执行中,请先中断或等待完成`)
typeReverting.value = true
// router.replace 后 route.params.type 回到 oldType → watch(type) 再触发一次
// → 二次触发被 typeReverting=true 短路掉,不会再走 router.replace(避免循环)
router.replace({ name: route.name, params: { ...route.params, type: oldType } })
return
}
await abortInFlight() // 1. 取消老 in-flight(兜底)
if (oldType) {
cacheByType.set(oldType, takeSnapshot(oldType))
}
applySnapshot(cacheByType.get(newType))
await loadTasksForCurrentType()
}, { immediate: true })
// 组件卸载:清理可能还在飞的请求 + 摘掉 session // 组件卸载:清理可能还在飞的请求 + 摘掉 session
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (sessionId.value) { if (sessionId.value) {
......
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