优化打手订单结算和大厅加载

This commit is contained in:
yml2213
2026-08-17 13:06:40 +08:00
parent 1d9e102527
commit bca2e4a801
8 changed files with 306 additions and 74 deletions
@@ -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<WorkOrderShareRow[]> {
@@ -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<WorkerOrderOverviewRow>(
`
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<
+11
View File
@@ -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,
@@ -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) {
@@ -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<WorkOrder | null>(null)
const [leaderboardOpen, setLeaderboardOpen] = useState(false)
const [sharingQuantity, setSharingQuantity] = useState(1)
const [submittingSharing, setSubmittingSharing] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(loadAutoRefreshPreference)
const loadMoreRef = useRef<HTMLDivElement | null>(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 ? (
<div ref={loadMoreRef} className="worker-hall-load-more" aria-live="polite">
{ordersQuery.isFetchingNextPage ? <Spin size="small" /> : null}
{ordersQuery.isFetchingNextPage ? <span></span> : null}
{ordersQuery.isFetchNextPageError ? <span></span> : null}
{!ordersQuery.hasNextPage && !ordersQuery.isFetchingNextPage ? (
<span> {totalOrderCount} </span>
) : null}
</div>
) : 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() {
<div className="worker-hall-loading">
<Spin size="large" />
</div>
) : ordersQuery.error ? (
) : ordersQuery.error && orders.length === 0 ? (
<Empty
description={
ordersQuery.error instanceof Error
@@ -455,20 +503,7 @@ export default function WorkerHallPage() {
})
)}
{(pagination?.total || 0) > 0 ? (
<div className="worker-orders-mobile-pagination">
<Pagination
current={pagination?.page || page}
pageSize={pagination?.pageSize || pageSize}
total={pagination?.total || 0}
simple
onChange={(nextPage: number, nextPageSize: number) => {
setPage(nextPage)
setPageSize(nextPageSize)
}}
/>
</div>
) : null}
{loadMoreIndicator}
</div>
</div>
) : (
@@ -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() {
<div className="worker-hall-loading">
<Spin size="large" />
</div>
) : ordersQuery.error ? (
) : ordersQuery.error && orders.length === 0 ? (
<Empty
description={
ordersQuery.error instanceof Error
@@ -704,23 +737,7 @@ export default function WorkerHallPage() {
})}
</div>
{(pagination?.total || 0) > 0 ? (
<div className="worker-hall-pagination">
<Pagination
current={pagination?.page || page}
pageSize={pagination?.pageSize || pageSize}
total={pagination?.total || 0}
showSizeChanger={!isMobile}
simple={isMobile}
pageSizeOptions={[20, 50, 100]}
showTotal={isMobile ? undefined : (total) => `${total}`}
onChange={(nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
}}
/>
</div>
) : null}
{loadMoreIndicator}
</>
)}
</Card>
@@ -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<string, number>
},
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() {
<div className="worker-orders-mobile-status-bar">
{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 (
<button
key={item.value || 'all'}
@@ -627,10 +626,15 @@ export default function WorkerOrdersPage() {
setPage(1)
}}
>
<span>{item.label}</span>
{count > 0 ? (
<span className="worker-orders-status-pill-count">{count}</span>
) : null}
<span className="worker-orders-status-pill-main">
<span>{item.label}</span>
{count > 0 ? (
<span className="worker-orders-status-pill-count">{count}</span>
) : null}
</span>
<span className="worker-orders-status-pill-settlement">
{settlementLabel} {formatMoney(settlementAmount)}
</span>
</button>
)
})}
@@ -928,16 +932,26 @@ export default function WorkerOrdersPage() {
<Card bordered={false}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Tabs
className="worker-order-tabs"
activeKey={status || 'all'}
items={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)
return {
key: item.value || 'all',
label: (
<span>
{item.label}
{count > 0 ? <span className="worker-order-tab-count">{count}</span> : null}
<span className="worker-order-tab-label">
<span className="worker-order-tab-label-main">
{item.label}
{count > 0 ? (
<span className="worker-order-tab-count">{count}</span>
) : null}
</span>
<span className="worker-order-tab-settlement">
{getSettlementLabel(item.value)} {formatMoney(settlementAmount)}
</span>
</span>
),
}
@@ -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<string, string> = {
open: '已超时',
+5
View File
@@ -10,6 +10,7 @@ import type {
WorkerLeaderboardResponse,
WorkerListResponse,
WorkerLoginResponse,
WorkerOrderOverviewResponse,
WorkerProfileResponse,
WorkerUser,
WorkerWalletLedger,
@@ -131,6 +132,10 @@ export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/worker/orders', params)
}
export function fetchWorkerMyOrderOverview() {
return apiGet<WorkerOrderOverviewResponse>('/api/v1/worker/orders/overview')
}
export function fetchWorkerMyOrderEvents(workOrderId: number) {
return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`)
}
+55 -4
View File
@@ -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;
@@ -323,6 +323,16 @@ export type WorkerListResponse<T> = {
pagination: Pagination
}
export type WorkerOrderOverviewItem = {
status: string
orderCount: number
settlementAmount: number
}
export type WorkerOrderOverviewResponse = {
items: WorkerOrderOverviewItem[]
}
export type WorkerHallOrdersResponse = {
items: WorkOrder[]
pagination: Pagination