From bca2e4a80196a3b2cfe34a186712317ac488095e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 13:06:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=93=E6=89=8B=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E7=BB=93=E7=AE=97=E5=92=8C=E5=A4=A7=E5=8E=85=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/work-order-repo.ts | 83 ++++++++++++ apps/backend/src/routes/worker.ts | 11 ++ .../worker-platform/worker-service.ts | 35 +++++ .../src/pages/worker/WorkerHallPage.tsx | 121 ++++++++++-------- .../src/pages/worker/WorkerOrdersPage.tsx | 56 +++++--- apps/frontend/src/services/worker.ts | 5 + apps/frontend/src/styles/worker.css | 59 ++++++++- apps/frontend/src/types/worker-platform.ts | 10 ++ 8 files changed, 306 insertions(+), 74 deletions(-) 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 bc129abf..e48c212c 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -48,6 +48,14 @@ const WORK_ORDER_SHARE_SELECT = ` LEFT JOIN worker_users wu ON wu.id = wos.worker_id ` +type WorkerOrderOverviewRow = { + status: string + order_count: number + settlement_amount: number + vip_evidence_pending_count: number + vip_evidence_pending_settlement_amount: number +} + export async function listWorkOrderShares( workOrderId: number | string, ): Promise { @@ -820,6 +828,81 @@ export async function listWorkOrders({ return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } } +/** 汇总打手本人各订单状态的可结算金额,拼单按个人份额计算。 */ +export async function getWorkerOrderOverview(workerId: number | string): Promise< + Array<{ + status: string + orderCount: number + settlementAmount: number + vipEvidencePendingCount: number + vipEvidencePendingSettlementAmount: number + }> +> { + const normalizedWorkerId = Number(workerId) + const result = await query( + ` + WITH worker_orders AS ( + SELECT + wo.status, + wo.acceptance_json, + wos.id IS NOT NULL AS is_worker_share, + CASE + WHEN wos.id IS NOT NULL THEN wos.share_reward + WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount + ELSE 0 + END AS worker_settlement_amount + FROM work_orders wo + LEFT JOIN work_order_shares wos + ON wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + WHERE wo.assigned_worker_id = $1 + OR wo.last_assigned_worker_id = $1 + OR wos.id IS NOT NULL + ) + SELECT + status, + COUNT(*)::int AS order_count, + COALESCE( + SUM( + CASE + WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') + OR (status = 'open' AND is_worker_share) + THEN worker_settlement_amount + ELSE 0 + END + ), + 0 + )::int AS settlement_amount, + COUNT(*) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + )::int AS vip_evidence_pending_count, + COALESCE( + SUM(worker_settlement_amount) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + ), + 0 + )::int AS vip_evidence_pending_settlement_amount + FROM worker_orders + GROUP BY status + `, + [normalizedWorkerId], + ) + return result.rows.map((row) => ({ + status: row.status, + orderCount: Number(row.order_count || 0), + settlementAmount: Number(row.settlement_amount || 0), + vipEvidencePendingCount: Number(row.vip_evidence_pending_count || 0), + vipEvidencePendingSettlementAmount: Number(row.vip_evidence_pending_settlement_amount || 0), + })) +} + export async function updateWorkOrder( workOrderId: number | string, patch: Partial< diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 54821e6e..3a87518c 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -11,6 +11,7 @@ import { getWorkerProfile, getWorkerSessionSummary, getWorkerHallLeaderboard, + getWorkerMyOrderOverview, grabWorkerHallOrder, joinWorkerSharingOrder, listWorkerProfileFinanceRequests, @@ -268,6 +269,16 @@ router.get( }), ) +router.get( + '/orders/overview', + requireActiveWorker, + createRouteHandler((req) => getWorkerMyOrderOverview(getRequiredWorkerSession(req)), { + successMessage: 'ok', + errorMessage: '读取我的订单概览失败', + scope: '[worker/orders/overview]', + }), +) + router.get( '/orders/:workOrderId/events', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 2bd0376b..3bdf9a5b 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -28,6 +28,7 @@ import { getWorkerUserByDisplayName, getWorkOrderById, getWorkOrderShare, + getWorkerOrderOverview, grabWorkOrder, incrementWorkerSessionVersion, joinWorkOrderShare, @@ -1148,6 +1149,40 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker } } +/** 打手订单标签概览:订单数与预计/已结算金额。 */ +export async function getWorkerMyOrderOverview(session: WorkerSession) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + await settleOverdueWorkOrders({ workerId: worker.id, limit: 50 }) + const rows = await getWorkerOrderOverview(worker.id) + const items = rows.map((row) => ({ + status: row.status, + orderCount: row.orderCount, + settlementAmount: row.settlementAmount, + })) + const vipEvidencePending = rows.reduce( + (summary, row) => ({ + orderCount: summary.orderCount + row.vipEvidencePendingCount, + settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount, + }), + { orderCount: 0, settlementAmount: 0 }, + ) + const all = items.reduce( + (summary, item) => ({ + orderCount: summary.orderCount + item.orderCount, + settlementAmount: summary.settlementAmount + item.settlementAmount, + }), + { orderCount: 0, settlementAmount: 0 }, + ) + return { + items: [ + ...items, + { status: 'vip_evidence_pending', ...vipEvidencePending }, + { status: 'all', ...all }, + ], + } +} + export async function getWorkerMyOrderEvents(workOrderId: number | string, session: WorkerSession) { requireActiveWorkerSession(session) if (runtimeConfig.worker.orderTimelineVisible !== true) { diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index ce00f0a6..a2a641d2 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -5,7 +5,7 @@ import { SearchOutlined, TrophyOutlined, } from '@ant-design/icons' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { Alert, App, @@ -17,7 +17,6 @@ import { Input, InputNumber, Modal, - Pagination, Space, Spin, Switch, @@ -26,7 +25,7 @@ import { Tooltip, Typography, } from 'antd' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { fetchWorkerHallOrders, @@ -44,6 +43,8 @@ type HallSummary = { problemCount: number } +const HALL_PAGE_SIZE = 20 + export default function WorkerHallPage() { const isMobile = useIsMobile() const { message } = App.useApp() @@ -51,14 +52,13 @@ export default function WorkerHallPage() { const [keywordInput, setKeywordInput] = useState('') const [keyword, setKeyword] = useState('') const [categoryId, setCategoryId] = useState(0) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(20) const [grabbingId, setGrabbingId] = useState(0) const [sharingOrder, setSharingOrder] = useState(null) const [leaderboardOpen, setLeaderboardOpen] = useState(false) const [sharingQuantity, setSharingQuantity] = useState(1) const [submittingSharing, setSubmittingSharing] = useState(false) const [autoRefresh, setAutoRefresh] = useState(loadAutoRefreshPreference) + const loadMoreRef = useRef(null) useEffect(() => { if (!autoRefresh) return @@ -73,10 +73,22 @@ export default function WorkerHallPage() { localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, enabled ? '1' : '0') } - const ordersQuery = useQuery({ - queryKey: ['worker-hall-orders', keyword, categoryId, page, pageSize], - queryFn: () => - fetchWorkerHallOrders({ keyword, page, pageSize, categoryId: categoryId || undefined }), + const ordersQuery = useInfiniteQuery({ + queryKey: ['worker-hall-orders', keyword, categoryId], + initialPageParam: 1, + queryFn: ({ pageParam }) => + fetchWorkerHallOrders({ + keyword, + page: Number(pageParam), + pageSize: HALL_PAGE_SIZE, + categoryId: categoryId || undefined, + }), + getNextPageParam: (lastPage) => { + const pagination = lastPage.data.pagination + return pagination.page * pagination.pageSize < pagination.total + ? pagination.page + 1 + : undefined + }, retry: false, }) @@ -121,9 +133,10 @@ export default function WorkerHallPage() { retry: false, }) - const orders = ordersQuery.data?.data.items || [] - const pagination = ordersQuery.data?.data.pagination - const categories = ordersQuery.data?.data.categories || [] + const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || [] + const firstPage = ordersQuery.data?.pages[0]?.data + const categories = firstPage?.categories || [] + const totalOrderCount = Number(firstPage?.pagination.total || 0) const worker = profileQuery.data?.data.worker const activeCount = Number(profileQuery.data?.data.summary.activeOrderCount || 0) const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0 @@ -132,9 +145,36 @@ export default function WorkerHallPage() { const remainingSlots = maxActiveOrders > 0 ? Math.max(0, maxActiveOrders - activeCount) : null const shouldWarn = (remainingSlots !== null && remainingSlots <= 0) || problemCount > 0 + useEffect(() => { + const target = loadMoreRef.current + if ( + !target || + !ordersQuery.hasNextPage || + ordersQuery.isFetchingNextPage || + ordersQuery.isFetchNextPageError + ) { + return + } + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + void ordersQuery.fetchNextPage() + } + }, + { rootMargin: '320px 0px' }, + ) + observer.observe(target) + return () => observer.disconnect() + }, [ + ordersQuery.fetchNextPage, + ordersQuery.hasNextPage, + ordersQuery.isFetchNextPageError, + ordersQuery.isFetchingNextPage, + ]) + async function refreshAll() { await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }), + queryClient.resetQueries({ queryKey: ['worker-hall-orders'] }), queryClient.invalidateQueries({ queryKey: ['worker-hall-summary'] }), queryClient.invalidateQueries({ queryKey: ['worker-profile'] }), queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }), @@ -195,9 +235,20 @@ export default function WorkerHallPage() { function applyKeywordSearch() { setKeyword(keywordInput.trim()) - setPage(1) } + const loadMoreIndicator = + orders.length > 0 ? ( +
+ {ordersQuery.isFetchingNextPage ? : null} + {ordersQuery.isFetchingNextPage ? 加载中 : null} + {ordersQuery.isFetchNextPageError ? 加载失败,请刷新后重试 : null} + {!ordersQuery.hasNextPage && !ordersQuery.isFetchingNextPage ? ( + 已加载全部 {totalOrderCount} 条 + ) : null} +
+ ) : null + const summaryItems = [ { label: '当前余额', @@ -272,7 +323,6 @@ export default function WorkerHallPage() { className={`worker-hall-mobile-cat-pill ${categoryId === 0 ? 'active' : ''}`} onClick={() => { setCategoryId(0) - setPage(1) }} > 全部 @@ -284,7 +334,6 @@ export default function WorkerHallPage() { className={`worker-hall-mobile-cat-pill ${categoryId === category.categoryId ? 'active' : ''}`} onClick={() => { setCategoryId(category.categoryId) - setPage(1) }} > {category.name} @@ -306,7 +355,6 @@ export default function WorkerHallPage() { setKeywordInput(nextKeyword) if (!nextKeyword.trim()) { setKeyword('') - setPage(1) } }} onPressEnter={applyKeywordSearch} @@ -340,7 +388,7 @@ export default function WorkerHallPage() {
- ) : ordersQuery.error ? ( + ) : ordersQuery.error && orders.length === 0 ? ( 0 ? ( -
- { - setPage(nextPage) - setPageSize(nextPageSize) - }} - /> -
- ) : null} + {loadMoreIndicator} ) : ( @@ -528,7 +563,6 @@ export default function WorkerHallPage() { setKeywordInput(nextKeyword) if (!nextKeyword.trim()) { setKeyword('') - setPage(1) } }} onPressEnter={applyKeywordSearch} @@ -569,7 +603,6 @@ export default function WorkerHallPage() { ]} onChange={(nextKey) => { setCategoryId(Number(nextKey) || 0) - setPage(1) }} /> ) : null} @@ -591,7 +624,7 @@ export default function WorkerHallPage() {
- ) : ordersQuery.error ? ( + ) : ordersQuery.error && orders.length === 0 ? ( - {(pagination?.total || 0) > 0 ? ( -
- `共 ${total} 条`} - onChange={(nextPage, nextPageSize) => { - setPage(nextPage) - setPageSize(nextPageSize) - }} - /> -
- ) : null} + {loadMoreIndicator} )} diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 7fff9770..0b1fae57 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -34,6 +34,7 @@ import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImagePreviewList from '@/components/files/ImagePreviewList' import ImageUpload from '@/components/files/ImageUpload' import { + fetchWorkerMyOrderOverview, fetchWorkerMyOrders, fetchWorkerProfile, saveWorkerAcceptanceDraft, @@ -215,17 +216,12 @@ export default function WorkerOrdersPage() { const overviewQuery = useQuery({ queryKey: ['worker-my-orders-overview'], - queryFn: async () => { - const results = await Promise.all( - STATUS_OPTIONS.map(async (item) => { - const response = await fetchWorkerMyOrders(buildWorkerOrderQuery(item.value, '', 1, 1)) - return [item.value || 'all', response.data.pagination.total] as const - }), - ) - return Object.fromEntries(results) as Record - }, + queryFn: fetchWorkerMyOrderOverview, retry: false, }) + const overviewByStatus = new Map( + (overviewQuery.data?.data.items || []).map((item) => [item.status, item]), + ) async function refreshAll() { await Promise.all([ @@ -615,8 +611,11 @@ export default function WorkerOrdersPage() {
{STATUS_OPTIONS.map((item) => { const countKey = item.value || 'all' - const count = Number(overviewQuery.data?.[countKey] || 0) + const overview = overviewByStatus.get(countKey) + const count = Number(overview?.orderCount || 0) + const settlementAmount = Number(overview?.settlementAmount || 0) const isActive = (status || '') === item.value + const settlementLabel = getSettlementLabel(item.value) return ( ) })} @@ -928,16 +932,26 @@ export default function WorkerOrdersPage() { { const countKey = item.value || 'all' - const count = Number(overviewQuery.data?.[countKey] || 0) + const overview = overviewByStatus.get(countKey) + const count = Number(overview?.orderCount || 0) + const settlementAmount = Number(overview?.settlementAmount || 0) return { key: item.value || 'all', label: ( - - {item.label} - {count > 0 ? {count} : null} + + + {item.label} + {count > 0 ? ( + {count} + ) : null} + + + {getSettlementLabel(item.value)} {formatMoney(settlementAmount)} + ), } @@ -1519,6 +1533,12 @@ function formatMoney(value: number | undefined) { return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` } +function getSettlementLabel(status: string) { + if (status === 'accepted' || status === 'vip_evidence_pending') return '已结算' + if (!status) return '结算合计' + return '预计' +} + function formatStatus(status: string) { const labels: Record = { open: '已超时', diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index a2a1274f..a6d441c7 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -10,6 +10,7 @@ import type { WorkerLeaderboardResponse, WorkerListResponse, WorkerLoginResponse, + WorkerOrderOverviewResponse, WorkerProfileResponse, WorkerUser, WorkerWalletLedger, @@ -131,6 +132,10 @@ export function fetchWorkerMyOrders(params?: Record) { return apiGet>('/api/v1/worker/orders', params) } +export function fetchWorkerMyOrderOverview() { + return apiGet('/api/v1/worker/orders/overview') +} + export function fetchWorkerMyOrderEvents(workOrderId: number) { return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`) } diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index fa6fff57..f2599175 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -159,6 +159,29 @@ line-height: 18px; } +.worker-order-tab-label { + display: inline-flex; + flex-direction: column; + gap: 2px; + line-height: 1.2; +} + +.worker-order-tab-label-main { + display: inline-flex; + align-items: center; +} + +.worker-order-tab-settlement { + color: #64748b; + font-size: 11px; + font-weight: 400; + white-space: nowrap; +} + +.worker-order-tabs .ant-tabs-tab-active .worker-order-tab-settlement { + color: var(--theme-primary); +} + .worker-order-tabs .ant-tabs-nav { margin-bottom: 12px; } @@ -575,6 +598,16 @@ place-items: center; } +.worker-hall-load-more { + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #94a3b8; + font-size: 12px; +} + .worker-hall-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); @@ -882,16 +915,17 @@ body { .worker-orders-status-pill { flex-shrink: 0; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; border: 1px solid #e2e8f0; background: #ffffff; color: #475569; - padding: 5px 12px; + padding: 5px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; - display: inline-flex; - align-items: center; - gap: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: all 0.15s ease; @@ -919,6 +953,23 @@ body { color: #ffffff; } +.worker-orders-status-pill-main { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.worker-orders-status-pill-settlement { + color: #94a3b8; + font-size: 10px; + line-height: 14px; + white-space: nowrap; +} + +.worker-orders-status-pill.active .worker-orders-status-pill-settlement { + color: rgba(255, 255, 255, 0.86); +} + /* 搜索与快捷刷新栏 */ .worker-orders-mobile-search-row { display: flex; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 87cd64b0..d4b2e938 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -323,6 +323,16 @@ export type WorkerListResponse = { pagination: Pagination } +export type WorkerOrderOverviewItem = { + status: string + orderCount: number + settlementAmount: number +} + +export type WorkerOrderOverviewResponse = { + items: WorkerOrderOverviewItem[] +} + export type WorkerHallOrdersResponse = { items: WorkOrder[] pagination: Pagination