From 950c4fd578ddaf24df8eca00ad3ad666ab4acf7f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 19:25:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8D=95=E5=B7=A5=E5=8D=95=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=B5=81=E8=BD=AC=E8=AE=B0=E5=BD=95=EF=BC=88=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=AF=A6=E6=83=85=E6=97=B6=E9=97=B4=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:新增工单事件查询接口(管理端 + 打手端),事件带操作人显示名(管理员用户名 / 打手昵称 / 系统) - 补齐关键节点事件:抢单接手(grabbed)、验收通过(accepted 带操作人) - 管理端/打手端事件写入操作人(admin session username 贯通至事件) - 前端:订单详情新增「流转记录」时间线,按订单生命周期节点展示(创建/发单/接手/资料/验收/问题/取消),时间+操作人+状态流转 --- .../worker-platform/work-order-repo.ts | 56 +++- .../src/routes/admin/worker-platform.ts | 206 +++++++++----- apps/backend/src/routes/worker.ts | 15 + .../services/worker-platform/admin-service.ts | 66 +++-- .../src/services/worker-platform/mappers.ts | 76 +++++ .../worker-platform/worker-service.ts | 37 +++ .../src/components/WorkOrderEventTimeline.tsx | 267 ++++++++++++++++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 5 + .../src/pages/worker/WorkerOrdersPage.tsx | 5 + .../src/services/admin/worker-platform.ts | 7 + apps/frontend/src/services/worker.ts | 5 + apps/frontend/src/styles/worker.css | 34 +++ apps/frontend/src/types/worker-platform.ts | 12 + 13 files changed, 699 insertions(+), 92 deletions(-) create mode 100644 apps/frontend/src/components/WorkOrderEventTimeline.tsx 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 c9561236..25ee91ce 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -1056,6 +1056,8 @@ export async function assignWorkOrderToWorker(input: { maxActiveOrders: number deadlineAt: string | null now: string + /** 操作人显示名(管理员用户名等) */ + actorName?: string }): Promise<{ order: WorkOrderRow | null failureReason: @@ -1143,7 +1145,7 @@ export async function assignWorkOrderToWorker(input: { await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', - actorId: '', + actorId: input.actorName || '', eventType: 'assigned_by_admin', fromStatus, toStatus: 'in_progress', @@ -1245,7 +1247,11 @@ export async function cancelWorkerWorkOrder(input: { }) } -export async function unassignWorkOrder(input: { workOrderId: number; now: string }): Promise<{ +export async function unassignWorkOrder(input: { + workOrderId: number + now: string + actorName?: string +}): Promise<{ order: WorkOrderRow | null failureReason: 'work_order_not_in_progress' | 'work_order_no_worker' | null }> { @@ -1314,7 +1320,7 @@ export async function unassignWorkOrder(input: { workOrderId: number; now: strin await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', - actorId: '', + actorId: input.actorName || '', eventType: 'unassigned_by_admin', fromStatus: 'in_progress', toStatus: 'unassigned', @@ -1333,6 +1339,7 @@ export async function returnAssignedWorkOrderToHall(input: { workOrderId: number now: string reason?: string + actorName?: string }): Promise<{ order: WorkOrderRow | null failureReason: 'work_order_not_in_progress' | 'work_order_no_worker' | null @@ -1403,7 +1410,7 @@ export async function returnAssignedWorkOrderToHall(input: { await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', - actorId: '', + actorId: input.actorName || '', eventType: 'returned_to_hall_by_admin', fromStatus: 'in_progress', toStatus: 'open', @@ -1426,6 +1433,7 @@ export async function cancelAssignedWorkOrder(input: { workOrderId: number now: string reason?: string + actorName?: string }): Promise<{ order: WorkOrderRow | null failureReason: 'work_order_not_in_progress' | 'work_order_no_worker' | null @@ -1496,7 +1504,7 @@ export async function cancelAssignedWorkOrder(input: { await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', - actorId: '', + actorId: input.actorName || '', eventType: 'cancelled_by_admin', fromStatus: 'in_progress', toStatus: 'cancelled', @@ -1537,6 +1545,10 @@ export async function acceptWorkOrderAndSettle(input: { workOrderId: number unfreezeDays: number now: string + /** 操作人类型:admin / worker / system */ + actorType?: string + /** 操作人显示名 */ + actorName?: string }): Promise<{ order: WorkOrderRow | null failureReason: 'sharing_pending_submissions' | null @@ -1809,8 +1821,8 @@ export async function acceptWorkOrderAndSettle(input: { await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, - actorType: 'admin', - actorId: '', + actorType: input.actorType || 'admin', + actorId: input.actorName || '', eventType: 'accepted', fromStatus: 'pending_acceptance', toStatus: 'accepted', @@ -1834,6 +1846,7 @@ export async function resolveProblemWorkOrder(input: { action: ProblemWorkOrderResolutionAction note: string now: string + actorName?: string }): Promise { return withTransaction(async (client) => { const currentResult = await client.query( @@ -1937,7 +1950,7 @@ export async function resolveProblemWorkOrder(input: { await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', - actorId: '', + actorId: input.actorName || '', eventType: `problem_${input.action}`, fromStatus: 'problem', toStatus: targetStatus, @@ -2409,6 +2422,33 @@ export async function countWorkerTimeoutEvents(workerId: number | string): Promi return counts.get(Number(workerId)) || 0 } +export type WorkOrderEventRow = { + id: number + work_order_id: number + actor_type: string + actor_id: string + event_type: string + from_status: string + to_status: string + payload_json: string | Record + created_at: string +} + +export async function listWorkOrderEventsByOrderId( + workOrderId: number | string, +): Promise { + const result = await query( + ` + SELECT * + FROM work_order_events + WHERE work_order_id = $1 + ORDER BY id ASC + `, + [Number(workOrderId)], + ) + return result.rows +} + export async function createWorkOrderEvent(input: { workOrderId: number actorType: string diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index ada78972..a8c66d72 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -15,6 +15,7 @@ import { deleteAdminWorkerLevel, getAdminWorkerFinanceConfig, getAdminWorkerPlatformSummary, + getAdminWorkOrderEvents, getAdminWorkOrderSharing, listAdminWorkerFinanceRequests, listAdminWorkCategories, @@ -311,7 +312,12 @@ router.post( '/worker-platform/orders/:workOrderId/material', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => submitAdminWorkOrderMaterial(String(req.params.workOrderId || ''), req.body || {}), + (req) => + submitAdminWorkOrderMaterial( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '订单资料已保存', errorMessage: '补充订单资料失败', @@ -333,6 +339,7 @@ router.put( (req) => updateAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}, { canEditRequirementFields: req.adminSession?.role !== 'support', + actorName: req.adminSession?.username || '', }), { successMessage: '工单信息已保存', @@ -352,7 +359,12 @@ router.post( '/worker-platform/orders/:workOrderId/assign', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => assignAdminWorkOrderToWorker(String(req.params.workOrderId || ''), req.body || {}), + (req) => + assignAdminWorkOrderToWorker( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '已指派打手', errorMessage: '指派打手失败', @@ -370,24 +382,36 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/unassign', requireAdminRoles(['admin', 'operator', 'support']), - createJsonHandler((req) => unassignAdminWorkOrder(String(req.params.workOrderId || '')), { - successMessage: '已取消指派', - errorMessage: '取消指派失败', - scope: '[admin/worker-platform/orders/:workOrderId/unassign]', - audit: (req, data) => ({ - action: 'work_order_worker_unassigned', - targetType: 'work_order', - targetId: String(req.params.workOrderId || ''), - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }), + createJsonHandler( + (req) => + unassignAdminWorkOrder( + String(req.params.workOrderId || ''), + req.adminSession?.username || '', + ), + { + successMessage: '已取消指派', + errorMessage: '取消指派失败', + scope: '[admin/worker-platform/orders/:workOrderId/unassign]', + audit: (req, data) => ({ + action: 'work_order_worker_unassigned', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.post( '/worker-platform/orders/:workOrderId/cancel', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => cancelAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}), + (req) => + cancelAdminWorkOrder( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '撤单处理完成,打手额度已恢复', errorMessage: '撤单失败', @@ -424,40 +448,56 @@ router.delete( router.post( '/worker-platform/orders/:workOrderId/publish', requireAdminRoles(['admin', 'operator', 'support']), - createJsonHandler((req) => publishAdminWorkOrder(String(req.params.workOrderId || '')), { - successMessage: '订单已发布到抢单大厅', - errorMessage: '发布接单订单失败', - scope: '[admin/worker-platform/orders/:workOrderId/publish]', - audit: (req, data) => ({ - action: 'work_order_published', - targetType: 'work_order', - targetId: String(req.params.workOrderId || ''), - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }), + createJsonHandler( + (req) => + publishAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''), + { + successMessage: '订单已发布到抢单大厅', + errorMessage: '发布接单订单失败', + scope: '[admin/worker-platform/orders/:workOrderId/publish]', + audit: (req, data) => ({ + action: 'work_order_published', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.post( '/worker-platform/orders/:workOrderId/unpublish', requireAdminRoles(['admin', 'operator', 'support']), - createJsonHandler((req) => unpublishAdminWorkOrder(String(req.params.workOrderId || '')), { - successMessage: '订单已撤回到未分配', - errorMessage: '下架接单订单失败', - scope: '[admin/worker-platform/orders/:workOrderId/unpublish]', - audit: (req, data) => ({ - action: 'work_order_unpublished', - targetType: 'work_order', - targetId: String(req.params.workOrderId || ''), - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }), + createJsonHandler( + (req) => + unpublishAdminWorkOrder( + String(req.params.workOrderId || ''), + req.adminSession?.username || '', + ), + { + successMessage: '订单已撤回到未分配', + errorMessage: '下架接单订单失败', + scope: '[admin/worker-platform/orders/:workOrderId/unpublish]', + audit: (req, data) => ({ + action: 'work_order_unpublished', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.post( '/worker-platform/orders/:workOrderId/pin', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => pinAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}), + (req) => + pinAdminWorkOrder( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '置顶状态已更新', errorMessage: '更新置顶状态失败', @@ -482,11 +522,26 @@ router.get( }), ) +router.get( + '/worker-platform/orders/:workOrderId/events', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => getAdminWorkOrderEvents(String(req.params.workOrderId || '')), { + successMessage: 'ok', + errorMessage: '读取工单流转记录失败', + scope: '[admin/worker-platform/orders/:workOrderId/events]', + }), +) + router.post( '/worker-platform/orders/:workOrderId/sharing', requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => updateAdminWorkOrderSharing(String(req.params.workOrderId || ''), req.body || {}), + (req) => + updateAdminWorkOrderSharing( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '拼单配置已保存', errorMessage: '保存拼单配置失败', @@ -505,7 +560,12 @@ router.post( '/worker-platform/orders/:workOrderId/problem', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => markAdminWorkOrderProblem(String(req.params.workOrderId || ''), req.body || {}), + (req) => + markAdminWorkOrderProblem( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '订单已标记为问题单', errorMessage: '标记问题单失败', @@ -524,7 +584,12 @@ router.post( '/worker-platform/orders/:workOrderId/problem/resolve', requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => resolveAdminProblemWorkOrder(String(req.params.workOrderId || ''), req.body || {}), + (req) => + resolveAdminProblemWorkOrder( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '问题单已处置', errorMessage: '处置问题单失败', @@ -542,35 +607,42 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/accept', requireAdminRoles(['admin', 'operator']), - createJsonHandler((req) => acceptAdminWorkOrder(String(req.params.workOrderId || '')), { - successMessage: '订单已验收通过', - errorMessage: '验收接单订单失败', - scope: '[admin/worker-platform/orders/:workOrderId/accept]', - audit: (req, data) => ({ - action: 'work_order_accepted', - targetType: 'work_order', - targetId: String(req.params.workOrderId || ''), - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }), + createJsonHandler( + (req) => + acceptAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''), + { + successMessage: '订单已验收通过', + errorMessage: '验收接单订单失败', + scope: '[admin/worker-platform/orders/:workOrderId/accept]', + audit: (req, data) => ({ + action: 'work_order_accepted', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.post( '/worker-platform/orders/accept-batch', requireAdminRoles(['admin', 'operator']), - createJsonHandler((req) => acceptAdminWorkOrders(req.body || {}), { - successMessage: '选中订单已验收通过', - errorMessage: '批量验收接单订单失败', - scope: '[admin/worker-platform/orders/accept-batch]', - audit: (req, data) => ({ - action: 'work_orders_batch_accepted', - targetType: 'work_order', - targetId: Array.isArray(req.body?.workOrderIds) - ? req.body.workOrderIds.map((item: unknown) => String(item)).join(',') - : '', - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }), + createJsonHandler( + (req) => acceptAdminWorkOrders(req.body || {}, req.adminSession?.username || ''), + { + successMessage: '选中订单已验收通过', + errorMessage: '批量验收接单订单失败', + scope: '[admin/worker-platform/orders/accept-batch]', + audit: (req, data) => ({ + action: 'work_orders_batch_accepted', + targetType: 'work_order', + targetId: Array.isArray(req.body?.workOrderIds) + ? req.body.workOrderIds.map((item: unknown) => String(item)).join(',') + : '', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.post( @@ -578,7 +650,11 @@ router.post( requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => - deductAdminWorkOrderPendingDeposit(String(req.params.workOrderId || ''), req.body || {}), + deductAdminWorkOrderPendingDeposit( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), { successMessage: '待解冻押金已扣减', errorMessage: '扣减待解冻押金失败', diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index b3e1683b..5a06311b 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -16,6 +16,7 @@ import { listWorkerProfileWalletLedgers, listWorkerHallOrders, listWorkerMyOrders, + getWorkerMyOrderEvents, loginWorker, logoutWorkerSession, registerWorker, @@ -252,6 +253,20 @@ router.get( }), ) +router.get( + '/orders/:workOrderId/events', + requireActiveWorker, + createRouteHandler( + (req) => + getWorkerMyOrderEvents(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), + { + successMessage: 'ok', + errorMessage: '读取订单流转记录失败', + scope: '[worker/orders/:workOrderId/events]', + }, + ), +) + router.post( '/orders/:workOrderId/note', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 91063d46..f059f41c 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -28,6 +28,7 @@ import { listAllWorkCategories, listWorkerFinanceRequests, listWorkOrders, + listWorkOrderEventsByOrderId, listWorkProductRules, listWorkerLevels, listWorkerUsers, @@ -91,6 +92,7 @@ import { normalizeUploadedFiles, normalizeWorkerType, resolveFreezeDepositAmount, + mapWorkOrderEvents, resolveMatchingProductRule, resolveRequirementFields, resolveSkuNameQuantity, @@ -386,6 +388,7 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload: export async function assignAdminWorkOrderToWorker( workOrderId: number | string, payload: JsonObject = {}, + actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) if (!['unassigned', 'open'].includes(workOrder.status) || workOrder.assigned_worker_id) { @@ -430,7 +433,7 @@ export async function assignAdminWorkOrderToWorker( await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: 'assign_voucher_consume_failed', fromStatus: workOrder.status, toStatus: workOrder.status, @@ -452,6 +455,7 @@ export async function assignAdminWorkOrderToWorker( maxActiveOrders: permissions.maxActiveOrders, deadlineAt: resolveAssignedDeadlineAt(Number(workOrder.timeout_minutes || 0), now), now, + actorName, }) if (failureReason === 'work_order_not_assignable') { throw createHttpError( @@ -595,6 +599,12 @@ export async function reviewAdminWorkerFinanceRequest( } } +export async function getAdminWorkOrderEvents(workOrderId: number | string) { + await getRequiredWorkOrder(workOrderId) + const events = await listWorkOrderEventsByOrderId(workOrderId) + return { items: await mapWorkOrderEvents(events) } +} + export async function listAdminWorkOrders(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) @@ -640,6 +650,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { export async function deductAdminWorkOrderPendingDeposit( workOrderId: number | string, payload: JsonObject = {}, + actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.ACCEPTED) { @@ -683,7 +694,7 @@ export async function deductAdminWorkOrderPendingDeposit( await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: 'pending_deposit_deducted', fromStatus: workOrder.status, toStatus: workOrder.status, @@ -705,6 +716,7 @@ export async function getAdminWorkOrderSharing(workOrderId: number | string) { export async function updateAdminWorkOrderSharing( workOrderId: number | string, payload: JsonObject = {}, + actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) if ( @@ -751,7 +763,7 @@ export async function updateAdminWorkOrderSharing( await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: 'sharing_config_updated', fromStatus: workOrder.status, toStatus: updated?.status || workOrder.status, @@ -780,6 +792,7 @@ function resolveSharingQuantity( export async function submitAdminWorkOrderMaterial( workOrderId: number | string, payload: JsonObject = {}, + actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) if ( @@ -841,7 +854,7 @@ export async function submitAdminWorkOrderMaterial( await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS ? 'material_updated' @@ -942,7 +955,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { export async function updateAdminWorkOrder( workOrderId: number | string, payload: JsonObject = {}, - options: { canEditRequirementFields?: boolean } = {}, + options: { canEditRequirementFields?: boolean; actorName?: string } = {}, ) { const workOrder = await getRequiredWorkOrder(workOrderId) if ( @@ -1006,7 +1019,7 @@ export async function updateAdminWorkOrder( await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: options.actorName || '', eventType: 'edited', fromStatus: workOrder.status, toStatus: workOrder.status, @@ -1061,7 +1074,7 @@ export async function deleteAdminWorkOrder(workOrderId: number | string) { return { deleted: true } } -export async function publishAdminWorkOrder(workOrderId: number | string) { +export async function publishAdminWorkOrder(workOrderId: number | string, actorName = '') { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) { throw createHttpError('只有未分配订单可以发布到大厅', { @@ -1082,7 +1095,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string) { await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: 'publish_voucher_consume_failed', fromStatus: workOrder.status, toStatus: workOrder.status, @@ -1117,7 +1130,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string) { await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: 'published', fromStatus: workOrder.status, toStatus: WORK_ORDER_STATUS.OPEN, @@ -1127,7 +1140,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated), voucherConsume } } -export async function unpublishAdminWorkOrder(workOrderId: number | string) { +export async function unpublishAdminWorkOrder(workOrderId: number | string, actorName = '') { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { throw createHttpError('只有待抢单且未被接走的订单可以下架', { @@ -1144,7 +1157,7 @@ export async function unpublishAdminWorkOrder(workOrderId: number | string) { await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: 'unpublished', fromStatus: workOrder.status, toStatus: WORK_ORDER_STATUS.UNASSIGNED, @@ -1153,7 +1166,11 @@ export async function unpublishAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated || workOrder) } } -export async function pinAdminWorkOrder(workOrderId: number | string, payload: JsonObject = {}) { +export async function pinAdminWorkOrder( + workOrderId: number | string, + payload: JsonObject = {}, + actorName = '', +) { const workOrder = await getRequiredWorkOrder(workOrderId) const pinned = payload.pinned === true if (pinned && workOrder.status !== WORK_ORDER_STATUS.OPEN) { @@ -1170,7 +1187,7 @@ export async function pinAdminWorkOrder(workOrderId: number | string, payload: J await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: pinned ? 'pinned' : 'unpinned', fromStatus: workOrder.status, toStatus: workOrder.status, @@ -1182,6 +1199,7 @@ export async function pinAdminWorkOrder(workOrderId: number | string, payload: J export async function markAdminWorkOrderProblem( workOrderId: number | string, payload: JsonObject = {}, + actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) if ( @@ -1204,7 +1222,7 @@ export async function markAdminWorkOrderProblem( await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'admin', - actorId: '', + actorId: actorName, eventType: 'marked_problem', fromStatus: workOrder.status, toStatus: WORK_ORDER_STATUS.PROBLEM, @@ -1217,6 +1235,7 @@ export async function markAdminWorkOrderProblem( export async function resolveAdminProblemWorkOrder( workOrderId: number | string, payload: JsonObject = {}, + actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.PROBLEM) { @@ -1240,6 +1259,7 @@ export async function resolveAdminProblemWorkOrder( action, note, now: nowIso(), + actorName, }) if (!updated) { throw createHttpError('问题单处置失败,订单状态可能已变化', { @@ -1250,7 +1270,7 @@ export async function resolveAdminProblemWorkOrder( return { order: mapWorkOrderAdmin(updated) } } -export async function acceptAdminWorkOrder(workOrderId: number | string) { +export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { throw createHttpError('只有待验收订单可以通过验收', { @@ -1263,6 +1283,8 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) { workOrderId: workOrder.id, unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays, now, + actorType: 'admin', + actorName, }) if (failureReason === 'sharing_pending_submissions') { throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', { @@ -1279,7 +1301,7 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated || workOrder) } } -export async function acceptAdminWorkOrders(payload: JsonObject = {}) { +export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') { const workOrderIds = [ ...new Set( (Array.isArray(payload.workOrderIds) ? payload.workOrderIds : []) @@ -1316,12 +1338,12 @@ export async function acceptAdminWorkOrders(payload: JsonObject = {}) { const results = [] for (const workOrder of workOrders) { - results.push(await acceptAdminWorkOrder(workOrder.id)) + results.push(await acceptAdminWorkOrder(workOrder.id, actorName)) } return { orders: results.map((result) => result.order) } } -export async function unassignAdminWorkOrder(workOrderId: number | string) { +export async function unassignAdminWorkOrder(workOrderId: number | string, actorName = '') { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.IN_PROGRESS || !workOrder.assigned_worker_id) { throw createHttpError('只有进行中且已指派打手的订单可以取消指派', { @@ -1333,6 +1355,7 @@ export async function unassignAdminWorkOrder(workOrderId: number | string) { const { order: updated, failureReason } = await unassignWorkOrder({ workOrderId: workOrder.id, now, + actorName, }) if (failureReason === 'work_order_not_in_progress') { throw createHttpError('订单状态已变化,无法取消指派', { @@ -1355,7 +1378,11 @@ export async function unassignAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated) } } -export async function cancelAdminWorkOrder(workOrderId: number | string, payload: JsonObject = {}) { +export async function cancelAdminWorkOrder( + workOrderId: number | string, + payload: JsonObject = {}, + actorName = '', +) { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.IN_PROGRESS || !workOrder.assigned_worker_id) { throw createHttpError('只有代练中且已接单的订单可以撤单', { @@ -1381,6 +1408,7 @@ export async function cancelAdminWorkOrder(workOrderId: number | string, payload workOrderId: workOrder.id, now: nowIso(), reason, + actorName, } const { order: updated, failureReason } = action === 'return_to_hall' diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 17f3ed4b..38a0ab72 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -3,6 +3,7 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' import { addWorkerWalletCredit, + getWorkerUserById, type WorkCategoryRow, type WorkerFinanceRequestRow, type WorkOrderRow, @@ -1028,3 +1029,78 @@ export const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([ 'worker_auth_user_invalid', 'worker_auth_stale', ]) + +export type WorkOrderEventView = { + id: number + time: string + actorType: string + actorName: string + eventType: string + fromStatus: string + toStatus: string + payload: JsonObject +} + +/** + * 将工单事件原始行映射为带操作人显示名的视图。 + * admin 事件的操作人来自 actor_id(管理员用户名);worker 事件按打手表解析显示名。 + */ +export async function mapWorkOrderEvents( + events: Array<{ + id: number + actor_type: string + actor_id: string + event_type: string + from_status: string + to_status: string + payload_json: string | Record + created_at: string + }>, +): Promise { + const workerIds = [ + ...new Set( + events + .filter((event) => event.actor_type === 'worker') + .map((event) => Number(event.actor_id)) + .filter((id) => Number.isFinite(id) && id > 0), + ), + ] + const workerNameById = new Map() + for (const workerId of workerIds) { + const worker = await getWorkerUserById(workerId) + if (worker) { + workerNameById.set(workerId, worker.display_name || worker.username || `打手#${workerId}`) + } + } + + return events.map((event) => ({ + id: Number(event.id), + time: new Date(event.created_at).toISOString(), + actorType: event.actor_type, + actorName: resolveWorkOrderEventActorName(event, workerNameById), + eventType: event.event_type, + fromStatus: event.from_status, + toStatus: event.to_status, + payload: safeParseJson(event.payload_json), + })) +} + +function resolveWorkOrderEventActorName( + event: { actor_type: string; actor_id: string }, + workerNameById: Map, +) { + const actorId = String(event.actor_id || '').trim() + if (event.actor_type === 'worker') { + return workerNameById.get(Number(actorId)) || (actorId ? `打手#${actorId}` : '打手') + } + if (event.actor_type === 'admin') { + return actorId || '管理员' + } + if (event.actor_type === 'system') { + return '系统' + } + if (event.actor_type === 'collect') { + return '采集' + } + return actorId || '未知' +} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index cee6f967..e6900ae1 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -37,6 +37,7 @@ import { listWorkerWorkOrderNotes, listWorkCategories, listWorkOrders, + listWorkOrderEventsByOrderId, listWorkerLevels, listDueDepositUnfreezes, listOverdueWorkOrders, @@ -79,6 +80,7 @@ import { mapWorkCategory, mapWorkOrderForWorker, mapWorkOrderPublic, + mapWorkOrderEvents, mapWorkOrderShare, mapWorkerUser, normalizeAmountFen, @@ -903,6 +905,21 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: throwGrabWorkOrderFailure(grabbed.failureReason) } + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'worker', + actorId: String(worker.id), + eventType: 'grabbed', + fromStatus: workOrder.status, + toStatus: grabbed.order.status, + payloadJson: JSON.stringify({ + workerId: worker.id, + workerName: worker.display_name || worker.username, + internal: worker.worker_type === 'internal', + }), + now: nowIso(), + }) + return { order: mapWorkOrderForWorker(grabbed.order, permissions) } } @@ -1018,6 +1035,24 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker } } +export async function getWorkerMyOrderEvents(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_events_owner_required', + }) + } + const events = await listWorkOrderEventsByOrderId(workOrderId) + return { items: await mapWorkOrderEvents(events) } +} + export async function saveWorkerOrderNote( workOrderId: number | string, payload: JsonObject = {}, @@ -1190,6 +1225,8 @@ export async function submitWorkerOrderAcceptance( workOrderId: workOrder.id, unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays, now, + actorType: 'worker', + actorName: worker.display_name || worker.username || '', }) if (failureReason === 'sharing_pending_submissions') { throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', { diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx new file mode 100644 index 00000000..941f3c0e --- /dev/null +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -0,0 +1,267 @@ +import { ClockCircleOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { Empty, Skeleton, Tag, Timeline, Tooltip, Typography } from 'antd' +import dayjs from '@/lib/dayjs' + +import { fetchAdminWorkOrderEvents } from '@/services/admin' +import { fetchWorkerMyOrderEvents } from '@/services/worker' +import type { WorkOrderEvent } from '@/types/worker-platform' + +type EventNodeStyle = { + /** 节点标题(主文案) */ + title: string + /** 补充说明(次文案) */ + description?: string + /** Timeline 节点颜色 */ + color: string +} + +/** 工单状态中文名 */ +const STATUS_LABELS: Record = { + pending_material: '待完善资料', + unassigned: '未分配', + open: '待抢单', + in_progress: '代练中', + pending_acceptance: '待验收', + problem: '问题单', + accepted: '已验收', + cancelled: '已取消', +} + +function formatStatusLabel(status: string) { + return STATUS_LABELS[status] || status || '未知' +} + +function readPayload(event: WorkOrderEvent, key: string): string { + const value = event.payload?.[key] + if (value === undefined || value === null) return '' + return String(value).trim() +} + +/** 事件类型 → 节点阶段文案与颜色 */ +export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { + const type = event.eventType + const note = readPayload(event, 'note') || readPayload(event, 'reason') + + switch (type) { + // —— 创建节点 —— + case 'source_synced': + return { + title: '系统收到推送的新订单,自动生成该笔订单', + color: 'blue', + } + case 'mock_created': + return { title: '本地 Mock 生成该笔订单', color: 'blue' } + + // —— 发布节点 —— + case 'published': + return { title: '将订单发单至内部接单大厅', color: 'geekblue' } + case 'unpublished': + return { title: '将订单下架,退回未分配状态', color: 'geekblue' } + case 'pinned': + return { title: '将订单置顶,抢单大厅优先展示', color: 'gold' } + case 'unpinned': + return { title: '取消订单置顶', color: 'gold' } + + // —— 接手节点 —— + case 'grabbed': { + const workerName = readPayload(event, 'workerName') + return { + title: workerName + ? `通过接单大厅自助接手此单,该订单当前接手人:${workerName}` + : '通过接单大厅自助接手此单', + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'green', + } + } + case 'assigned_by_admin': { + const workerId = readPayload(event, 'workerId') + return { + title: `指派打手接手此单${workerId ? `(打手 #${workerId})` : ''}`, + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'green', + } + } + case 'unassigned_by_admin': + return { title: '取消指派,订单回到未分配状态', color: 'green' } + case 'returned_to_hall_by_admin': + return { + title: note ? `撤回订单到抢单大厅(${note})` : '撤回订单到抢单大厅', + color: 'green', + } + case 'cancelled_by_worker': + return { title: '打手取消接单', color: 'red' } + case 'timeout_reopen': + return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' } + + // —— 资料节点 —— + case 'material_submitted': + return { title: '提交了订单资料', color: 'cyan' } + case 'material_completed': + return { title: '完善了订单资料', color: 'cyan' } + case 'material_updated': + return { title: '更新了订单资料', color: 'cyan' } + + // —— 验收节点 —— + case 'acceptance_submitted': { + const imageCount = readPayload(event, 'imageCount') + return { + title: `提交验收资料${imageCount ? `(${imageCount} 张图)` : ''}`, + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'purple', + } + } + case 'acceptance_updated': + return { title: '补充了验收资料', color: 'purple' } + case 'acceptance_draft_saved': + return { title: '暂存了验收草稿', color: 'purple' } + case 'accepted_by_internal': + return { + title: '内部打手自助验收通过,订单完成', + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'purple', + } + case 'accepted': + case 'accepted_by_admin': + return { + title: '验收通过,订单完成', + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'purple', + } + + // —— 问题节点 —— + case 'marked_problem': + return { + title: note ? `将订单标记为问题单:${note}` : '将订单标记为问题单', + color: 'red', + } + case 'problem_return_to_worker': + return { title: '问题单已处置:退回打手继续处理', color: 'orange' } + case 'problem_reopen': + return { title: '问题单已处置:重新开工', color: 'orange' } + case 'problem_cancel_release': + return { title: '问题单已处置:取消订单并释放押金', color: 'orange' } + case 'problem_cancel_deduct': + return { title: '问题单已处置:取消订单并扣除押金', color: 'orange' } + + // —— 取消节点 —— + case 'cancelled_by_admin': + return { + title: note ? `取消订单(${note})` : '取消订单', + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'red', + } + + // —— 其他 —— + case 'edited': + return { title: '更改了订单信息', color: 'default' } + case 'sharing_config_updated': + return { title: '更新了拼单配置', color: 'default' } + case 'pending_deposit_deducted': + return { title: '扣减了待解冻押金', color: 'default' } + case 'voucher_backfilled': + return { title: '回填了电子凭证信息', color: 'default' } + case 'assign_voucher_consume_failed': + case 'publish_voucher_consume_failed': + return { title: '电子凭证核销失败,本次操作未生效', color: 'red' } + + default: + // 通用状态流转兜底 + if (event.fromStatus && event.toStatus && event.fromStatus !== event.toStatus) { + return { + title: `订单状态变更:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'default', + } + } + return { title: type, color: 'default' } + } +} + +/** 订单详情中的流转节点时间线(管理端 / 打手端共用) */ +export default function WorkOrderEventTimeline({ + orderId, + viewer, +}: { + orderId: number + viewer: 'admin' | 'worker' +}) { + const eventsQuery = useQuery({ + queryKey: ['work-order-events', viewer, orderId], + queryFn: () => + viewer === 'admin' ? fetchAdminWorkOrderEvents(orderId) : fetchWorkerMyOrderEvents(orderId), + enabled: Boolean(orderId), + retry: false, + }) + + if (eventsQuery.isLoading) { + return + } + if (eventsQuery.error) { + return ( + + {eventsQuery.error instanceof Error ? eventsQuery.error.message : '读取流转记录失败'} + + ) + } + + const events = eventsQuery.data?.data?.items || [] + if (events.length === 0) { + return + } + + // 倒序:最新节点在上 + const items = [...events].reverse().map((event) => { + const style = formatWorkOrderEvent(event) + return { + color: style.color, + children: ( +
+
+ {style.title} + + + {dayjs(event.time).format('MM-DD HH:mm')} + + +
+ {style.description ? ( + + {style.description} + + ) : null} +
+ + {event.actorName || event.actorType} + +
+
+ ), + } + }) + + return +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 5681d310..1331260b 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -40,6 +40,7 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import JsonPreview from '@/components/admin/JsonPreview' +import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImageUpload from '@/components/files/ImageUpload' import ImagePreviewList from '@/components/files/ImagePreviewList' import { @@ -1101,6 +1102,10 @@ export default function WorkOrdersPanel() { + + + + ) : null} + + + + ) : null} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 6e7f653f..f6bed1e6 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -3,6 +3,7 @@ import type { UploadedFile, WorkCategory, WorkOrder, + WorkOrderEvent, WorkOrderShare, WorkProductRule, WorkerFinanceConfig, @@ -200,6 +201,12 @@ export function fetchAdminWorkOrders(params?: Record) { return apiGet>('/api/v1/admin/worker-platform/orders', params) } +export function fetchAdminWorkOrderEvents(workOrderId: number) { + return apiGet<{ items: WorkOrderEvent[] }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/events`, + ) +} + export function createAdminMockWorkOrder(payload: { platformOrderId?: string productName?: string diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index e9d5da43..d0290770 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -3,6 +3,7 @@ import type { CollectLookupResponse, UploadedFile, WorkOrder, + WorkOrderEvent, WorkOrderShare, WorkerFinanceRequest, WorkerHallOrdersResponse, @@ -123,6 +124,10 @@ export function fetchWorkerMyOrders(params?: Record) { return apiGet>('/api/v1/worker/orders', params) } +export function fetchWorkerMyOrderEvents(workOrderId: number) { + return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`) +} + 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 f8631500..fa6fff57 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -168,6 +168,40 @@ gap: 16px; } +/* ===== 工单流转记录时间线 ===== */ +.work-order-event-timeline { + margin-top: 4px; +} + +.work-order-event-item { + display: grid; + gap: 4px; +} + +.work-order-event-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.work-order-event-head .ant-typography { + margin: 0; +} + +.work-order-event-time { + font-size: 12px; + white-space: nowrap; +} + +.work-order-event-actor .ant-tag { + margin-inline-end: 0; +} + +.work-order-event-desc { + font-size: 12px; +} + .worker-order-actions { display: grid; justify-items: start; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 409b4bce..3de9d077 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -269,6 +269,18 @@ export type WorkOrder = { updatedAt: string } +/** 工单流转事件(订单详情中的操作日志) */ +export type WorkOrderEvent = { + id: number + time: string + actorType: string + actorName: string + eventType: string + fromStatus: string + toStatus: string + payload: Record +} + export type Pagination = { page: number pageSize: number