fix(worker): 打手端实时补丁不再误删"按工单号搜到的行",并同步分页总数
这是后台那个"搜工单号搜不到、必须刷新整页"在打手端的同源副本: 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 只就地更新、删/插同步总数、无限下拉页边界)。
This commit is contained in:
@@ -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<typeof useQueryClient>,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>): 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),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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 页不得插入')
|
||||
})
|
||||
Reference in New Issue
Block a user