diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 5e2dccb9..979ea773 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -95,6 +95,7 @@ import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' +import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders' import { DeadlineCountdown, formatAcceptanceMode, @@ -1068,16 +1069,6 @@ export default function WorkOrdersPanel() { await doSubmit() } - function isExplicitOrderNoSearch(text: string) { - const trimmed = text.trim() - if (!trimmed) return false - return ( - /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed) || - /^\d{6,}(-\d{6,})?$/.test(trimmed) || - /^OD[0-9a-zA-Z]{4,}$/i.test(trimmed) - ) - } - function searchOrders() { const trimmed = keywordInput.trim() setKeyword(trimmed) diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index 35381e51..1ee9d0ce 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -99,12 +99,34 @@ function matchesCreatedRange( return true } +/** + * 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号。 + * + * 两处依赖它,必须保持一致,所以放在这里而不是面板内部: + * 1. 面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶); + * 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉。 + */ +export function isExplicitOrderNoSearch(text: unknown): boolean { + const trimmed = String(text || '').trim() + if (!trimmed) return false + return ( + /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed) || + /^\d{6,}(-\d{6,})?$/.test(trimmed) || + /^OD[0-9a-zA-Z]{4,}$/i.test(trimmed) + ) +} + export function evaluateAdminWorkOrderRealtimeMatch( snapshot: unknown, filters: AdminWorkOrderListFilters, ): AdminWorkOrderRealtimeVerdict { if (!isRecord(snapshot)) return 'unknown' + // 显式单号检索要排在最前面:用户就是在找这一单,无论它流转到什么状态都必须留在视野里。 + // 只做原地更新,不插入也不移除 —— 否则状态流转(或前端对状态/接单人员/日期口径的复刻偏差) + // 会把正在看的单从结果里删掉,表现为「状态一变就搜不到,必须整页刷新」(生产已复现)。 + if (isExplicitOrderNoSearch(filters.keyword)) return 'unknown' + // 先跑所有「可以证伪」的条件:任何一条不满足就是明确的 mismatch(允许从列表移除) // // 状态必须用 rawStatus 比:快照的 `status` 是运营态(拼单母单 open 有份额时会被显示成 @@ -151,6 +173,7 @@ export function evaluateAdminWorkOrderRealtimeMatch( // - 赠送阶段:gift_ready 是"冷却到点"的实时阶段,含 NOW() 判定; // - 打手等级:只在服务端按接单人与拼单参与者计算; // - VIP 待补图:混在状态列表里的虚拟值,判定依赖 acceptance_json 多个字段与 NOW()。 + // 注:显式单号检索已在函数开头短路,走不到这里。 if (filters.acceptanceMode) return 'unknown' if (vipEvidencePendingActive) return 'unknown' if (filters.keyword) return 'unknown' diff --git a/apps/frontend/tests/admin-realtime-work-orders.test.ts b/apps/frontend/tests/admin-realtime-work-orders.test.ts index 7cdb645b..91918a8a 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import { evaluateAdminWorkOrderRealtimeMatch, + isExplicitOrderNoSearch, parseAdminWorkOrderListFilters, patchAdminWorkOrderListItems, shouldPrependAdminWorkOrder, @@ -372,3 +373,48 @@ test('关键词 / 赠送阶段 / 打手等级筛选:无法证真,只原地 'mismatch', ) }) + +/** + * 显式单号检索的护栏。 + * + * 生产现象:按工单号搜出来的那一行,在订单状态变化后会从结果里消失,必须整页刷新才回来。 + * 成因是实时补丁的判定链里,「状态/接单人员/日期范围」这些可证伪条件排在关键词护栏之前, + * 一旦它们判定 mismatch 就允许把行删掉。而面板搜索时只清空了状态,没清接单人员/日期范围, + * 所以护栏还会被这些条件绕过。 + */ +test('显式单号检索:状态流转后不得把正在看的单删掉', () => { + const cases: Array> = [ + { keyword: 'WO26100217194691', statuses: 'in_progress' }, + { keyword: 'WO26100217194691', workerId: 7 }, + { keyword: 'WO26100217194691', createdFrom: '2026-10-05' }, + { keyword: 'OD26100217', statuses: 'open' }, + { keyword: '2627502003065030', statuses: 'open' }, + ] + for (const override of cases) { + const filters = parseAdminWorkOrderListFilters(buildQueryKey(override))! + assert.equal( + evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), filters), + 'unknown', + `${JSON.stringify(override)} 应判为 unknown(只原地更新),否则状态一变就搜不到`, + ) + } +}) + +test('模糊关键词不享受单号护栏:状态可证伪时仍按原设计移除', () => { + const filters = parseAdminWorkOrderListFilters( + buildQueryKey({ keyword: '暗潮', statuses: 'open' }), + )! + assert.equal( + evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), filters), + 'mismatch', + ) +}) + +test('isExplicitOrderNoSearch 只认单号形态,普通关键词不误判', () => { + for (const orderNo of ['WO26100217194691', 'OD26100217', '2627502003065030', 'wo12345678']) { + assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`) + } + for (const keyword of ['暗潮', '无限潮汐', 'WO1', '', 'abcd']) { + assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`) + } +})