From f4c405d51a98fab18f7d119651f13f132805ee9d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 13:48:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker):=20=E6=89=93=E6=89=8B=E7=AB=AF?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E8=A1=A5=E4=B8=81=E4=B8=8D=E5=86=8D=E8=AF=AF?= =?UTF-8?q?=E5=88=A0"=E6=8C=89=E5=B7=A5=E5=8D=95=E5=8F=B7=E6=90=9C?= =?UTF-8?q?=E5=88=B0=E7=9A=84=E8=A1=8C"=EF=BC=8C=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=88=86=E9=A1=B5=E6=80=BB=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这是后台那个"搜工单号搜不到、必须刷新整页"在打手端的同源副本: WorkerLayout 的 operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' 只要前端判"不匹配"就删行,而前端关键词匹配器**没有比对工单号**—— 后端搜索表达式的第一项恰恰就是 work_order_no(还有 material_json 里更多前端不知道的字段)。 于是按工单号搜索时,该单一旦有实时变更,行就被从缓存删掉、列表变空。 修法与后台侧对齐: 1. 抽出 utils/worker-realtime-work-orders.ts(可单测、无运行时依赖),关键词匹配补上 workOrderNo,并改成三态判定——状态/分类等可证伪维度不符才删行;关键词能找到=match (允许插入),找不到=unknown(不删不插,只就地更新)。 2. patchWorkerWorkOrderListEnvelope 增删行时同步 data.pagination.total, 修掉"列表空了、分页器还显示有 N 条"。 3. 大厅无限下拉只在第 1 页允许插入,其余页只就地更新。 验证:前端 86 项单测全绿(新增 6 项,覆盖"按工单号搜索+实时变更不得删行"、 unknown 只就地更新、删/插同步总数、无限下拉页边界)。 --- apps/frontend/src/layouts/WorkerLayout.tsx | 78 +++------- .../src/utils/worker-realtime-work-orders.ts | 134 ++++++++++++++++++ .../tests/worker-realtime-work-orders.test.ts | 118 +++++++++++++++ 3 files changed, 271 insertions(+), 59 deletions(-) create mode 100644 apps/frontend/src/utils/worker-realtime-work-orders.ts create mode 100644 apps/frontend/tests/worker-realtime-work-orders.test.ts diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 20b9aa4c..a5f8fdbf 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -14,6 +14,11 @@ import { Outlet, useLocation, useNavigate } from 'react-router' import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker' import { useIsMobile } from '@/utils/use-is-mobile' +import { + evaluateWorkerKeywordVerdict, + patchWorkerWorkOrderInfiniteEnvelope, + patchWorkerWorkOrderListEnvelope, +} from '@/utils/worker-realtime-work-orders' import { clearWorkerSession, expireWorkerSession, @@ -337,14 +342,16 @@ function patchWorkerMyOrderCaches( productName?: unknown platformOrderId?: unknown } - const matchesStatus = !status || status === String(item.status || '') - const matchesKeyword = matchesWorkOrderKeyword(item, keyword) - const matches = matchesStatus && matchesKeyword - const shouldInsert = event.operation === 'upsert' && page === 1 && matches - const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' + // 状态可证伪:不符才允许删行;关键词不可证伪(后端搜的字段比前端多,含工单号), + // 找不到时只判 unknown —— 不删也不插,就地更新,避免"按工单号搜索时行被删空"。 + const statusMatches = !status || status === String(item.status || '') + const keywordVerdict = evaluateWorkerKeywordVerdict(item, keyword) + const shouldInsert = + event.operation === 'upsert' && page === 1 && statusMatches && keywordVerdict === 'match' + const operation = event.operation === 'remove' || !statusMatches ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, - patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert), + patchWorkerWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert), ) } } @@ -406,15 +413,14 @@ function applyWorkerHallCachePatch( const keyword = String(queryKey[1] || '') const categoryId = Number(queryKey[2] || 0) const matchesCategory = !categoryId || categoryId === Number(item.categoryId || 0) - const matches = - String(item.status || '') === 'open' && - matchesCategory && - matchesWorkOrderKeyword(item, keyword) - const shouldInsert = event.operation === 'upsert' && matches - const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' + // 大厅是"必须是 open"且分类可证伪:不符就该移出大厅;关键词仍按不可证真处理。 + const provableMatch = String(item.status || '') === 'open' && matchesCategory + const keywordVerdict = evaluateWorkerKeywordVerdict(item, keyword) + const shouldInsert = event.operation === 'upsert' && provableMatch && keywordVerdict === 'match' + const operation = event.operation === 'remove' || !provableMatch ? 'remove' : 'upsert' queryClient.setQueryData( queryKey, - patchInfiniteWorkOrderEnvelope( + patchWorkerWorkOrderInfiniteEnvelope( current, { ...event, data: workerSnapshot, operation }, shouldInsert, @@ -476,52 +482,6 @@ function applyWorkerDepositRule(queryClient: ReturnType, } } -function matchesWorkOrderKeyword( - item: { productName?: unknown; platformOrderId?: unknown; material?: unknown }, - keyword: string, -) { - const normalizedKeyword = keyword.trim().toLowerCase() - if (!normalizedKeyword) return true - const material = isRecord(item.material) ? item.material : {} - const collect = isRecord(material.collect) ? material.collect : {} - const fields = isRecord(collect.fields) ? collect.fields : {} - return [ - item.productName, - item.platformOrderId, - fields.gameId, - fields.gameNickname, - fields.gameAccount, - fields.roleName, - material.gameId, - material.gameNickname, - ] - .map((value) => String(value || '').toLowerCase()) - .some((value) => value.includes(normalizedKeyword)) -} - -function patchWorkOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) { - if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { - return current - } - const id = Number((event.data as { workOrderId?: unknown }).workOrderId || event.entityId) - const items = current.data.items as unknown[] - const nextItems = - event.operation === 'remove' - ? items.filter((entry) => Number((entry as { workOrderId?: unknown })?.workOrderId) !== id) - : upsertById(items, event.data, 'workOrderId', insert) - return { ...current, data: { ...current.data, items: nextItems } } -} - -function patchInfiniteWorkOrderEnvelope(current: unknown, event: RealtimeEvent, insert = false) { - if (!isRecord(current) || !Array.isArray(current.pages)) return current - return { - ...current, - pages: current.pages.map((page: unknown, index) => - patchWorkOrderListEnvelope(page, event, insert && index === 0), - ), - } -} - function patchFinanceRequestListEnvelope(current: unknown, event: RealtimeEvent) { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts new file mode 100644 index 00000000..4a55e52c --- /dev/null +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -0,0 +1,134 @@ +/** + * 打手端列表的实时快照补丁(与后台的 admin-realtime-work-orders.ts 同源同策)。 + * + * 这里修的是和后台"搜工单号搜不到、必须整页刷新"完全同一个坑: + * 旧逻辑 `operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert'` + * 只要前端认为"不匹配"就把行从缓存里删掉,而**前端的关键词匹配天生不完整**—— + * 后端查询串的第一项是工单号(work_order_no,见 work-order-query-repo 的搜索表达式), + * 前端匹配器里却没有它;而且后端还会搜 material_json 里更多字段。 + * 于是"按工单号搜索 → 该单有实时变更 → 行被删 → 列表变空",只能刷新整页恢复。 + * + * 所以判定改成三态: + * - 状态 / 分类等**可证伪**的维度不符 → mismatch,才允许删行; + * - 关键词能找到 → match,允许插入到第 1 页顶部; + * - 有关键词但在前端已知字段里找不到 → unknown,**既不移除也不插入,只就地更新**。 + * + * 另外补上分页总数同步:旧实现只改 items,删行后会出现"列表空了、分页器还显示有 N 条"。 + */ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function toNumber(value: unknown): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +type WorkerListEvent = { + operation?: unknown + entityId?: unknown + data?: unknown +} + +/** 前端能自己核对的关键词字段(后端搜索范围更广,所以不一致时只判 unknown)。 */ +function collectKeywordFieldValues(item: Record): unknown[] { + const material = isRecord(item.material) ? item.material : {} + const collect = isRecord(material.collect) ? material.collect : {} + const fields = isRecord(collect.fields) ? collect.fields : {} + return [ + // 关键补充:工单号。后端搜索表达式的第一项就是它,缺了它"按工单号搜索"必然误判。 + item.workOrderNo, + item.productName, + item.platformOrderId, + fields.gameId, + fields.gameNickname, + fields.gameAccount, + fields.roleName, + material.gameId, + material.gameNickname, + ] +} + +export function matchesWorkerWorkOrderKeyword(item: unknown, keyword: string): boolean { + const normalizedKeyword = String(keyword || '') + .trim() + .toLowerCase() + if (!normalizedKeyword) return true + if (!isRecord(item)) return false + return collectKeywordFieldValues(item) + .map((value) => String(value ?? '').toLowerCase()) + .some((value) => value.includes(normalizedKeyword)) +} + +/** 关键词维度的三态判定:能找到= match;有关键词但找不到= unknown(不可证伪)。 */ +export function evaluateWorkerKeywordVerdict(item: unknown, keyword: string): 'match' | 'unknown' { + return matchesWorkerWorkOrderKeyword(item, keyword) ? 'match' : 'unknown' +} + +/** 按工单号原地增删行,并同步分页总数(结构同后端 `{ data: { items, pagination } }`)。 */ +export function patchWorkerWorkOrderListEnvelope( + current: unknown, + event: WorkerListEvent, + insert = false, +): unknown { + if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { + return current + } + const items = current.data.items as unknown[] + const data = isRecord(event.data) ? event.data : {} + const id = toNumber(data.workOrderId) || toNumber(event.entityId) + if (id <= 0) return current + + let nextItems: unknown[] + if (event.operation === 'remove') { + nextItems = items.filter( + (entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) !== id, + ) + } else { + const index = items.findIndex( + (entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id, + ) + if (index >= 0) { + nextItems = items.map((entry, itemIndex) => (itemIndex === index ? event.data : entry)) + } else if (insert) { + nextItems = [event.data, ...items] + } else { + return current + } + } + + const delta = nextItems.length - items.length + const pagination = isRecord(current.data.pagination) ? current.data.pagination : null + return { + ...current, + data: { + ...current.data, + items: nextItems, + // 删行/插行都要同步总数,否则出现"列表空着、分页器还有 N 条" + ...(pagination && delta !== 0 + ? { + pagination: { + ...pagination, + total: Math.max(0, toNumber(pagination.total) + delta), + }, + } + : {}), + }, + } +} + +/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新。 */ +export function patchWorkerWorkOrderInfiniteEnvelope( + current: unknown, + event: WorkerListEvent, + insert = false, +): unknown { + if (!isRecord(current) || !Array.isArray(current.pages)) return current + return { + ...current, + pages: current.pages.map((page: unknown, index: number) => + patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0), + ), + } +} diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts new file mode 100644 index 00000000..7447f3a3 --- /dev/null +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + evaluateWorkerKeywordVerdict, + matchesWorkerWorkOrderKeyword, + patchWorkerWorkOrderInfiniteEnvelope, + patchWorkerWorkOrderListEnvelope, +} from '../src/utils/worker-realtime-work-orders.ts' + +function buildRow(overrides: Record = {}) { + return { + workOrderId: 9001, + workOrderNo: 'WO26093017345168', + status: 'in_progress', + productName: '某游戏代练', + platformOrderId: '2609301734', + material: { collect: { fields: { gameId: 'g-1', gameNickname: '小明' } } }, + ...overrides, + } +} + +function buildEnvelope(items: unknown[], total = items.length) { + return { ok: true, data: { items, pagination: { page: 1, pageSize: 20, total } } } +} + +function itemsOf(envelope: unknown): Array<{ workOrderId: number }> { + return (envelope as { data: { items: Array<{ workOrderId: number }> } }).data.items +} + +function totalOf(envelope: unknown): number { + return (envelope as { data: { pagination: { total: number } } }).data.pagination.total +} + +test('按工单号搜索必须能命中(后端搜索串第一项就是工单号,前端曾完全没比对它)', () => { + assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), 'WO26093017345168'), true) + assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), 'wo26093017'), true) + // 原有字段仍然要能命中 + assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), '小明'), true) + assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), '2609301734'), true) +}) + +test('关键词维度:找不到只能是 unknown,绝不能当成"不匹配"(这才是行被删空的根因)', () => { + const row = buildRow() + assert.equal(evaluateWorkerKeywordVerdict(row, 'WO26093017345168'), 'match') + assert.equal(evaluateWorkerKeywordVerdict(row, ''), 'match') + // 后端还能搜 material_json 里前端不知道的字段,所以找不到 ≠ 不匹配 + assert.equal(evaluateWorkerKeywordVerdict(row, '后端才知道的字段'), 'unknown') +}) + +test('按工单号搜到的行,实时事件来了也不能被删掉,并且总数要跟着走', () => { + const row = buildRow({ status: 'pending_material' }) + const envelope = buildEnvelope([row]) + const snapshot = buildRow({ status: 'in_progress' }) + + // 关键词命中 → upsert,原地更新,行数不变 + const updated = patchWorkerWorkOrderListEnvelope( + envelope, + { operation: 'upsert', data: snapshot }, + false, + ) + assert.equal(itemsOf(updated).length, 1) + assert.equal(totalOf(updated), 1) + assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') +}) + +test('不可证真的关键词下,快照只能就地更新:不插入、不删除', () => { + const envelope = buildEnvelope([buildRow()]) + // 关键词没命中前端已知字段(unknown):既不该把它当"不匹配"删掉 + const patched = patchWorkerWorkOrderListEnvelope( + envelope, + { operation: 'upsert', data: buildRow({ status: 'accepted' }) }, + false, + ) + assert.equal(itemsOf(patched).length, 1) + // 也不该把不在列表里的行插进来(留着让后端说了算) + const notInserted = patchWorkerWorkOrderListEnvelope( + envelope, + { operation: 'upsert', data: buildRow({ workOrderId: 8888 }) }, + false, + ) + assert.equal(itemsOf(notInserted).length, 1) +}) + +test('真正移出可见范围时要删行,并同步分页总数(旧实现只删 items,分页器还显示有 N 条)', () => { + const envelope = buildEnvelope([buildRow(), buildRow({ workOrderId: 9002 })], 2) + const removed = patchWorkerWorkOrderListEnvelope( + envelope, + { operation: 'remove', data: buildRow() }, + false, + ) + assert.deepEqual( + itemsOf(removed).map((item) => item.workOrderId), + [9002], + ) + assert.equal(totalOf(removed), 1, '删行后总数必须减一') + + const inserted = patchWorkerWorkOrderListEnvelope( + buildEnvelope([buildRow()], 1), + { operation: 'upsert', data: buildRow({ workOrderId: 9003 }) }, + true, + ) + assert.equal(itemsOf(inserted).length, 2) + assert.equal(totalOf(inserted), 2, '插行后总数必须加一') +}) + +test('无限下拉:只有第 1 页允许插入,其余页只就地更新', () => { + const page = (ids: number[]) => + buildEnvelope(ids.map((id) => buildRow({ workOrderId: id, status: 'open' }))) + const current = { pages: [page([1, 2]), page([3, 4])], pageParams: [1, 2] } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + current, + { operation: 'upsert', data: buildRow({ workOrderId: 99, status: 'open' }) }, + true, + ) as { pages: unknown[] } + assert.equal(itemsOf(patched.pages[0]).length, 3, '第 1 页应插入') + assert.equal(itemsOf(patched.pages[1]).length, 2, '第 2 页不得插入') +})