From 370acdba604d1a429f1b4fe467f5a7942546b82b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 20:11:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=B7=A5=E5=8D=95=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E6=9F=A5=E7=9C=8B=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../032_worker_work_order_views.sql | 14 ++++ .../worker-platform/work-order-repo.ts | 45 ++++++++++++ apps/backend/src/routes/worker.ts | 15 ++++ .../realtime/realtime-event-service.ts | 16 ++++- .../src/services/worker-platform/mappers.ts | 2 + .../worker-platform-service.test.ts | 12 ++++ .../worker-platform/worker-service.ts | 43 +++++++++-- apps/frontend/src/layouts/WorkerLayout.tsx | 16 ++++- .../src/pages/worker/WorkerOrdersPage.tsx | 72 ++++++++++++++++--- apps/frontend/src/services/worker.ts | 4 ++ apps/frontend/src/styles/worker.css | 5 ++ apps/frontend/src/types/worker-platform.ts | 2 + 12 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/db/migrations/032_worker_work_order_views.sql diff --git a/apps/backend/src/db/migrations/032_worker_work_order_views.sql b/apps/backend/src/db/migrations/032_worker_work_order_views.sql new file mode 100644 index 00000000..1c1846f4 --- /dev/null +++ b/apps/backend/src/db/migrations/032_worker_work_order_views.sql @@ -0,0 +1,14 @@ +-- 032_worker_work_order_views.sql —— 打手个人工单首次查看状态。 + +CREATE TABLE IF NOT EXISTS worker_work_order_views ( + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE, + viewed_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (work_order_id, worker_id) +); + +CREATE INDEX IF NOT EXISTS idx_worker_work_order_views_worker_order + ON worker_work_order_views(worker_id, work_order_id); + +COMMENT ON TABLE worker_work_order_views IS '打手个人工单首次查看记录,用于展示未读“新”标识'; +COMMENT ON COLUMN worker_work_order_views.viewed_at IS '首次点击订单详情、复制资料、编辑备注或验收时写入'; diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 3345c035..6876d07d 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -472,6 +472,51 @@ export async function upsertWorkerWorkOrderNote(input: { return String(result.rows[0]?.note || '') } +/** 查询打手已查看过的工单,用于计算“新”标识。 */ +export async function listWorkerWorkOrderViews( + workerId: number | string, + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return [] + const result = await query<{ work_order_id: number; viewed_at: string }>( + ` + SELECT work_order_id, viewed_at + FROM worker_work_order_views + WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) + `, + [Number(workerId), workOrderIds], + ) + return result.rows +} + +/** 首次查看工单时写入已读记录;重复调用保持原查看时间不变。 */ +export async function markWorkerWorkOrderViewed(input: { + workOrderId: number + workerId: number + now: string +}): Promise { + const inserted = await query<{ viewed_at: string }>( + ` + INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at) + VALUES ($1, $2, $3) + ON CONFLICT (work_order_id, worker_id) DO NOTHING + RETURNING viewed_at + `, + [input.workOrderId, input.workerId, input.now], + ) + if (inserted.rows[0]?.viewed_at) return String(inserted.rows[0].viewed_at) + const existing = await query<{ viewed_at: string }>( + ` + SELECT viewed_at + FROM worker_work_order_views + WHERE work_order_id = $1 AND worker_id = $2 + LIMIT 1 + `, + [input.workOrderId, input.workerId], + ) + return String(existing.rows[0]?.viewed_at || input.now) +} + async function getWorkOrderShareById(shareId: number): Promise { const result = await query( `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index fb4cc13e..d17f514c 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -18,6 +18,7 @@ import { listWorkerProfileWalletLedgers, listWorkerHallOrders, listWorkerMyOrders, + markWorkerOrderRead, getWorkerMyOrderEvents, loginWorker, getWorkerSessions, @@ -376,6 +377,20 @@ router.get( ), ) +router.post( + '/orders/:workOrderId/read', + requireActiveWorker, + createRouteHandler( + (req) => + markWorkerOrderRead(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), + { + successMessage: '订单已标记为已读', + errorMessage: '标记订单已读失败', + scope: '[worker/orders/:workOrderId/read]', + }, + ), +) + router.post( '/orders/:workOrderId/note', requireActiveWorker, diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 070d0b02..55e95374 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -5,6 +5,7 @@ import { getWorkerUserById, getWorkOrderById, listWorkerWorkOrderNotes, + listWorkerWorkOrderViews, listWorkOrderShares, sumPendingUnfreezeByOrderIds, } from '../../repositories/worker-platform/index.js' @@ -283,15 +284,26 @@ async function publishWorkOrderSnapshots(input: { )) { const worker = await getWorkerUserById(workerId) if (!worker) continue - const notes = await listWorkerWorkOrderNotes(workerId, [workOrderId]) + const [notes, views] = await Promise.all([ + listWorkerWorkOrderNotes(workerId, [workOrderId]), + listWorkerWorkOrderViews(workerId, [workOrderId]), + ]) const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' + const viewedAt = + views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null const share = shares.find((item) => Number(item.worker_id) === workerId) publishRealtimeEvent({ type: 'work_order.changed', entityId: workOrderId, scopes: ['worker_my_orders'], operation: 'upsert', - data: mapWorkOrderForWorker(workOrder, resolveWorkerPermissions(worker), share, note), + data: mapWorkOrderForWorker( + workOrder, + resolveWorkerPermissions(worker), + share, + note, + viewedAt, + ), workerIds: [workerId], }) } diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 36a3e394..889b0311 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -365,6 +365,7 @@ export function mapWorkOrderForWorker( permissions: { depositFreeAmount: number }, myShare?: WorkOrderShareRow | null, workerNote = '', + viewedAt?: string | null, ) { const mapped = mapWorkOrderAdmin(workOrder) const { @@ -380,6 +381,7 @@ export function mapWorkOrderForWorker( freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), workerNote, myShare: myShare ? mapWorkOrderShare(myShare) : null, + ...(viewedAt === undefined ? {} : { isUnread: !viewedAt }), } } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 6298648d..71b71409 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -8,6 +8,7 @@ import { isWorkerPasswordStrong, normalizeWithdrawChannel, normalizeRequirementFields, + mapWorkOrderForWorker, resolveCollectSubmitTargetWorkOrder, resolveWorkerPermissions, resolveSkuNameQuantity, @@ -197,6 +198,17 @@ function buildWorkOrderRow(overrides: Partial = {}): WorkOrderRow } } +test('mapWorkOrderForWorker reflects the personal first-view state', () => { + const workOrder = buildWorkOrderRow({ assigned_worker_id: 1 }) + const permissions = resolveWorkerPermissions(buildWorkerUserRow()) + assert.equal(mapWorkOrderForWorker(workOrder, permissions, null, '', null).isUnread, true) + assert.equal( + mapWorkOrderForWorker(workOrder, permissions, null, '', '2026-08-17T00:00:00.000Z').isUnread, + false, + ) + assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined) +}) + test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => { const workOrder = buildWorkOrderRow() const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '') diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 4104bc25..d421d176 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -43,6 +43,7 @@ import { listWorkerSharesByWorker, listWorkerWalletLedgers, listWorkerWorkOrderNotes, + listWorkerWorkOrderViews, listWorkerWithdrawalAccounts, listWorkCategories, listWorkOrders, @@ -60,6 +61,7 @@ import { updateWorkOrderShareAcceptanceRecord, updateWorkOrderShareDraftAcceptance, upsertWorkerWorkOrderNote, + markWorkerWorkOrderViewed, updateWorkerPassword, upsertWorkCategory, upsertWorkerLevel, @@ -1335,13 +1337,17 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker const permissions = resolveWorkerPermissions(worker) const myShares = await listWorkerSharesByWorker(worker.id) const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share])) - const notes = await listWorkerWorkOrderNotes( - worker.id, - items.map((item) => Number(item.id)), - ) + const workOrderIds = items.map((item) => Number(item.id)) + const [notes, views] = await Promise.all([ + listWorkerWorkOrderNotes(worker.id, workOrderIds), + listWorkerWorkOrderViews(worker.id, workOrderIds), + ]) const noteByOrderId = new Map( notes.map((item) => [Number(item.work_order_id), String(item.note || '')]), ) + const viewedAtByOrderId = new Map( + views.map((item) => [Number(item.work_order_id), String(item.viewed_at || '')]), + ) return { items: items.map((item) => mapWorkOrderForWorker( @@ -1349,6 +1355,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker permissions, myShareByOrderId.get(Number(item.id)), noteByOrderId.get(Number(item.id)) || '', + viewedAtByOrderId.get(Number(item.id)) || null, ), ), pagination: { page, pageSize, total }, @@ -1456,6 +1463,34 @@ export async function saveWorkerOrderNote( } } +/** 标记打手本人已查看工单,首次操作后不再展示“新”标识。 */ +export async function markWorkerOrderRead(workOrderId: number | string, session: WorkerSession) { + requireActiveWorkerSession(session) + const workOrder = await getRequiredWorkOrder(workOrderId) + const workerId = Number(session.workerId) + const sharingShare = await getWorkOrderShare(workOrder.id, workerId) + const isOrderOwner = + Number(workOrder.assigned_worker_id || 0) === workerId || + Number(workOrder.last_assigned_worker_id || 0) === workerId + if (!sharingShare && !isOrderOwner) { + throw createHttpError('只能标记自己的订单为已读', { + statusCode: 403, + errorCode: 'work_order_read_owner_required', + }) + } + + const viewedAt = await markWorkerWorkOrderViewed({ + workOrderId: Number(workOrder.id), + workerId, + now: nowIso(), + }) + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrder.id), + workerIds: [workerId], + }) + return { viewedAt, isUnread: false } +} + export async function joinWorkerSharingOrder( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index b736df75..37143dee 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -269,12 +269,24 @@ function applyWorkerDepositRule(queryClient: ReturnType, } function matchesWorkOrderKeyword( - item: { productName?: unknown; platformOrderId?: unknown }, + item: { productName?: unknown; platformOrderId?: unknown; material?: unknown }, keyword: string, ) { const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return true - return [item.productName, item.platformOrderId] + 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)) } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 594708ae..48431a16 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -37,6 +37,7 @@ import { fetchWorkerMyOrderOverview, fetchWorkerMyOrders, fetchWorkerProfile, + markWorkerOrderRead, remindWorkerAcceptance, saveWorkerAcceptanceDraft, saveWorkerOrderNote, @@ -231,7 +232,23 @@ export default function WorkerOrdersPage() { ]) } + function markOrderAsRead(order: WorkOrder) { + if (!order.isUnread) return + queryClient.setQueriesData({ queryKey: ['worker-my-orders'] }, (current: unknown) => + patchWorkOrderReadState(current, order.workOrderId), + ) + void markWorkerOrderRead(order.workOrderId).catch(() => { + void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) + }) + } + + function openOrderDetail(order: WorkOrder) { + markOrderAsRead(order) + setDetailOrder(order) + } + function copyOrderInfo(order: WorkOrder) { + markOrderAsRead(order) const items = getCopyOrderInfoItems(order) if (items.length === 0) { message.warning('该订单暂无可复制信息') @@ -273,6 +290,7 @@ export default function WorkerOrdersPage() { } function openAcceptanceModal(order: WorkOrder) { + markOrderAsRead(order) setEditingOrder(order) // 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容 const source = hasSubmittedAcceptance(order) @@ -284,6 +302,7 @@ export default function WorkerOrdersPage() { } function openNoteModal(order: WorkOrder) { + markOrderAsRead(order) setNoteOrder(order) noteForm.setFieldsValue({ note: String(order.workerNote || '') }) } @@ -388,14 +407,21 @@ export default function WorkerOrdersPage() { width: 270, render: (_, row) => (
- setDetailOrder(row)} - title={row.productName || '未命名商品'} - ellipsis - > - {row.productName || '未命名商品'} - + + {row.isUnread ? ( + + 新 + + ) : null} + openOrderDetail(row)} + title={row.productName || '未命名商品'} + ellipsis + > + {row.productName || '未命名商品'} + + {row.categoryName ? {row.categoryName} : null} {row.myShare ? 拼单 : null} @@ -596,7 +622,7 @@ export default function WorkerOrdersPage() { @@ -1304,6 +1335,27 @@ export default function WorkerOrdersPage() { ) } +function patchWorkOrderReadState(current: unknown, workOrderId: number) { + if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { + return current + } + return { + ...current, + data: { + ...current.data, + items: current.data.items.map((item) => + isRecord(item) && Number(item.workOrderId) === workOrderId + ? { ...item, isUnread: false } + : item, + ), + }, + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + function canSubmitAcceptance(order: WorkOrder) { if (order.myShare) { return order.myShare.status === 'joined' && order.status !== 'accepted' diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 52d30f25..9969aed4 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -172,6 +172,10 @@ export function fetchWorkerMyOrderEvents(workOrderId: number) { return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`) } +export function markWorkerOrderRead(workOrderId: number) { + return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`) +} + export function saveWorkerOrderNote(workOrderId: number, note: string) { return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note }) } diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 12c0045f..dc09866e 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -1008,6 +1008,11 @@ body { line-height: 18px; } +.worker-order-unread-tag { + margin: 0; + font-weight: 700; +} + .worker-order-mobile-orderno-text { font-size: 12px; color: #64748b; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index a90397e2..fc545eb7 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -309,6 +309,8 @@ export type WorkOrder = { /** 暂存的验收草稿(未提交时保存于此,刷新不丢失;提交后清空) */ draftAcceptance: WorkerAcceptance workerNote?: string + /** 打手本人尚未点击查看或操作过该工单。 */ + isUnread?: boolean problemNote: string worker: null | { workerId: number