From 2224dd06d33ca1a52ad952c2025674b658526b59 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 22 Aug 2026 15:04:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E6=89=8B=E7=AB=AF=E5=BE=85=E5=AE=A1?= =?UTF-8?q?=E6=92=A4=E5=8D=95=E4=B8=8E=E6=9C=AA=E5=A4=84=E7=90=86=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E8=AE=A2=E5=8D=95=E5=BD=92=E5=85=A5=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=8D=95=E5=88=86=E7=BB=84=E5=B9=B6=E6=A0=87=E6=B3=A8=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E8=A7=92=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../057_worker_material_seen_at.sql | 5 ++ .../worker-platform/work-order-event-repo.ts | 18 +++++ .../worker-platform/work-order-query-repo.ts | 78 ++++++++++++++++--- .../worker-cancel-request-repo.ts | 17 ++++ .../worker-order-feedback-repo.ts | 17 ++++ .../worker-platform/worker-order-note-repo.ts | 29 +++++-- apps/backend/src/routes/worker/orders.ts | 6 +- .../worker-my-order-service.ts | 59 ++++++++++---- .../pages/worker/WorkerOrderMobileList.tsx | 7 ++ .../pages/worker/WorkerOrderTableColumns.tsx | 11 +++ .../src/pages/worker/WorkerOrdersPage.tsx | 20 +++-- .../src/pages/worker/worker-order-utils.tsx | 11 +++ apps/frontend/src/services/worker.ts | 7 +- apps/frontend/src/types/worker-platform.ts | 5 ++ 14 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 apps/backend/src/db/migrations/057_worker_material_seen_at.sql diff --git a/apps/backend/src/db/migrations/057_worker_material_seen_at.sql b/apps/backend/src/db/migrations/057_worker_material_seen_at.sql new file mode 100644 index 00000000..1884c5e5 --- /dev/null +++ b/apps/backend/src/db/migrations/057_worker_material_seen_at.sql @@ -0,0 +1,5 @@ +-- 打手查看工单资料更新的时间:客服更新资料后订单展示"资料已更新"角标,打手打开详情后清除。 +ALTER TABLE worker_work_order_views + ADD COLUMN IF NOT EXISTS material_seen_at TIMESTAMPTZ; + +COMMENT ON COLUMN worker_work_order_views.material_seen_at IS '打手最近一次打开工单详情的时间,用于计算资料已更新角标'; diff --git a/apps/backend/src/repositories/worker-platform/work-order-event-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-event-repo.ts index 10db8c3f..cfc391d9 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-event-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-event-repo.ts @@ -2,6 +2,24 @@ import type { PoolClient } from 'pg' import { query } from '../../db/client.js' +/** 批量查询工单最近一次客服更新资料的时间,用于打手端"资料已更新"角标。 */ +export async function getLatestMaterialEventAtByOrderIds( + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return new Map() + const result = await query<{ work_order_id: number; created_at: string }>( + ` + SELECT DISTINCT ON (work_order_id) work_order_id, created_at + FROM work_order_events + WHERE work_order_id = ANY($1::bigint[]) + AND event_type IN ('material_updated', 'material_completed') + ORDER BY work_order_id, id DESC + `, + [workOrderIds], + ) + return new Map(result.rows.map((row) => [Number(row.work_order_id), String(row.created_at)])) +} + export type WorkOrderEventRow = { id: number work_order_id: number diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 5e9012f1..aab56ab7 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -122,9 +122,13 @@ function workOrderSearchExpression(alias = 'wo') { )` } -/** 打手订单筛选使用个人份额状态,而不是拼单母工单的 open 状态。 */ -function workerOrderStatusExpression(workerShareAlias = 'worker_share') { - return `CASE +/** + * 打手订单个人状态:拼单按个人份额状态计算。 + * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, + * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 + */ +export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { + const baseExpression = `CASE WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status ELSE COALESCE(CASE ${workerShareAlias}.status WHEN 'joined' THEN 'in_progress' @@ -134,6 +138,27 @@ function workerOrderStatusExpression(workerShareAlias = 'worker_share') { ELSE wo.status END, wo.status) END` + const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) + if (normalizedWorkerId <= 0) return baseExpression + const pendingHandlingExists = `( + EXISTS ( + SELECT 1 + FROM worker_order_cancel_requests wcr + WHERE wcr.work_order_id = wo.id + AND wcr.worker_id = ${normalizedWorkerId} + AND wcr.status = 'pending' + ) OR EXISTS ( + SELECT 1 + FROM worker_order_feedbacks wf + WHERE wf.work_order_id = wo.id + AND wf.worker_id = ${normalizedWorkerId} + AND wf.status = 'pending' + ) + )` + return `CASE + WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem' + ELSE ${baseExpression} + END` } export async function getWorkOrderByOrderItemId( orderItemId: number | string, @@ -343,15 +368,44 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise WITH worker_orders AS ( SELECT CASE - WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos.status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' + WHEN ( + CASE + WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem') + THEN CASE wos.status + WHEN 'joined' THEN 'in_progress' + WHEN 'submitted' THEN 'pending_acceptance' + WHEN 'accepted' THEN 'accepted' + WHEN 'cancelled' THEN 'cancelled' + ELSE wo.status + END ELSE wo.status END - ELSE wo.status + ) = 'in_progress' AND ( + EXISTS ( + SELECT 1 + FROM worker_order_cancel_requests wcr + WHERE wcr.work_order_id = wo.id + AND wcr.worker_id = $1 + AND wcr.status = 'pending' + ) OR EXISTS ( + SELECT 1 + FROM worker_order_feedbacks wf + WHERE wf.work_order_id = wo.id + AND wf.worker_id = $1 + AND wf.status = 'pending' + ) + ) THEN 'problem' + ELSE CASE + WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem') + THEN CASE wos.status + WHEN 'joined' THEN 'in_progress' + WHEN 'submitted' THEN 'pending_acceptance' + WHEN 'accepted' THEN 'accepted' + WHEN 'cancelled' THEN 'cancelled' + ELSE wo.status + END + ELSE wo.status + END END AS status, wo.acceptance_json, wos.id IS NOT NULL AS is_worker_share, @@ -678,7 +732,9 @@ export function buildWorkOrderWhere({ params.push(normalizedStatuses) const statusesPlaceholder = `$${params.length}` if (workerSharingId) { - filters.push(`${workerOrderStatusExpression()} = ANY(${statusesPlaceholder}::text[])`) + filters.push( + `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`, + ) } else { filters.push( `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, diff --git a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts index e7c9acb2..6c285c0f 100644 --- a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts @@ -201,6 +201,23 @@ async function applyWorkerCancelRequestPenaltyWithClient( } /** 打手提交撤单申请;待审核期间订单与押金均保持原状。 */ +/** 批量查询打手在各工单上待审核的撤单申请,用于订单角标与问题单归类。 */ +export async function getPendingCancelRequestOrderIds( + workerId: number, + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return new Set() + const result = await query<{ work_order_id: number }>( + ` + SELECT work_order_id + FROM worker_order_cancel_requests + WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) AND status = 'pending' + `, + [Number(workerId), workOrderIds], + ) + return new Set(result.rows.map((row) => Number(row.work_order_id))) +} + export async function createWorkerCancelRequest(input: { workOrderId: number workerId: number diff --git a/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts index e21f1ec4..46d6d1ff 100644 --- a/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts @@ -39,6 +39,23 @@ async function getWorkerOrderFeedbackByIdWithClient( return result.rows[0] || null } +/** 批量查询打手在各工单上未处理的问题反馈,用于订单角标与问题单归类。 */ +export async function getPendingFeedbackOrderIds( + workerId: number, + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return new Set() + const result = await query<{ work_order_id: number }>( + ` + SELECT work_order_id + FROM worker_order_feedbacks + WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) AND status = 'pending' + `, + [Number(workerId), workOrderIds], + ) + return new Set(result.rows.map((row) => Number(row.work_order_id))) +} + export async function createWorkerOrderFeedback(input: { workOrderId: number workerId: number diff --git a/apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts b/apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts index 3ea85f99..087449c0 100644 --- a/apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts @@ -36,15 +36,19 @@ export async function upsertWorkerWorkOrderNote(input: { return String(result.rows[0]?.note || '') } -/** 查询打手已查看过的工单,用于计算"新"标识。 */ +/** 查询打手已查看过的工单,用于计算"新"标识与资料已更新角标。 */ export async function listWorkerWorkOrderViews( workerId: number | string, workOrderIds: number[], -): Promise> { +): Promise> { if (workOrderIds.length === 0) return [] - const result = await query<{ work_order_id: number; viewed_at: string }>( + const result = await query<{ + work_order_id: number + viewed_at: string + material_seen_at: string | null + }>( ` - SELECT work_order_id, viewed_at + SELECT work_order_id, viewed_at, material_seen_at FROM worker_work_order_views WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) `, @@ -53,12 +57,27 @@ export async function listWorkerWorkOrderViews( return result.rows } -/** 首次查看工单时写入已读记录;重复调用保持原查看时间不变。 */ +/** + * 首次查看工单时写入已读记录,重复调用保持原查看时间不变; + * materialSeen 为 true 时刷新资料查看时间,用于清除"资料已更新"角标。 + */ export async function markWorkerWorkOrderViewed(input: { workOrderId: number workerId: number now: string + materialSeen?: boolean }): Promise { + if (input.materialSeen) { + await query( + ` + INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at, material_seen_at) + VALUES ($1, $2, $3, $3) + ON CONFLICT (work_order_id, worker_id) DO UPDATE + SET material_seen_at = EXCLUDED.material_seen_at + `, + [input.workOrderId, input.workerId, input.now], + ) + } const inserted = await query<{ viewed_at: string }>( ` INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at) diff --git a/apps/backend/src/routes/worker/orders.ts b/apps/backend/src/routes/worker/orders.ts index d993eae7..910114c0 100644 --- a/apps/backend/src/routes/worker/orders.ts +++ b/apps/backend/src/routes/worker/orders.ts @@ -157,7 +157,11 @@ router.post( requireActiveWorker, createRouteHandler( (req) => - markWorkerOrderRead(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), + markWorkerOrderRead( + String(req.params.workOrderId || ''), + req.body || {}, + getRequiredWorkerSession(req), + ), { successMessage: '订单已标记为已读', errorMessage: '标记订单已读失败', diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 834fab6e..906cb5bf 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -2,6 +2,9 @@ import { runtimeConfig } from '../../config/runtime.js' import { createWorkerCancelRequest, createWorkerOrderFeedback, + getLatestMaterialEventAtByOrderIds, + getPendingCancelRequestOrderIds, + getPendingFeedbackOrderIds, getWorkOrderShare, getWorkerCancelRequestRisk, getWorkerOrderOverview, @@ -61,26 +64,47 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker const myShares = await listWorkerSharesByWorker(worker.id) const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share])) const workOrderIds = items.map((item) => Number(item.id)) - const [notes, views] = await Promise.all([ - listWorkerWorkOrderNotes(worker.id, workOrderIds), - listWorkerWorkOrderViews(worker.id, workOrderIds), - ]) + const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] = + await Promise.all([ + listWorkerWorkOrderNotes(worker.id, workOrderIds), + listWorkerWorkOrderViews(worker.id, workOrderIds), + getPendingCancelRequestOrderIds(worker.id, workOrderIds), + getPendingFeedbackOrderIds(worker.id, workOrderIds), + getLatestMaterialEventAtByOrderIds(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 || '')]), ) + const materialSeenAtByOrderId = new Map( + views.map((item) => [Number(item.work_order_id), String(item.material_seen_at || '')]), + ) return { - items: items.map((item) => - mapWorkOrderForWorker( - item, - permissions, - myShareByOrderId.get(Number(item.id)), - noteByOrderId.get(Number(item.id)) || '', - viewedAtByOrderId.get(Number(item.id)) || null, - ), - ), + items: items.map((item) => { + const workOrderId = Number(item.id) + const materialEventAt = materialEventAtByOrderId.get(workOrderId) || '' + const materialSeenAt = materialSeenAtByOrderId.get(workOrderId) || '' + const materialUpdated = + Boolean(materialEventAt) && + (!materialSeenAt || + new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime()) + return { + ...mapWorkOrderForWorker( + item, + permissions, + myShareByOrderId.get(workOrderId), + noteByOrderId.get(workOrderId) || '', + viewedAtByOrderId.get(workOrderId) || null, + ), + // 问题单分组内的来源角标:平台标记 / 撤单审核中 / 反馈处理中。 + problemMarked: item.status === 'problem', + pendingCancelRequest: pendingCancelOrderIds.has(workOrderId), + pendingFeedback: pendingFeedbackOrderIds.has(workOrderId), + materialUpdated, + } + }), pagination: { page, pageSize, total }, } } @@ -319,8 +343,12 @@ export async function saveWorkerOrderNote( } } -/** 标记打手本人已查看工单,首次操作后不再展示“新”标识。 */ -export async function markWorkerOrderRead(workOrderId: number | string, session: WorkerSession) { +/** 标记打手本人已查看工单,首次操作后不再展示“新”标识;materialSeen 同时刷新资料查看时间。 */ +export async function markWorkerOrderRead( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { requireActiveWorkerSession(session) const workOrder = await getRequiredWorkOrder(workOrderId) const workerId = Number(session.workerId) @@ -339,6 +367,7 @@ export async function markWorkerOrderRead(workOrderId: number | string, session: workOrderId: Number(workOrder.id), workerId, now: nowIso(), + materialSeen: payload.materialSeen === true || payload.materialSeen === 'true', }) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 725d84c7..95c7f58f 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -31,6 +31,7 @@ import { getDisplayOrderNo, getMaterialDetailItems, getMaterialScreenshots, + getOrderFlagTags, getOrderStatusDisplay, getSettlementLabel, getUniqueAcceptanceFiles, @@ -228,6 +229,7 @@ function WorkerOrderMobileCard({ const noteText = getWorkerAcceptance(order)?.note const workerNote = String(order.workerNote || '').trim() const statusDisplay = getOrderStatusDisplay(order) + const flagTags = getOrderFlagTags(order) return (
@@ -248,6 +250,11 @@ function WorkerOrderMobileCard({ 拼单 ) : null} + {flagTags.map((flag) => ( + + {flag.label} + + ))} {getDisplayOrderNo(order)}
diff --git a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx index e559ce30..03c930cc 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx @@ -26,6 +26,7 @@ import { getDisplayOrderNo, getMaterialDetailItems, getMaterialScreenshots, + getOrderFlagTags, getOrderStatusDisplay, getUniqueAcceptanceFiles, getWorkerAcceptance, @@ -102,6 +103,7 @@ export function buildWorkerOrderTableColumns({ width: 130, render: (_, row) => { const statusDisplay = getOrderStatusDisplay(row) + const flagTags = getOrderFlagTags(row) return (
@@ -115,6 +117,15 @@ export function buildWorkerOrderTableColumns({ ) : null} + {flagTags.length > 0 ? ( + + {flagTags.map((flag) => ( + + {flag.label} + + ))} + + ) : null} {row.status === 'problem' && String(row.problemNote || '').trim() ? ( - patchWorkOrderReadState(current, order.workOrderId), - ) - void markWorkerOrderRead(order.workOrderId).catch(() => { - void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) - }) + if (order.isUnread) { + queryClient.setQueriesData({ queryKey: ['worker-my-orders'] }, (current: unknown) => + patchWorkOrderReadState(current, order.workOrderId), + ) + } + // 每次打开详情都刷新资料查看时间,用于清除"资料已更新"角标。 + void markWorkerOrderRead(order.workOrderId, { materialSeen: true }) + .then(() => { + void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) + }) + .catch(() => { + void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) + }) } function openOrderDetail(order: WorkOrder) { diff --git a/apps/frontend/src/pages/worker/worker-order-utils.tsx b/apps/frontend/src/pages/worker/worker-order-utils.tsx index f181c519..45785706 100644 --- a/apps/frontend/src/pages/worker/worker-order-utils.tsx +++ b/apps/frontend/src/pages/worker/worker-order-utils.tsx @@ -72,6 +72,17 @@ export function getOrderStatusDisplay(order: WorkOrder): { label: string; color: return { label: formatStatus(order.status), color: resolveStatusColor(order.status) } } +/** 问题单来源与资料更新的订单角标,桌面表格与移动卡片共用。 */ +export function getOrderFlagTags(order: WorkOrder) { + const flags: Array<{ key: string; label: string; color: string }> = [] + if (order.problemMarked) flags.push({ key: 'problem', label: '平台标记', color: 'red' }) + if (order.pendingCancelRequest) + flags.push({ key: 'cancel', label: '撤单审核中', color: 'orange' }) + if (order.pendingFeedback) flags.push({ key: 'feedback', label: '反馈处理中', color: 'orange' }) + if (order.materialUpdated) flags.push({ key: 'material', label: '资料已更新', color: 'blue' }) + return flags +} + export function getOrderStatusHint(order: WorkOrder) { if (isFriendGiftOrder(order) && order.status === 'in_progress' && order.gift) { const hints: Record = { diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 7e28637e..162ab6d1 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -270,8 +270,11 @@ export function submitWorkerOrderFeedback(workOrderId: number, payload: { conten ) } -export function markWorkerOrderRead(workOrderId: number) { - return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`) +export function markWorkerOrderRead(workOrderId: number, options: { materialSeen?: boolean } = {}) { + return apiPost<{ viewedAt: string; isUnread: false }>( + `/api/v1/worker/orders/${workOrderId}/read`, + { materialSeen: options.materialSeen === true }, + ) } export function saveWorkerOrderNote(workOrderId: number, note: string) { diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index ec1088bc..ba47f528 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -548,6 +548,11 @@ export type WorkOrder = { /** 打手本人尚未点击查看或操作过该工单。 */ isUnread?: boolean problemNote: string + /** 问题单分组来源角标:平台标记 / 撤单审核中 / 反馈处理中 / 资料已更新。 */ + problemMarked?: boolean + pendingCancelRequest?: boolean + pendingFeedback?: boolean + materialUpdated?: boolean /** 取消信息,仅已取消工单返回。 */ cancel?: { reason: string