优化抢单大厅布局

This commit is contained in:
yml2213
2026-07-28 11:24:29 +08:00
parent cdfee71c97
commit 55ffb1e88b
2 changed files with 694 additions and 98 deletions
+491 -85
View File
@@ -1,10 +1,36 @@
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Button, Card, Empty, Input, Pagination, Space, Spin, Tag, Typography } from 'antd'
import {
Alert,
App,
Button,
Card,
Descriptions,
Drawer,
Empty,
Input,
Pagination,
Space,
Spin,
Tag,
Typography,
} from 'antd'
import { useState } from 'react'
import { fetchWorkerHallOrders, grabWorkerOrder } from '@/services/worker'
import type { WorkOrder } from '@/types/worker-platform'
import {
fetchWorkerHallOrders,
fetchWorkerMyOrders,
fetchWorkerProfile,
grabWorkerOrder,
} from '@/services/worker'
import type { CollectField, WorkOrder, WorkerUser } from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
type HallSummary = {
activeCount: number
pendingAcceptanceCount: number
problemCount: number
}
export default function WorkerHallPage() {
const { message } = App.useApp()
@@ -14,6 +40,7 @@ export default function WorkerHallPage() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [grabbingId, setGrabbingId] = useState(0)
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
const ordersQuery = useQuery({
queryKey: ['worker-hall-orders', keyword, page, pageSize],
@@ -21,15 +48,82 @@ export default function WorkerHallPage() {
retry: false,
})
const profileQuery = useQuery({
queryKey: ['worker-profile'],
queryFn: () => fetchWorkerProfile(),
retry: false,
})
const hallSummaryQuery = useQuery({
queryKey: ['worker-hall-summary'],
queryFn: async () => {
const [inProgressResponse, pendingAcceptanceResponse, problemResponse] =
await Promise.all([
fetchWorkerMyOrders({
status: 'in_progress',
page: 1,
pageSize: 1,
}),
fetchWorkerMyOrders({
status: 'pending_acceptance',
page: 1,
pageSize: 1,
}),
fetchWorkerMyOrders({
status: 'problem',
page: 1,
pageSize: 1,
}),
])
const pendingAcceptanceCount =
pendingAcceptanceResponse.data.pagination.total
const problemCount = problemResponse.data.pagination.total
return {
activeCount:
inProgressResponse.data.pagination.total +
pendingAcceptanceCount +
problemCount,
pendingAcceptanceCount,
problemCount,
} satisfies HallSummary
},
retry: false,
})
const orders = ordersQuery.data?.data.items || []
const pagination = ordersQuery.data?.data.pagination
const worker = profileQuery.data?.data.worker
const activeCount = hallSummaryQuery.data?.activeCount || 0
const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0
const problemCount = hallSummaryQuery.data?.problemCount || 0
const maxActiveOrders = Number(worker?.level?.permissions.maxActiveOrders || 0)
const remainingSlots =
maxActiveOrders > 0 ? Math.max(0, maxActiveOrders - activeCount) : null
const shouldWarn =
(remainingSlots !== null && remainingSlots <= 0) || problemCount > 0
const detailDisabledReason = detailOrder
? resolveGrabDisabledReason(detailOrder, worker, remainingSlots)
: ''
async function refreshAll() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }),
queryClient.invalidateQueries({ queryKey: ['worker-hall-summary'] }),
queryClient.invalidateQueries({ queryKey: ['worker-profile'] }),
queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }),
queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }),
])
}
async function submitGrab(order: WorkOrder) {
setGrabbingId(order.workOrderId)
try {
await grabWorkerOrder(order.workOrderId)
message.success('抢单成功')
await queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] })
message.success('抢单成功,已进入我的订单')
setDetailOrder(null)
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '抢单失败')
} finally {
@@ -42,103 +136,415 @@ export default function WorkerHallPage() {
setPage(1)
}
const summaryItems = [
{
label: '当前余额',
value: worker ? formatMoney(worker.wallet.availableAmount) : '-',
note: '可用于冻结押金',
},
{
label: '接单上限',
value:
remainingSlots === null
? '未配置'
: `${activeCount}/${maxActiveOrders}`,
note:
remainingSlots === null
? '当前等级待配置'
: `还能再接 ${remainingSlots}`,
},
{
label: '冻结押金',
value: worker ? formatMoney(worker.wallet.frozenDepositAmount) : '-',
note: '当前已占用金额',
},
{
label: '问题单',
value: `${problemCount}`,
note: problemCount ? '建议优先处理' : '当前暂无问题单',
},
]
return (
<section className="page-stack">
<div className="worker-page-head">
<Typography.Title level={3}></Typography.Title>
<Space.Compact>
<Input
allowClear
value={keywordInput}
placeholder="搜索商品或订单号"
prefix={<SearchOutlined />}
onChange={(event) => {
const nextKeyword = event.target.value
setKeywordInput(nextKeyword)
if (!nextKeyword.trim()) {
setKeyword('')
setPage(1)
}
}}
onPressEnter={applyKeywordSearch}
/>
<Button
icon={<SearchOutlined />}
loading={ordersQuery.isFetching}
onClick={applyKeywordSearch}
>
</Button>
<Space wrap className="worker-hall-toolbar">
<Button
icon={<ReloadOutlined />}
loading={ordersQuery.isFetching}
onClick={() => ordersQuery.refetch()}
loading={
ordersQuery.isFetching ||
profileQuery.isFetching ||
hallSummaryQuery.isFetching
}
onClick={() => refreshAll()}
>
</Button>
</Space.Compact>
<Space.Compact className="worker-hall-search">
<Input
allowClear
value={keywordInput}
placeholder="搜索商品名称 / 订单号 / 工单号"
prefix={<SearchOutlined />}
onChange={(event) => {
const nextKeyword = event.target.value
setKeywordInput(nextKeyword)
if (!nextKeyword.trim()) {
setKeyword('')
setPage(1)
}
}}
onPressEnter={applyKeywordSearch}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={ordersQuery.isFetching}
onClick={applyKeywordSearch}
>
</Button>
</Space.Compact>
</Space>
</div>
{ordersQuery.isLoading ? <Spin /> : null}
{ordersQuery.error ? (
<Card>
<Typography.Text type="danger">
{ordersQuery.error instanceof Error ? ordersQuery.error.message : '读取抢单大厅失败'}
</Typography.Text>
</Card>
) : null}
{!ordersQuery.isLoading && orders.length === 0 ? (
<Empty description="暂无可抢订单" />
) : (
<div className="worker-order-grid">
{orders.map((order) => (
<Card key={order.workOrderId} className="worker-order-card" bordered={false}>
<Space direction="vertical" className="full-width" size={8}>
<Space className="worker-order-card-head">
<Typography.Text strong ellipsis>
{order.productName}
</Typography.Text>
<Tag color="blue">{order.categoryName || '默认分类'}</Tag>
</Space>
<Typography.Text type="secondary">{order.platformOrderId}</Typography.Text>
<Typography.Title level={4} className="worker-order-money">
{formatMoney(order.rewardAmount)}
</Typography.Title>
<Typography.Text type="secondary">
{formatMoney(order.freezeDepositAmount)}
</Typography.Text>
<Button
type="primary"
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
</Button>
</Space>
</Card>
<Card className="worker-hall-board-card" bordered={false}>
<div className="worker-hall-summary-strip">
{summaryItems.map((item) => (
<div key={item.label} className="worker-hall-summary-item">
<span className="worker-hall-summary-label">{item.label}</span>
<strong className="worker-hall-summary-value">{item.value}</strong>
<small className="worker-hall-summary-note">{item.note}</small>
</div>
))}
</div>
)}
{(pagination?.total || 0) > 0 ? (
<Pagination
current={pagination?.page || page}
pageSize={pagination?.pageSize || pageSize}
total={pagination?.total || 0}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
showTotal={(total) => `${total}`}
onChange={(nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
}}
/>
) : null}
{shouldWarn ? (
<Alert
className="worker-hall-warning"
showIcon
type="warning"
message={
remainingSlots !== null && remainingSlots <= 0
? '当前已达到接单上限,请先处理现有工单'
: `当前还有 ${problemCount} 单问题单待处理,建议优先收口`
}
/>
) : null}
{ordersQuery.isLoading ? (
<div className="worker-hall-loading">
<Spin size="large" />
</div>
) : ordersQuery.error ? (
<Empty
description={
ordersQuery.error instanceof Error
? ordersQuery.error.message
: '读取抢单大厅失败'
}
/>
) : orders.length === 0 ? (
<Empty
description={
keyword
? `没有找到与 “${keyword}” 相关的可抢工单`
: '当前暂无可抢订单,可稍后刷新再看'
}
/>
) : (
<>
<div className="worker-hall-card-grid">
{orders.map((order) => {
const requirementFields = getRequirementFields(order)
const disabledReason = resolveGrabDisabledReason(
order,
worker,
remainingSlots,
)
const zeroDeposit = Number(order.freezeDepositAmount || 0) <= 0
return (
<article key={order.workOrderId} className="worker-hall-card">
<div className="worker-hall-card-head">
<div className="worker-hall-card-title">
<Typography.Link onClick={() => setDetailOrder(order)}>
{order.productName || order.workOrderNo}
</Typography.Link>
<Typography.Text type="secondary">
{order.categoryName || '默认分类'}
</Typography.Text>
</div>
<div className="worker-hall-card-badges">
<Tag color={zeroDeposit ? 'green' : 'gold'}>
{zeroDeposit ? '免押金' : '需押金'}
</Tag>
<Tag color="blue">
{formatRequirementShortLabel(requirementFields)}
</Tag>
</div>
</div>
<div className="worker-hall-card-order">
<Typography.Text type="secondary">
{order.workOrderNo}
</Typography.Text>
<Typography.Text type="secondary">
{order.platformOrderId || '-'}
</Typography.Text>
</div>
<div className="worker-hall-card-main">
<div className="worker-hall-card-amount">
{formatMoney(order.rewardAmount)}
</div>
<div className="worker-hall-card-side">
<span>{formatMoney(order.freezeDepositAmount)}</span>
<span>{formatDateTime(resolvePublishedTime(order))}</span>
</div>
</div>
<div className="worker-hall-card-meta">
<div className="worker-hall-card-meta-row">
<span></span>
<strong>{formatRequirementPreview(requirementFields)}</strong>
</div>
<div className="worker-hall-card-meta-row">
<span></span>
<strong
className={
disabledReason
? 'worker-hall-card-danger'
: 'worker-hall-card-safe'
}
>
{disabledReason || buildGrabReadyHint(order)}
</strong>
</div>
</div>
<div className="worker-hall-card-actions">
<Button icon={<EyeOutlined />} onClick={() => setDetailOrder(order)}>
</Button>
<Button
type="primary"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
</Button>
</div>
</article>
)
})}
</div>
{(pagination?.total || 0) > 0 ? (
<div className="worker-hall-pagination">
<Pagination
current={pagination?.page || page}
pageSize={pagination?.pageSize || pageSize}
total={pagination?.total || 0}
showSizeChanger
pageSizeOptions={[20, 50, 100]}
showTotal={(total) => `${total}`}
onChange={(nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
}}
/>
</div>
) : null}
</>
)}
</Card>
<Drawer
title={
detailOrder
? `${detailOrder.productName || '工单详情'} · ${detailOrder.workOrderNo}`
: '工单详情'
}
width={760}
open={Boolean(detailOrder)}
destroyOnHidden
onClose={() => setDetailOrder(null)}
extra={
detailOrder ? (
<Button
type="primary"
disabled={Boolean(detailDisabledReason)}
loading={grabbingId === detailOrder.workOrderId}
onClick={() => submitGrab(detailOrder)}
>
</Button>
) : null
}
>
{detailOrder ? (
<div className="worker-hall-detail-stack">
<Alert
showIcon
type={detailDisabledReason ? 'warning' : 'success'}
message={detailDisabledReason || buildGrabReadyHint(detailOrder)}
/>
<Card title="基础信息" size="small">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="商品">
{detailOrder.productName || '-'}
</Descriptions.Item>
<Descriptions.Item label="分类">
{detailOrder.categoryName || '-'}
</Descriptions.Item>
<Descriptions.Item label="工单号">
{detailOrder.workOrderNo}
</Descriptions.Item>
<Descriptions.Item label="平台订单号">
{detailOrder.platformOrderId || '-'}
</Descriptions.Item>
<Descriptions.Item label="接单奖励">
{formatMoney(detailOrder.rewardAmount)}
</Descriptions.Item>
<Descriptions.Item label="冻结押金">
{formatMoney(detailOrder.freezeDepositAmount)}
</Descriptions.Item>
<Descriptions.Item label="发布时间">
{formatDateTime(resolvePublishedTime(detailOrder))}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatDateTime(detailOrder.updatedAt)}
</Descriptions.Item>
<Descriptions.Item label="资料要求">
{formatRequirementShortLabel(getRequirementFields(detailOrder))}
</Descriptions.Item>
<Descriptions.Item label="接单资格">
{detailDisabledReason || '当前可直接抢单'}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="资料要求" size="small">
{renderRequirementFieldValues(detailOrder)}
</Card>
</div>
) : null}
</Drawer>
</section>
)
}
function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields)
? order.requirement.fields
: []
return rawFields
.map((item) => {
const source = asRecord(item)
const key = String(source.key || '').trim()
if (!key) return null
return {
key,
label: String(source.label || key).trim(),
required: source.required !== false,
}
})
.filter((item): item is CollectField => Boolean(item))
}
function renderRequirementFieldValues(order: WorkOrder) {
const fields = getRequirementFields(order)
if (fields.length === 0) {
return <Typography.Text type="secondary"></Typography.Text>
}
return (
<Descriptions column={1} bordered size="small">
{fields.map((field) => (
<Descriptions.Item key={field.key} label={field.label}>
<Space size={6} wrap>
<Typography.Text code>{field.key}</Typography.Text>
<Tag color={field.required ? 'orange' : 'default'}>
{field.required ? '必填' : '选填'}
</Tag>
</Space>
</Descriptions.Item>
))}
</Descriptions>
)
}
function resolveGrabDisabledReason(
order: WorkOrder,
worker: WorkerUser | undefined,
remainingSlots: number | null,
) {
if (remainingSlots !== null && remainingSlots <= 0) {
return '已达到当前等级最大同时接单量'
}
if (!worker) {
return ''
}
const availableAmount = Number(worker.wallet.availableAmount || 0)
const freezeAmount = Number(order.freezeDepositAmount || 0)
if (availableAmount < freezeAmount) {
return `余额不足,还差 ${formatMoney(freezeAmount - availableAmount)}`
}
return ''
}
function buildGrabReadyHint(order: WorkOrder) {
const freezeAmount = Number(order.freezeDepositAmount || 0)
if (freezeAmount <= 0) {
return '当前工单无需冻结押金,可直接抢单'
}
return `抢单后会冻结 ${formatMoney(freezeAmount)}`
}
function formatRequirementShortLabel(fields: CollectField[]) {
if (fields.length === 0) {
return '资料简单'
}
const requiredCount = fields.filter((field) => field.required).length
return `${requiredCount} 项必填 / 共 ${fields.length}`
}
function formatRequirementPreview(fields: CollectField[]) {
if (fields.length === 0) {
return '当前工单未配置额外资料字段'
}
const preview = fields
.slice(0, 3)
.map((field) => field.label)
.filter(Boolean)
.join('、')
if (fields.length <= 3) {
return preview
}
return `${preview}${fields.length}`
}
function resolvePublishedTime(order: WorkOrder) {
return order.publishedAt || order.createdAt || order.updatedAt
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
+203 -13
View File
@@ -1475,6 +1475,27 @@ select {
.table-toolbar {
display: grid;
}
.worker-profile-hero-head {
flex-direction: column;
}
.worker-hall-toolbar {
width: 100%;
justify-content: flex-start;
}
.worker-hall-search {
width: 100%;
}
.worker-hall-summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.worker-hall-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
@@ -1668,24 +1689,177 @@ select {
margin: 0;
}
.worker-order-grid {
.worker-hall-head-copy {
display: grid;
gap: 4px;
}
.worker-hall-head-copy .ant-typography {
margin: 0;
}
.worker-hall-toolbar {
justify-content: flex-end;
}
.worker-hall-search {
width: min(460px, 100%);
}
.worker-hall-board-card .ant-card-body {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.worker-order-card .ant-card-body {
height: 100%;
.worker-hall-summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.worker-order-card-head {
width: 100%;
.worker-hall-summary-item {
min-width: 0;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
display: grid;
gap: 4px;
}
.worker-hall-summary-label {
color: #64748b;
font-size: 12px;
}
.worker-hall-summary-value {
color: #0f172a;
font-size: 22px;
line-height: 1.15;
}
.worker-hall-summary-note {
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
}
.worker-hall-warning {
margin-bottom: 0;
}
.worker-hall-loading {
min-height: 220px;
display: grid;
place-items: center;
}
.worker-hall-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 16px;
}
.worker-hall-card {
min-width: 0;
padding: 18px 18px 16px;
border-radius: 16px;
background: #ffffff;
border: 1px solid #edf0f5;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
display: grid;
gap: 14px;
}
.worker-hall-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.worker-order-money {
margin: 4px 0 0;
.worker-hall-card-title {
display: grid;
gap: 6px;
min-width: 0;
}
.worker-hall-card-title .ant-typography {
margin: 0;
}
.worker-hall-card-badges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.worker-hall-card-order {
display: grid;
gap: 4px;
}
.worker-hall-card-main {
display: grid;
gap: 8px;
}
.worker-hall-card-amount {
color: #f97316;
font-size: 34px;
line-height: 1;
font-weight: 700;
}
.worker-hall-card-side {
display: grid;
gap: 4px;
color: #94a3b8;
font-size: 12px;
}
.worker-hall-card-meta {
display: grid;
gap: 10px;
}
.worker-hall-card-meta-row {
display: grid;
gap: 6px;
}
.worker-hall-card-meta-row span {
color: #64748b;
font-size: 12px;
}
.worker-hall-card-meta-row strong {
color: #334155;
font-weight: 500;
line-height: 1.5;
overflow-wrap: anywhere;
}
.worker-hall-card-danger {
color: #dc2626 !important;
}
.worker-hall-card-safe {
color: #16a34a !important;
}
.worker-hall-card-actions {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-top: auto;
}
.worker-hall-detail-stack {
display: grid;
gap: 16px;
}
.worker-orders-overview-grid {
@@ -2079,12 +2253,32 @@ select {
flex-direction: column;
}
.worker-order-grid {
.worker-hall-summary-strip {
grid-template-columns: 1fr;
}
.worker-hall-toolbar,
.worker-orders-toolbar {
align-items: stretch;
flex-direction: column;
}
.worker-hall-search {
width: 100%;
}
.worker-hall-card-grid {
grid-template-columns: 1fr;
}
.worker-hall-card {
padding: 16px;
}
.worker-hall-card-head,
.worker-hall-card-actions {
align-items: stretch;
flex-direction: column;
}
.worker-orders-search {
@@ -2096,10 +2290,6 @@ select {
justify-content: space-between;
}
.worker-profile-hero-head {
flex-direction: column;
}
.worker-profile-hero-side {
width: 100%;
justify-content: flex-start;