fix:优化再拼一次bug,优化打手端移动端拼单列表显示

This commit is contained in:
2026-10-26 16:36:26 +08:00
parent 2b967ddd4c
commit 7743d2019c
12 changed files with 465 additions and 66 deletions
@@ -18,6 +18,7 @@ import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders'
import {
compareWorkerHallOrderRows,
evaluateWorkerKeywordVerdict,
patchWorkerHallPersonalShares,
patchWorkerWorkOrderInfiniteEnvelope,
patchWorkerWorkOrderListEnvelope,
resolveWorkerMyOrderRealtimeVerdict,
@@ -324,6 +325,24 @@ function applyWorkerRealtimeEvent(
const data = event.data
if (event.scopes.includes('worker_my_orders')) {
patchWorkerMyOrderCaches(queryClient, { ...event, data })
// 大厅行上的个人份额只信任「我的订单」快照:大厅广播是通用快照,已剔除这些字段。
// 否则后台撤掉我的份额后,大厅按钮会一直停在「再拼一次」。
const hallWorkOrderId = Number(data.workOrderId) || Number(event.entityId) || 0
if (hallWorkOrderId > 0 && 'myShare' in data && 'myShares' in data) {
for (const [queryKey, current] of queryClient.getQueriesData({
queryKey: ['worker-hall-orders'],
})) {
queryClient.setQueryData(
queryKey,
patchWorkerHallPersonalShares(
current,
hallWorkOrderId,
data.myShare ?? null,
data.myShares ?? [],
),
)
}
}
void queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] })
}
if (event.scopes.includes('worker_hall')) {
@@ -51,6 +51,7 @@ import {
import type {
WorkCategory,
WorkOrder,
WorkOrderShare,
WorkerLeaderboardPeriod,
WorkerUser,
} from '@/types/worker-platform'
@@ -361,17 +362,27 @@ export default function WorkerHallPage() {
requestId: createRequestId('share'),
})
message.success('拼单成功,已进入我的订单')
const joinedShare = result.data?.share
const currentProgress = sharingOrder.sharingProgress
// 本地即时反馈:把刚拿到的份额合并进行上的 myShares,按钮据此切成「再拼一次」。
// 只写 myShare 虽然也能让按钮变一次,但连续拼单时「已参与 N 次」会一直停在 1,
// 且后端广播不带个人份额,必须在本地维护完整列表。
const previousShares = resolveHallMyShares(sharingOrder)
const joinedQuantity = Number(joinedShare?.quantity || sharingQuantity)
const nextShares = joinedShare
? [...previousShares.filter((share) => share.shareId !== joinedShare.shareId), joinedShare]
: previousShares
queryClient.setQueriesData({ queryKey: ['worker-hall-orders'] }, (current: unknown) =>
patchWorkerWorkOrderInfiniteEnvelope(current, {
operation: 'upsert',
entityId: sharingOrder.workOrderId,
data: {
...sharingOrder,
myShare: { quantity: Number(result.data.share?.quantity || sharingQuantity) },
myShare: joinedShare || sharingOrder.myShare || null,
myShares: nextShares,
sharingProgress: {
joinedQuantity:
Number(currentProgress?.joinedQuantity || 0) + Number(sharingQuantity || 0),
...currentProgress,
joinedQuantity: Number(currentProgress?.joinedQuantity || 0) + joinedQuantity,
pendingSubmissionCount: Number(currentProgress?.pendingSubmissionCount || 0) + 1,
},
},
@@ -1163,13 +1174,18 @@ type HallCardProps = {
}
/**
* 我在该工单的份额摘要:参与次数 + 累计份数。
* 我在该工单的全部有效份额。
*
* 同一打手在同一工单可以多次拼单,`myShares` 是全部有效份额;旧数据只有 `myShare`。
* 大厅按钮据此固定显示「再拼一次」,不再随参与次数改文案。
*/
function resolveHallMyShares(order: WorkOrder): WorkOrderShare[] {
if (order.myShares?.length) return order.myShares
return order.myShare ? [order.myShare] : []
}
function resolveHallMyShareSummary(order: WorkOrder): { count: number; quantity: number } {
const shares = order.myShares?.length ? order.myShares : order.myShare ? [order.myShare] : []
const shares = resolveHallMyShares(order)
return {
count: shares.length,
quantity: shares.reduce((total, share) => total + Number(share.quantity || 0), 0),
@@ -1,5 +1,6 @@
import {
CopyOutlined,
DownOutlined,
EditOutlined,
EyeOutlined,
MessageOutlined,
@@ -8,6 +9,7 @@ import {
SearchOutlined,
} from '@ant-design/icons'
import { Alert, Button, Empty, Input, Pagination, Spin, Tag, Typography } from 'antd'
import { useState } from 'react'
import type { ReactNode } from 'react'
import { DeadlineCountdown } from '@/components/DeadlineCountdown'
@@ -238,6 +240,9 @@ function WorkerOrderMobileCard({
| 'onRefresh'
| 'onPageChange'
> & { order: WorkOrder }) {
/** 拼单明细默认折叠,点标题行展开;单份订单不显示该区块。 */
const [sharesExpanded, setSharesExpanded] = useState(false)
const shareCount = order.myShares?.length || 0
const items = getMaterialDetailItems(order).filter((item) => String(item.value || '').trim())
const screenshots = getMaterialScreenshots(order)
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order))
@@ -343,12 +348,23 @@ function WorkerOrderMobileCard({
<span className="worker-order-mobile-sub-time">{formatDateTime(order.assignedAt)}</span>
</div>
{(order.myShares?.length || 0) > 1 && renderShareTable ? (
{shareCount > 1 && renderShareTable ? (
<div className="worker-order-mobile-shares">
<Typography.Text strong style={{ fontSize: 12 }}>
拼单明细({order.myShares?.length} 份)
</Typography.Text>
{renderShareTable(order)}
<button
type="button"
className="worker-order-mobile-shares-toggle"
aria-expanded={sharesExpanded}
onClick={() => setSharesExpanded((previous) => !previous)}
>
<span>拼单明细({shareCount} 份)</span>
<span className="worker-order-mobile-shares-hint">
{sharesExpanded ? '收起' : '展开'}
<DownOutlined rotate={sharesExpanded ? 180 : 0} />
</span>
</button>
{sharesExpanded ? (
<div className="worker-order-mobile-shares-body">{renderShareTable(order)}</div>
) : null}
</div>
) : null}
@@ -22,11 +22,14 @@ const SHARE_STATUS_META: Record<string, { label: string; color: string }> = {
*/
export function WorkerOrderShareTable({
order,
mobile = false,
onOpenAcceptance,
onOpenCancelRequest,
onRemindAcceptance,
}: {
order: WorkOrder
/** 移动端改成卡片列表,避免 9 列表格在窄屏被压成竖排文字。 */
mobile?: boolean
onOpenAcceptance: (order: WorkOrder, shareId: number) => void
onOpenCancelRequest: (order: WorkOrder, shareId: number) => void
onRemindAcceptance: (order: WorkOrder) => void
@@ -41,6 +44,48 @@ export function WorkerOrderShareTable({
if (shares.length === 0) return null
const renderShareActions = (share: WorkOrderShare) => {
const canAccept = !isTimedOut && share.status === 'joined'
const canEdit = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
const canRemind = !isTimedOut && share.status === 'submitted'
const canCancel = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
if (!canAccept && !canEdit && !canRemind && !canCancel) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
无
</Typography.Text>
)
}
return (
<Space size={6} wrap>
{canAccept ? (
<Button
type="primary"
size="small"
onClick={() => onOpenAcceptance(order, share.shareId)}
>
验收
</Button>
) : null}
{canEdit && share.status === 'submitted' ? (
<Button size="small" onClick={() => onOpenAcceptance(order, share.shareId)}>
补充图片
</Button>
) : null}
{canRemind ? (
<Button size="small" onClick={() => onRemindAcceptance(order)}>
催验收
</Button>
) : null}
{canCancel ? (
<Button danger size="small" onClick={() => onOpenCancelRequest(order, share.shareId)}>
申请取消
</Button>
) : null}
</Space>
)
}
const columns: TableColumnsType<WorkOrderShare> = [
{
title: '份额',
@@ -154,50 +199,79 @@ export function WorkerOrderShareTable({
{
title: '操作',
width: 190,
render: (_, share) => {
const canAccept = !isTimedOut && share.status === 'joined'
const canEdit = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
const canRemind = !isTimedOut && share.status === 'submitted'
const canCancel = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
if (!canAccept && !canEdit && !canRemind && !canCancel) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
无
</Typography.Text>
)
}
return (
<Space size={6} wrap>
{canAccept ? (
<Button
type="primary"
size="small"
onClick={() => onOpenAcceptance(order, share.shareId)}
>
验收
</Button>
) : null}
{canEdit && share.status === 'submitted' ? (
<Button size="small" onClick={() => onOpenAcceptance(order, share.shareId)}>
补充图片
</Button>
) : null}
{canRemind ? (
<Button size="small" onClick={() => onRemindAcceptance(order)}>
催验收
</Button>
) : null}
{canCancel ? (
<Button danger size="small" onClick={() => onOpenCancelRequest(order, share.shareId)}>
申请取消
</Button>
) : null}
</Space>
)
},
render: (_, share) => renderShareActions(share),
},
]
if (mobile) {
return (
<div className="worker-order-share-cards">
{shares.map((share, index) => {
const meta = SHARE_STATUS_META[String(share.status)] || {
label: String(share.status || '-'),
color: 'default',
}
const problemNote = String(share.problemNote || '').trim()
const canAct = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
return (
<div className="worker-order-share-card" key={share.shareId}>
<div className="worker-order-share-card-head">
<Typography.Text strong className="worker-order-share-card-title">
第 {index + 1} 份 · #{share.shareId}
</Typography.Text>
<Tag color={meta.color} style={{ marginInlineEnd: 0 }}>
{meta.label}
</Tag>
</div>
<div className="worker-order-share-card-meta">
<div className="worker-order-share-card-meta-item">
<span className="worker-order-share-card-meta-label">数量</span>
<span className="worker-order-share-card-meta-value">{share.quantity} 份</span>
</div>
<div className="worker-order-share-card-meta-item">
<span className="worker-order-share-card-meta-label">奖励</span>
<span className="worker-order-share-card-meta-value" style={{ color: '#cf1322' }}>
{formatMoney(share.shareReward)}
</span>
</div>
<div className="worker-order-share-card-meta-item">
<span className="worker-order-share-card-meta-label">押金</span>
<span className="worker-order-share-card-meta-value">
{formatMoney(share.shareDeposit)}
</span>
</div>
</div>
<div className="worker-order-share-card-line">
<span className="worker-order-share-card-line-label">截止时间</span>
<span className="worker-order-share-card-line-value">
{share.deadlineAt ? formatDateTime(share.deadlineAt) : '不限时'}
</span>
{share.deadlineAt && share.status === 'joined' && !isTimedOut ? (
<DeadlineCountdown deadlineAt={share.deadlineAt} />
) : null}
</div>
{share.problemMarked ? (
<div className="worker-order-share-card-problem">
<Tag color="red" style={{ marginInlineEnd: 6 }}>
问题单
</Tag>
{problemNote || '后台已标记该份额有问题'}
</div>
) : null}
<div className="worker-order-share-card-acceptance">
<span className="worker-order-share-card-line-label">验收信息</span>
<ShareAcceptanceCell share={share} />
</div>
{canAct ? (
<div className="worker-order-share-card-actions">{renderShareActions(share)}</div>
) : null}
</div>
)
})}
</div>
)
}
return (
<PercentTable<WorkOrderShare>
rowKey="shareId"
@@ -592,6 +592,7 @@ export default function WorkerOrdersPage() {
return (
<WorkerOrderShareTable
order={order}
mobile={isMobile}
onOpenAcceptance={openAcceptanceForShare}
onOpenCancelRequest={openCancelRequestForShare}
onRemindAcceptance={(target) => {
+150 -3
View File
@@ -4894,8 +4894,155 @@ body {
.worker-order-mobile-shares {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
}
/* 拼单明细默认折叠:整行可点,右侧显示「展开/收起」与箭头 */
.worker-order-mobile-shares-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 0 0;
background: transparent;
border: 0;
border-top: 1px dashed #f0f0f0;
color: #0f172a;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
text-align: left;
cursor: pointer;
}
.worker-order-mobile-shares-toggle:active {
opacity: 0.7;
}
.worker-order-mobile-shares-hint {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
color: #94a3b8;
font-size: 12px;
font-weight: 400;
}
.worker-order-mobile-shares-body {
display: flex;
flex-direction: column;
gap: 6px;
}
/* 移动端拼单明细:窄屏改用卡片,避免多列表格被压成竖排文字 */
.worker-order-share-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.worker-order-share-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.worker-order-share-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.worker-order-share-card-title {
font-size: 13px;
color: #0f172a;
}
.worker-order-share-card-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.worker-order-share-card-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 8px;
background: #f8fafc;
border-radius: 8px;
min-width: 0;
}
.worker-order-share-card-meta-label {
font-size: 11px;
color: #94a3b8;
line-height: 1.2;
}
.worker-order-share-card-meta-value {
font-size: 13px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.worker-order-share-card-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 8px 12px 4px;
padding-top: 8px;
border-top: 1px dashed #f0f0f0;
font-size: 12px;
color: #475569;
}
.worker-order-share-card-line-label {
color: #94a3b8;
font-size: 11px;
flex-shrink: 0;
}
.worker-order-share-card-line-value {
color: #334155;
}
.worker-order-share-card-problem {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12px;
line-height: 1.4;
}
.worker-order-share-card-acceptance {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #475569;
}
.worker-order-share-card-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
padding-top: 6px;
border-top: 1px dashed #eef2f7;
}
@@ -155,10 +155,11 @@ export function patchWorkerWorkOrderListEnvelope(
event: WorkerListEvent,
insert = false,
/**
* 就地更新时,快照里没有的「本打手私有字段」保留旧值。
* 就地更新时,快照里没有(或为空)的「本打手私有字段」保留旧值。
*
* 大厅广播是发给所有打手的通用快照,天然没有 `myShare`/`myShares`(那是每个打手自己的份额)。
* 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。
* 旧版本后端还会显式带 `null`/`[]` 过来;整行替换或直接合并空值都会把
* 「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时两种情况都要保留。
*/
preserveFields: readonly string[] = [],
/**
@@ -198,7 +199,11 @@ export function patchWorkerWorkOrderListEnvelope(
...(isRecord(event.data) ? event.data : {}),
}
for (const field of preserveFields) {
if (existing && !(field in merged) && field in existing) merged[field] = existing[field]
if (!existing || !(field in existing)) continue
// 快照没有该键,或显式带了 null/[](旧版大厅广播),都视为「没有个人份额信息」。
const incoming = merged[field]
const incomingEmpty = incoming == null || (Array.isArray(incoming) && incoming.length === 0)
if (incomingEmpty) merged[field] = existing[field]
}
nextItems = items.map((entry, itemIndex) => (itemIndex === index ? merged : entry))
} else if (insert) {
@@ -259,6 +264,36 @@ export function sortWorkerHallItems(items: unknown[]): unknown[] {
})
}
/**
* 只把「我的订单」快照里的个人份额字段覆盖到大厅缓存行上。
*
* 大厅广播是发给所有打手的通用快照,已经剔除 myShare/myShares(否则浅合并会
* 冲掉「再拼一次」);但「后台撤销我的份额」「我的份额被验收」这类变化只出现在
* 「我的订单」快照里,需要用后者回填大厅行。这里不增删、不重排,找不到工单就原样返回。
*/
export function patchWorkerHallPersonalShares(
current: unknown,
workOrderId: number,
myShare: unknown,
myShares: unknown,
): unknown {
if (!isRecord(current) || !Array.isArray(current.pages) || workOrderId <= 0) return current
let changed = false
const pages = current.pages.map((page) => {
if (!isRecord(page) || !isRecord(page.data) || !Array.isArray(page.data.items)) return page
let pageChanged = false
const items = page.data.items.map((item) => {
if (!isRecord(item) || toNumber(item.workOrderId) !== workOrderId) return item
pageChanged = true
return { ...item, myShare, myShares }
})
if (!pageChanged) return page
changed = true
return { ...page, data: { ...page.data, items } }
})
return changed ? { ...current, pages } : current
}
/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新;最终按置顶优先重排。 */
export function patchWorkerWorkOrderInfiniteEnvelope(
current: unknown,