diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 4d064763..b350d11d 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -338,6 +338,7 @@ export type ListInput = { keyword?: string /** 打手类型筛选:internal 内部打手 / external 外部打手 */ workerType?: string + /** 后台打手列表按主键精确定位。 */ workerId?: number /** 后台待验收筛选:普通单按接单人,拼单按参与打手等级。 */ workerLevelIds?: number[] diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index 72648eae..b9e0e819 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -483,8 +483,9 @@ export async function listWorkerUsers({ status = '', keyword = '', workerType = '', + workerId = 0, }: ListInput = {}): Promise<{ items: WorkerUserRow[]; total: number }> { - const { whereClause, params } = buildWorkerUserWhere({ status, keyword, workerType }) + const { whereClause, params } = buildWorkerUserWhere({ status, keyword, workerType, workerId }) const totalResult = await query<{ total: number }>( `SELECT COUNT(*)::int AS total FROM worker_users wu ${whereClause}`, params, @@ -1257,7 +1258,8 @@ function buildWorkerUserWhere({ status = '', keyword = '', workerType = '', -}: Pick) { + workerId = 0, +}: Pick) { const filters: string[] = [] const params: unknown[] = [] if (status) { @@ -1274,6 +1276,10 @@ function buildWorkerUserWhere({ params.push(workerType) filters.push(`wu.worker_type = $${params.length}`) } + if (workerId) { + params.push(workerId) + filters.push(`wu.id = $${params.length}`) + } return { whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', params, diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 6d1e2388..2359dd9b 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -1003,9 +1003,11 @@ export async function deleteAdminWorkProductRule(ruleId: number | string) { export async function listAdminWorkerUsers(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) + const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0 const { items, total } = await listWorkerUsers({ page, pageSize, + workerId, status: String(query.status || '').trim(), keyword: String(query.keyword || '').trim(), workerType: String(query.workerType || query.worker_type || '').trim(), @@ -1445,7 +1447,7 @@ function mapAdminWorkerWithdrawalAccount(account: WorkerWithdrawalAccountRow) { export async function getAdminWorkOrderEvents(workOrderId: number | string) { await getRequiredWorkOrder(workOrderId) const events = await listWorkOrderEventsByOrderId(workOrderId) - return { items: await mapWorkOrderEvents(events) } + return { items: await mapWorkOrderEvents(events, { includeWorkerSummary: true }) } } export async function listAdminWorkOrders(query: JsonObject = {}) { diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index a2abb202..90ed324c 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -1686,6 +1686,15 @@ export type WorkOrderEventView = { fromStatus: string toStatus: string payload: JsonObject + /** 仅管理端流转记录返回,供查看事件涉及的打手信息。 */ + worker?: { + workerId: number + displayName: string + username: string + workerType: string + levelName: string + status: string + } } /** @@ -1703,33 +1712,64 @@ export async function mapWorkOrderEvents( payload_json: string | Record created_at: string }>, + options: { includeWorkerSummary?: boolean } = {}, ): 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), + events.map(resolveWorkOrderEventWorkerId).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}`) - } - } + const workers = await Promise.all(workerIds.map((workerId) => getWorkerUserById(workerId))) + const workerById = new Map( + workers + .filter((worker): worker is WorkerUserRow => Boolean(worker)) + .map((worker) => [Number(worker.id), worker]), + ) + const workerNameById = new Map( + [...workerById.entries()].map(([workerId, worker]) => [ + 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), - })) + return events.map((event) => { + const workerId = resolveWorkOrderEventWorkerId(event) + const worker = workerById.get(workerId) + return { + 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), + ...(options.includeWorkerSummary && worker + ? { + worker: { + workerId: Number(worker.id), + displayName: worker.display_name || worker.username || `打手#${worker.id}`, + username: worker.username, + workerType: normalizeWorkerType(worker.worker_type), + levelName: worker.level_name || '-', + status: worker.status, + }, + } + : {}), + } + }) +} + +/** 操作人优先,其次读取事件载荷中的关联打手。 */ +function resolveWorkOrderEventWorkerId(event: { + actor_type: string + actor_id: string + payload_json: string | Record +}) { + const actorWorkerId = event.actor_type === 'worker' ? Number(event.actor_id) : 0 + if (Number.isInteger(actorWorkerId) && actorWorkerId > 0) return actorWorkerId + const payloadWorkerId = Number(safeParseJson(event.payload_json).workerId || 0) + return Number.isInteger(payloadWorkerId) && payloadWorkerId > 0 ? payloadWorkerId : 0 } function resolveWorkOrderEventActorName( diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index dc27caac..70fafbaf 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -1,11 +1,13 @@ -import { ClockCircleOutlined } from '@ant-design/icons' +import { ClockCircleOutlined, LinkOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' -import { Empty, Skeleton, Tag, Timeline, Tooltip, Typography } from 'antd' +import { Empty, Skeleton, Space, Tag, Timeline, Tooltip, Typography } from 'antd' +import { useNavigate } from 'react-router' import dayjs from '@/lib/dayjs' import { fetchAdminWorkOrderEvents } from '@/services/admin' import { fetchWorkerMyOrderEvents } from '@/services/worker' import type { WorkOrderEvent } from '@/types/worker-platform' +import { getAdminRole } from '@/utils/admin-auth' type EventNodeStyle = { /** 节点标题(主文案) */ @@ -279,6 +281,8 @@ export default function WorkOrderEventTimeline({ orderId: number viewer: 'admin' | 'worker' }) { + const navigate = useNavigate() + const canNavigateToWorker = viewer === 'admin' && getAdminRole() !== 'support' const eventsQuery = useQuery({ queryKey: ['work-order-events', viewer, orderId], queryFn: () => @@ -324,19 +328,42 @@ export default function WorkOrderEventTimeline({ ) : null}
- - {event.actorName || event.actorType} - + {event.worker ? ( + + { + if (!canNavigateToWorker) return + void navigate( + `/admin/worker-platform?tab=workers&workerId=${event.worker?.workerId}`, + ) + }} + > + {event.worker.displayName} #{event.worker.workerId} + {canNavigateToWorker ? : null} + + + {event.worker.username} ·{' '} + {event.worker.workerType === 'internal' ? '内部' : '外部'} ·{' '} + {event.worker.levelName} · {formatWorkerStatus(event.worker.status)} + + + ) : ( + + {event.actorName || event.actorType} + + )}
), @@ -345,3 +372,13 @@ export default function WorkOrderEventTimeline({ return } + +function formatWorkerStatus(status: string) { + const labels: Record = { + active: '已启用', + pending_review: '待审核', + rejected: '已冻结', + disabled: '已停用', + } + return labels[status] || status || '未知状态' +} diff --git a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx index 84889956..8806a958 100644 --- a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx @@ -15,7 +15,8 @@ import { Typography, } from 'antd' import type { TableColumnsType } from 'antd' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' import { creditAdminWorkerWallet, @@ -50,10 +51,12 @@ type FreezeWorkerFormValues = { export default function WorkersPanel() { const { message, modal } = App.useApp() const queryClient = useQueryClient() + const [searchParams, setSearchParams] = useSearchParams() const [status, setStatus] = useState('') const [keywordInput, setKeywordInput] = useState('') const [keyword, setKeyword] = useState('') const [workerType, setWorkerType] = useState('') + const [targetWorkerId, setTargetWorkerId] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [creditWorker, setCreditWorker] = useState(null) @@ -74,9 +77,39 @@ export default function WorkersPanel() { (account) => account.accountChannel === 'alipay', ) + useEffect(() => { + const workerId = Number(searchParams.get('workerId') || 0) + if (!Number.isInteger(workerId) || workerId <= 0) { + setTargetWorkerId(0) + return + } + setTargetWorkerId(workerId) + setKeywordInput('') + setKeyword('') + setStatus('') + setWorkerType('') + setPage(1) + }, [searchParams]) + const workersQuery = useQuery({ - queryKey: ['admin-worker-platform-workers', status, keyword, workerType, page, pageSize], - queryFn: () => fetchAdminWorkerUsers({ status, keyword, workerType, page, pageSize }), + queryKey: [ + 'admin-worker-platform-workers', + status, + keyword, + workerType, + targetWorkerId, + page, + pageSize, + ], + queryFn: () => + fetchAdminWorkerUsers({ + status, + keyword, + workerType, + workerId: targetWorkerId || undefined, + page, + pageSize, + }), }) const levelsQuery = useQuery({ queryKey: ['admin-worker-platform-levels'], @@ -105,7 +138,15 @@ export default function WorkersPanel() { setKeyword('') setStatus('') setWorkerType('') + setTargetWorkerId(0) setPage(1) + setSearchParams( + (current) => { + current.delete('workerId') + return current + }, + { replace: true }, + ) } async function review(worker: WorkerUser, nextStatus: string) { @@ -431,6 +472,9 @@ export default function WorkersPanel() { } bordered={false} > + {targetWorkerId ? ( + 已定位打手 #{targetWorkerId} + ) : null} rowKey="workerId" loading={workersQuery.isLoading} diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 3deb807e..6ffcc04f 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -292,6 +292,18 @@ margin-inline-end: 0; } +.work-order-event-worker-link { + cursor: pointer; +} + +.work-order-event-worker-link .anticon { + margin-left: 4px; +} + +.work-order-event-worker-meta { + font-size: 12px; +} + .work-order-event-desc { font-size: 12px; } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 36aa9ad8..b461d52c 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -495,6 +495,14 @@ export type WorkOrderEvent = { fromStatus: string toStatus: string payload: Record + worker?: { + workerId: number + displayName: string + username: string + workerType: string + levelName: string + status: string + } } export type Pagination = {