feat(consignment): 一个寄卖单号锚定全流程 + 统一状态机 + 卖家侧确认加好友/提交赠送截图

问题:一件货身上挂着三套互不相通的状态(提报单审核态、仓库库存态、工单赠送态),
my-items 同时返回两个数组、卖家端分两个列表渲染,同一件货显示两行两个号;
且卖家端只映射了前两套状态,"已出库"之后流程就断了,卖家不知道该干什么。

- 提报单号 SUB… 作为唯一「寄卖单号」从发布锚定到结算(零迁移);
  listWorkerConsignments 改成一件货一条记录,库存/工单信息挂上去,
  客服手工入库的现货(无提报单)才回退用库存号当锚点
- 新增 domain/consignment-item-stage.ts:把三套底层状态推导成卖家看得懂的一个阶段
  (待审核/已通过待匹配/已匹配-待加好友/冷却中/待赠送/已赠送待审核/已完成/驳回/作废/取消)
  + 一句"下一步做什么" + 能力位(谁能确认加好友、谁该传截图),列表筛选与 KPI 也改由它驱动
- 卖家侧新增动作(以前只有 publish + my-items,卖家只能干看):
  POST /worker/consignment/items/:submissionId/{confirm-friend,gift-evidence}
  POST /worker/consignment/inventories/:inventoryId/{confirm-friend,gift-evidence}(手工入库现货)
  复用既有工单链路 confirmWorkOrderFriendAdded / submitWorkerOrderAcceptance,
  只加归属校验(不是本人履约的订单 403),顺带白拿"冷却没结束不许提交凭证""必须带图"
- 客服端用同一个号:库存列表/详情补 sourceSubmissionNo(LEFT JOIN 提报单),
  履约/指派写进工单的寄售信息也带上;关键字搜索支持按寄卖单号搜(计数 SQL 同步补 join)

真实库端到端验证:确认加好友 → gift_phase=friend_countdown / friend_added_at 落库 /
gift_available_at 正好 +72h;冷却期内提交截图被拒(work_order_gift_cooldown_active);
越权操作被拒(consignment_item_not_found);按寄卖单号搜索命中正确。临时数据已清理。
This commit is contained in:
yml2213
2026-09-17 10:57:57 +08:00
parent 99752dce9e
commit 44de11a3a7
19 changed files with 1574 additions and 204 deletions
@@ -92,6 +92,14 @@ const BUYER_COLORS: Record<string, string> = {
客服B: 'magenta',
}
/**
* 寄卖单号 = 来源提报单号(卖家提报时生成的提报单号就是唯一寄卖单号)。
* 客服手工入库的现货没有来源提报,回退显示内部仓库号,保证客服对单口径一致。
*/
function resolveConsignmentNo(record: ConsignmentInventory) {
return record.sourceSubmissionNo || record.inventoryNo
}
export default function ConsignmentInventoryPanel() {
const { message } = App.useApp()
@@ -297,7 +305,7 @@ export default function ConsignmentInventoryPanel() {
async function handleArchive(record: ConsignmentInventory) {
Modal.confirm({
title: '确认下架作废该物品?',
content: `下架后该物品 [${record.itemName} - ${record.inventoryNo}] 将不再参与匹配。`,
content: `下架后该物品 [${record.itemName} - 寄卖单号 ${resolveConsignmentNo(record)}] 将不再参与匹配。`,
okText: '确认作废',
okType: 'danger',
cancelText: '取消',
@@ -342,16 +350,28 @@ export default function ConsignmentInventoryPanel() {
const columns: TableColumnsType<ConsignmentInventory> = [
{
title: '物品/库存编号',
title: '物品/寄卖单号',
key: 'item',
width: 220,
width: 240,
render: (_, record) => (
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{record.itemName}</div>
<Space size={4}>
<Text type="secondary" copyable style={{ fontSize: 12 }}>
{record.inventoryNo}
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 12 }}>
寄卖单号
</Text>
<Text copyable style={{ fontSize: 12, fontWeight: 600 }}>
{resolveConsignmentNo(record)}
</Text>
</Space>
{record.sourceSubmissionNo ? (
<div>
<Text type="secondary" style={{ fontSize: 11, color: '#bfbfbf' }}>
仓库号 {record.inventoryNo}
</Text>
</div>
) : null}
<div style={{ marginTop: 2 }}>
{record.sourceType === 'worker_portal' ? (
<Tag color="purple" style={{ fontSize: 11, padding: '0 4px' }}>
打手端提报
@@ -365,7 +385,7 @@ export default function ConsignmentInventoryPanel() {
人工入库
</Tag>
)}
</Space>
</div>
</div>
),
},
@@ -1176,7 +1196,7 @@ export default function ConsignmentInventoryPanel() {
{/* 查看详情与凭据弹窗 */}
<Modal
title={`物品详情 [${viewingItem?.inventoryNo}]`}
title={`物品详情 [${viewingItem ? resolveConsignmentNo(viewingItem) : ''}]`}
open={Boolean(viewingItem)}
width={680}
footer={[
@@ -1191,8 +1211,20 @@ export default function ConsignmentInventoryPanel() {
<Descriptions.Item label="物品名称" span={2}>
<strong>{viewingItem.itemName}</strong>
</Descriptions.Item>
<Descriptions.Item label="库存编号">
<Text copyable>{viewingItem.inventoryNo}</Text>
<Descriptions.Item label="寄卖单号">
<Text copyable strong>
{resolveConsignmentNo(viewingItem)}
</Text>
{viewingItem.sourceSubmissionNo ? null : (
<Text type="secondary" style={{ fontSize: 12 }}>
(客服手工入库,无来源提报单号)
</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="仓库号">
<Text type="secondary" copyable>
{viewingItem.inventoryNo}
</Text>
</Descriptions.Item>
<Descriptions.Item label="当前状态">
{viewingItem.status === 'in_stock' ? (
@@ -1289,7 +1321,8 @@ export default function ConsignmentInventoryPanel() {
<Text>
确认将该物品{' '}
<strong>
[{returnItem?.itemName} - {returnItem?.inventoryNo}]
[{returnItem?.itemName} - 寄卖单号{' '}
{returnItem ? resolveConsignmentNo(returnItem) : ''}]
</strong>{' '}
退回复库吗?
</Text>
@@ -80,7 +80,7 @@ import {
revokeWorkOrderConsignmentFulfillment,
type WorkOrderVoucherConsumeResult,
} from '@/services/admin/consignment'
import type { WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
@@ -174,6 +174,20 @@ function isInternalConsignmentOrder(row: WorkOrder) {
return Boolean(consignment.inventoryId) && !row.worker
}
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
return Object.keys(consignment).length > 0 ? (consignment as ConsignmentFulfillmentInfo) : null
}
/**
* 寄卖单号 = 来源提报单号(提报单号即寄卖单号)。
* 历史老单的 consignment 没有 sourceSubmissionNo,回退到内部仓库号 inventoryNo。
*/
function resolveConsignmentNo(info: ConsignmentFulfillmentInfo) {
return info.sourceSubmissionNo || info.inventoryNo || ''
}
/**
* 只有「未验收且未取消」的工单可以撤销寄售履约。
* 已验收工单撤销会造成同一件现货二次出货、并重新冻结已退还的押金,后端一律拒绝。
@@ -330,6 +344,9 @@ export default function WorkOrdersPanel() {
submitting: false,
})
// 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号)
const detailConsignment = detailOrder ? getConsignmentFulfillment(detailOrder) : null
// 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作)
const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance'
const selectedWorkOrderStatuses = statuses.filter(
@@ -1210,6 +1227,7 @@ export default function WorkOrdersPanel() {
const source = asRecord(row.material?.source)
const shopName = String(source.shopName || '').trim()
const provider = String(source.provider || '').trim()
const consignment = getConsignmentFulfillment(row)
return (
<div className="cell-stack">
<ProductNameEditableCell
@@ -1255,10 +1273,10 @@ export default function WorkOrdersPanel() {
{shopName ? `店铺:${shopName}` : ''}
</Typography.Text>
) : null}
{(row.material as any)?.consignment ? (
{consignment ? (
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
寄售履约: {(row.material as any).consignment.inventoryNo} (成本: ¥
{(((row.material as any).consignment.costPriceFen || 0) / 100).toFixed(2)})
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
</Tag>
) : null}
</div>
@@ -1869,6 +1887,13 @@ export default function WorkOrdersPanel() {
<Descriptions.Item label="分类">
{detailOrder.categoryName || '-'}
</Descriptions.Item>
{detailConsignment ? (
<Descriptions.Item label="寄卖单号">
<Typography.Text copyable={{ text: resolveConsignmentNo(detailConsignment) }}>
{resolveConsignmentNo(detailConsignment) || '-'}
</Typography.Text>
</Descriptions.Item>
) : null}
<Descriptions.Item label="状态">
<Tag color={resolveStatusColor(detailOrder.status)}>
{formatStatus(detailOrder.status)}
@@ -2999,9 +3024,14 @@ export default function WorkOrdersPanel() {
) : (
<Tag color="gold">【部分匹配 / 高配充低配】</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{inv.inventoryNo}
<Typography.Text style={{ fontSize: 12, fontWeight: 500 }}>
寄卖单号 {inv.sourceSubmissionNo || inv.inventoryNo}
</Typography.Text>
{inv.sourceSubmissionNo ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
仓库号 {inv.inventoryNo}
</Typography.Text>
) : null}
</Space>
<div style={{ marginTop: 4 }}>
@@ -22,6 +22,7 @@ import {
Image,
Input,
InputNumber,
Modal,
Radio,
Row,
Space,
@@ -33,12 +34,18 @@ import { useEffect, useMemo, useState } from 'react'
import ImageUpload from '@/components/files/ImageUpload'
import {
confirmWorkerConsignmentFriendAdded,
confirmWorkerConsignmentInventoryFriendAdded,
fetchWorkerConsignments,
fetchWorkerProfile,
publishWorkerConsignment,
submitWorkerConsignmentGiftEvidence,
submitWorkerConsignmentInventoryGiftEvidence,
} from '@/services/worker'
import type { WorkerConsignmentItem, WorkerInventoryItem } from '@/services/worker'
import type { WorkerConsignmentRecord } from '@/services/worker'
import type { UploadedFile } from '@/types/worker-platform'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { formatDateTime } from '@/utils/date-time'
import { useIsMobile } from '@/utils/use-is-mobile'
const SERIES_OPTIONS = [
@@ -57,10 +64,13 @@ const BRILLIANCE_OPTIONS = [
...[1, 2, 3, 4, 5, 6, 7].map((v) => ({ label: `${v} 璀璨`, value: v })),
]
type RecordFilter = 'all' | 'pending' | 'in_stock' | 'done'
type RecordFilter = 'all' | 'reviewing' | 'active' | 'finished'
const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen'
/** 赠送冷却倒计时刷新间隔:30 秒足够展示,不需要秒级跳动 */
const COOLDOWN_TICK_MS = 30_000
export default function WorkerConsignmentPage() {
const isMobile = useIsMobile()
const { message, modal } = App.useApp()
@@ -68,6 +78,11 @@ export default function WorkerConsignmentPage() {
const [recordFilter, setRecordFilter] = useState<RecordFilter>('all')
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
const [submitting, setSubmitting] = useState(false)
const [evidenceItem, setEvidenceItem] = useState<WorkerConsignmentRecord | null>(null)
const [evidenceFiles, setEvidenceFiles] = useState<UploadedFile[]>([])
const [evidenceNote, setEvidenceNote] = useState('')
const [evidenceSubmitting, setEvidenceSubmitting] = useState(false)
const [cooldownNow, setCooldownNow] = useState(() => Date.now())
const [form] = Form.useForm()
const profileQuery = useQuery({
@@ -83,8 +98,9 @@ export default function WorkerConsignmentPage() {
queryFn: () => fetchWorkerConsignments(),
})
const submissions = myConsignmentsQuery.data?.data.submissions || []
const inventories = myConsignmentsQuery.data?.data.inventories || []
// 后端已按「一件货一行」聚合,前端不再合并提报单与库存
const records = myConsignmentsQuery.data?.data.items || []
const stageCounts = myConsignmentsQuery.data?.data.stageCounts || {}
const publishDepositFen = Number(
myConsignmentsQuery.data?.data.publishDepositFen ??
financeConfig?.consignmentPublishDepositFen ??
@@ -94,10 +110,43 @@ export default function WorkerConsignmentPage() {
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
const canPublish = availableAmount >= publishDepositFen
const pendingCount = submissions.filter((s) => s.status === 'pending_review').length
const inStockCount = inventories.filter((i) => i.status === 'in_stock').length
const doneCount = inventories.filter((i) => i.status === 'consumed').length
const rejectedCount = submissions.filter((s) => s.status === 'rejected').length
const reviewingCount = records.filter((item) => item.group === 'reviewing').length
const activeCount = records.filter((item) => item.group === 'active').length
const finishedCount = records.filter((item) => item.group === 'finished').length
// 阶段 → 分组由后端下发(item.group),这里只把它映射到筛选角标计数上
const groupCounts = useMemo(() => {
const stageGroups = new Map<string, string>()
records.forEach((item) => stageGroups.set(item.stage, item.group))
const counts: Record<'reviewing' | 'active' | 'finished', number> = {
reviewing: 0,
active: 0,
finished: 0,
}
Object.entries(stageCounts).forEach(([stage, count]) => {
const group = stageGroups.get(stage)
if (group === 'reviewing' || group === 'active' || group === 'finished') {
counts[group] += Number(count) || 0
}
})
return counts
}, [records, stageCounts])
const filteredRecords = useMemo(() => {
if (recordFilter === 'all') return records
return records.filter((item) => item.group === recordFilter)
}, [records, recordFilter])
const hasCooldownItem = records.some(
(item) => item.stage === 'gift_cooldown' && Boolean(item.giftAvailableAt),
)
// 只有存在冷却中的寄卖单时才起定时器,避免无意义的重渲染
useEffect(() => {
if (!hasCooldownItem) return
const timer = window.setInterval(() => setCooldownNow(Date.now()), COOLDOWN_TICK_MS)
return () => window.clearInterval(timer)
}, [hasCooldownItem])
useEffect(() => {
if (worker?.phone) {
@@ -193,7 +242,7 @@ export default function WorkerConsignmentPage() {
form.resetFields()
setScreenshots([])
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
setRecordFilter('pending')
setRecordFilter('reviewing')
setActiveTab('records')
} catch (err: any) {
message.error(err.message || '发布寄卖失败,请重试')
@@ -202,32 +251,94 @@ export default function WorkerConsignmentPage() {
}
}
const filteredRecords = useMemo(() => {
const pendingSubs = submissions.filter((s) => s.status === 'pending_review')
const rejectedSubs = submissions.filter((s) => s.status === 'rejected')
const approvedSubs = submissions.filter((s) => s.status === 'approved')
const inStock = inventories.filter((i) => i.status === 'in_stock')
const done = inventories.filter((i) => i.status === 'consumed' || i.status === 'archived')
/**
* 定位要操作的寄卖单:有提报单号(寄卖单号)就用它,客服手工入库的现货回退用库存号。
* 两条路由在后端是分开的,这里用 submissionId/inventoryId 是否为 null 决定,
* 前端不会把两个号混用。
*/
function resolveActionAnchor(item: WorkerConsignmentRecord) {
if (item.submissionId != null) return { kind: 'submission' as const, id: item.submissionId }
if (item.inventoryId != null) return { kind: 'inventory' as const, id: item.inventoryId }
message.warning('该寄卖单缺少可操作的编号,请联系客服处理')
return null
}
if (recordFilter === 'pending') {
return {
submissions: [...pendingSubs, ...rejectedSubs],
inventories: [] as WorkerInventoryItem[],
}
}
if (recordFilter === 'in_stock') {
return { submissions: [] as WorkerConsignmentItem[], inventories: inStock }
}
if (recordFilter === 'done') {
return { submissions: [] as WorkerConsignmentItem[], inventories: done }
}
return {
submissions: [...pendingSubs, ...rejectedSubs, ...approvedSubs],
inventories,
}
}, [submissions, inventories, recordFilter])
function handleConfirmFriend(item: WorkerConsignmentRecord) {
const anchor = resolveActionAnchor(item)
if (!anchor) return
const recordTotal = filteredRecords.submissions.length + filteredRecords.inventories.length
modal.confirm({
title: '确认已在游戏内加好买家好友?',
content: (
<div>
<p>确认后系统立即开始赠送冷却,请逐条确认风险:</p>
<p style={{ marginBottom: 4 }}>1. 确认后立即进入赠送冷却期,冷却期间不能赠送;</p>
<p style={{ marginBottom: 4 }}>2. 冷却期结束后必须用本人账号完成赠送并上传截图;</p>
<p style={{ marginBottom: 0 }}>
<Typography.Text type="danger" strong>
3. 冷却期结束后未完成赠送的,履约押金不退。
</Typography.Text>
</p>
</div>
),
okText: '确认加好友,开始冷却',
okType: 'danger',
cancelText: '暂不确认',
centered: true,
onOk: async () => {
try {
await (anchor.kind === 'submission'
? confirmWorkerConsignmentFriendAdded(anchor.id)
: confirmWorkerConsignmentInventoryFriendAdded(anchor.id))
message.success('已确认加好友,赠送冷却开始')
await myConsignmentsQuery.refetch()
} catch (err: any) {
message.error(err?.message || '确认加好友失败,请刷新后重试')
}
},
})
}
function openEvidenceModal(item: WorkerConsignmentRecord) {
if (!resolveActionAnchor(item)) return
setEvidenceItem(item)
setEvidenceFiles([])
setEvidenceNote('')
}
function closeEvidenceModal() {
setEvidenceItem(null)
setEvidenceFiles([])
setEvidenceNote('')
}
async function handleEvidenceSubmit() {
const anchor = evidenceItem ? resolveActionAnchor(evidenceItem) : null
if (!anchor) return
const urls = evidenceFiles.map((file) => file.url).filter(Boolean)
if (urls.length === 0) {
message.warning('请先上传赠送完成截图,客服需要凭截图验收')
return
}
setEvidenceSubmitting(true)
try {
const note = evidenceNote.trim()
const payload = note ? { imageUrls: urls, note } : { imageUrls: urls }
await (anchor.kind === 'submission'
? submitWorkerConsignmentGiftEvidence(anchor.id, payload)
: submitWorkerConsignmentInventoryGiftEvidence(anchor.id, payload))
message.success('赠送截图已提交,等待客服验收')
closeEvidenceModal()
await myConsignmentsQuery.refetch()
} catch (err: any) {
message.error(err?.message || '提交赠送截图失败,请重试')
} finally {
setEvidenceSubmitting(false)
}
}
const recordTotal = filteredRecords.length
return (
<section className="page-stack worker-consignment-page">
@@ -254,9 +365,9 @@ export default function WorkerConsignmentPage() {
<div className="worker-orders-kpi-grid">
<div
className={`worker-orders-kpi-card ${recordFilter === 'pending' && activeTab === 'records' ? 'active' : ''}`}
className={`worker-orders-kpi-card ${recordFilter === 'reviewing' && activeTab === 'records' ? 'active' : ''}`}
onClick={() => {
setRecordFilter('pending')
setRecordFilter('reviewing')
setActiveTab('records')
}}
>
@@ -265,24 +376,22 @@ export default function WorkerConsignmentPage() {
<ClockCircleOutlined />
</div>
<div className="worker-orders-kpi-info">
<span className="worker-orders-kpi-title">审核中 / 驳回</span>
<span className="worker-orders-kpi-title">待审核</span>
<strong className="worker-orders-kpi-val">
{pendingCount + rejectedCount}
{reviewingCount}
<span className="worker-orders-kpi-unit"> 单</span>
</strong>
</div>
</div>
<div className="worker-orders-kpi-bottom">
<span className="worker-orders-kpi-sub-label">
{pendingCount > 0 ? `${pendingCount} 单待客服审核` : '暂无待审提报'}
</span>
<span className="worker-orders-kpi-sub-label">客服审核中,通过后自动入库待匹配</span>
</div>
</div>
<div
className={`worker-orders-kpi-card ${recordFilter === 'in_stock' && activeTab === 'records' ? 'active' : ''}`}
className={`worker-orders-kpi-card ${recordFilter === 'active' && activeTab === 'records' ? 'active' : ''}`}
onClick={() => {
setRecordFilter('in_stock')
setRecordFilter('active')
setActiveTab('records')
}}
>
@@ -291,22 +400,22 @@ export default function WorkerConsignmentPage() {
<InboxOutlined />
</div>
<div className="worker-orders-kpi-info">
<span className="worker-orders-kpi-title">在库待派单</span>
<span className="worker-orders-kpi-title">履约中</span>
<strong className="worker-orders-kpi-val">
{inStockCount}
{activeCount}
<span className="worker-orders-kpi-unit"> 把</span>
</strong>
</div>
</div>
<div className="worker-orders-kpi-bottom">
<span className="worker-orders-kpi-sub-label">已入库,等待匹配买家工单</span>
<span className="worker-orders-kpi-sub-label">待加好友 / 冷却 / 待赠送 / 待验收</span>
</div>
</div>
<div
className={`worker-orders-kpi-card ${recordFilter === 'done' && activeTab === 'records' ? 'active' : ''}`}
className={`worker-orders-kpi-card ${recordFilter === 'finished' && activeTab === 'records' ? 'active' : ''}`}
onClick={() => {
setRecordFilter('done')
setRecordFilter('finished')
setActiveTab('records')
}}
>
@@ -315,15 +424,15 @@ export default function WorkerConsignmentPage() {
<CheckCircleOutlined />
</div>
<div className="worker-orders-kpi-info">
<span className="worker-orders-kpi-title">已履约出库</span>
<span className="worker-orders-kpi-title">已完成</span>
<strong className="worker-orders-kpi-val">
{doneCount}
{finishedCount}
<span className="worker-orders-kpi-unit"> 把</span>
</strong>
</div>
</div>
<div className="worker-orders-kpi-bottom">
<span className="worker-orders-kpi-sub-label">押金履约交付完成</span>
<span className="worker-orders-kpi-sub-label">含已完成、已驳回、已下架作废</span>
</div>
</div>
@@ -594,10 +703,8 @@ export default function WorkerConsignmentPage() {
label: (
<span>
<HistoryOutlined /> 我的寄卖
{submissions.length + inventories.length > 0 ? (
<span className="worker-order-tab-count">
{submissions.length + inventories.length}
</span>
{records.length > 0 ? (
<span className="worker-order-tab-count">{records.length}</span>
) : null}
</span>
),
@@ -606,10 +713,10 @@ export default function WorkerConsignmentPage() {
<div className="worker-consignment-filter-row">
{(
[
{ key: 'all', label: '全部' },
{ key: 'pending', label: '审核中' },
{ key: 'in_stock', label: '在库' },
{ key: 'done', label: '已出库' },
{ key: 'all', label: '全部', count: records.length },
{ key: 'reviewing', label: '待审核', count: groupCounts.reviewing },
{ key: 'active', label: '履约中', count: groupCounts.active },
{ key: 'finished', label: '已完成', count: groupCounts.finished },
] as const
).map((item) => (
<Button
@@ -619,6 +726,7 @@ export default function WorkerConsignmentPage() {
onClick={() => setRecordFilter(item.key)}
>
{item.label}
{item.count > 0 ? ` (${item.count})` : ''}
</Button>
))}
</div>
@@ -628,19 +736,28 @@ export default function WorkerConsignmentPage() {
) : recordTotal === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无寄卖记录,去发布一单苏尔南吧"
description={
recordFilter === 'all'
? '暂无寄卖记录,去发布一单苏尔南吧'
: '当前筛选下没有寄卖记录'
}
>
<Button type="primary" onClick={() => setActiveTab('publish')}>
发布寄卖
</Button>
{recordFilter === 'all' ? (
<Button type="primary" onClick={() => setActiveTab('publish')}>
发布寄卖
</Button>
) : null}
</Empty>
) : (
<div className="worker-consignment-record-list">
{filteredRecords.inventories.map((inv) => (
<InventoryRecordCard key={`inv-${inv.id}`} inv={inv} />
))}
{filteredRecords.submissions.map((sub) => (
<SubmissionRecordCard key={`sub-${sub.id}`} sub={sub} />
{filteredRecords.map((record) => (
<ConsignmentRecordCard
key={record.consignmentNo}
record={record}
now={cooldownNow}
onConfirmFriend={handleConfirmFriend}
onSubmitEvidence={openEvidenceModal}
/>
))}
</div>
)}
@@ -650,6 +767,43 @@ export default function WorkerConsignmentPage() {
]}
/>
</Card>
<Modal
open={evidenceItem != null}
title={`${evidenceItem?.evidenceButtonLabel || '提交赠送截图'} · ${evidenceItem?.consignmentNo || ''}`}
okText="提交给客服审核"
cancelText="取消"
confirmLoading={evidenceSubmitting}
onOk={handleEvidenceSubmit}
onCancel={closeEvidenceModal}
destroyOnHidden
centered
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="请上传游戏内赠送完成的截图(含买家角色名/ID、赠送记录)"
description="客服核对后才算履约完成;冷却未结束时提交会被系统拒绝。"
/>
<ImageUpload
value={evidenceFiles}
onChange={setEvidenceFiles}
scene="worker-acceptance"
scope="worker"
maxCount={6}
showPasteHint={!isMobile}
/>
<Input.TextArea
rows={2}
maxLength={200}
showCount
placeholder="备注(选填,如赠送时间、角色名)"
value={evidenceNote}
onChange={(event) => setEvidenceNote(event.target.value)}
/>
</Space>
</Modal>
</section>
)
}
@@ -678,87 +832,77 @@ function SpecTags({
)
}
function InventoryRecordCard({ inv }: { inv: WorkerInventoryItem }) {
return (
<article className="worker-consignment-record-card">
<div className="worker-consignment-record-main">
<div className="worker-consignment-record-title-row">
<Typography.Text strong>{inv.itemName}</Typography.Text>
<Typography.Text type="secondary" className="worker-consignment-record-no">
{inv.inventoryNo}
</Typography.Text>
{inv.status === 'in_stock' ? (
<Tag color="green">在库待派</Tag>
) : inv.status === 'consumed' ? (
<Tag color="blue">已履约</Tag>
) : (
<Tag>已下架</Tag>
)}
</div>
<SpecTags
system={inv.system}
serverZone={inv.serverZone}
series={inv.series}
diamondLevel={inv.diamondLevel}
brillianceCount={inv.brillianceCount}
/>
<div className="worker-consignment-record-meta">
<span>
货款 <strong>¥{(inv.costPriceFen / 100).toFixed(2)}</strong>
</span>
<span>
押金 <strong>¥{((inv.depositAmountFen || 0) / 100).toFixed(0)}</strong>
</span>
{inv.boundPlatformOrderId ? (
<Typography.Text copyable={{ text: inv.boundPlatformOrderId }}>
履约单 {inv.boundPlatformOrderId}
</Typography.Text>
) : null}
</div>
</div>
<Typography.Text type="secondary" className="worker-consignment-record-time">
{inv.createdAt}
</Typography.Text>
</article>
)
}
function SubmissionRecordCard({ sub }: { sub: WorkerConsignmentItem }) {
const shots = Array.isArray(sub.screenshotsJson)
? sub.screenshotsJson.filter((s): s is string => typeof s === 'string')
/**
* 「我的寄卖」记录卡片:一件货一张卡。
*
* 号段只显示后端下发的 `consignmentNo`(提报单号,客服手工入库时是库存号),
* 阶段文案 / 配色 / 可执行动作全部用后端下发字段,前端只渲染 + 发请求。
*/
function ConsignmentRecordCard({
record,
now,
onConfirmFriend,
onSubmitEvidence,
}: {
record: WorkerConsignmentRecord
now: number
onConfirmFriend: (record: WorkerConsignmentRecord) => void
onSubmitEvidence: (record: WorkerConsignmentRecord) => void
}) {
const shots = Array.isArray(record.screenshotsJson)
? record.screenshotsJson.filter((s): s is string => typeof s === 'string')
: []
// 只有后端把阶段标成 gift_cooldown 时才显示倒计时,前端不自己推阶段
const cooldown =
record.stage === 'gift_cooldown' && record.giftAvailableAt
? formatGiftCooldownRemaining(record.giftAvailableAt, now)
: null
const cooldownDeadline = record.giftAvailableAt ? formatDateTime(record.giftAvailableAt) : ''
return (
<article className="worker-consignment-record-card">
<div className="worker-consignment-record-main">
<div className="worker-consignment-record-title-row">
<Typography.Text strong>{sub.itemName}</Typography.Text>
<Typography.Text strong>{record.itemName}</Typography.Text>
<Typography.Text type="secondary" className="worker-consignment-record-no">
{sub.submissionNo}
寄卖单号 {record.consignmentNo}
</Typography.Text>
{sub.status === 'pending_review' ? (
<Tag color="gold">审核中</Tag>
) : sub.status === 'approved' ? (
<Tag color="green">已通过</Tag>
) : (
<Tag color="red">已驳回</Tag>
)}
<Tag color={record.color}>{record.label}</Tag>
</div>
<SpecTags
system={sub.system}
serverZone={sub.serverZone}
series={sub.series}
diamondLevel={sub.diamondLevel}
brillianceCount={sub.brillianceCount}
system={record.system}
serverZone={record.serverZone}
series={record.series}
diamondLevel={record.diamondLevel}
brillianceCount={record.brillianceCount}
/>
<div className="worker-consignment-record-meta">
<span>
期望价 <strong>¥{(sub.askingPriceFen / 100).toFixed(2)}</strong>
寄售价 <strong>¥{(record.askingPriceFen / 100).toFixed(2)}</strong>
</span>
{sub.reviewNote ? (
<Typography.Text type="danger">审核说明:{sub.reviewNote}</Typography.Text>
) : null}
<span>
押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong>
</span>
{record.workOrderId ? <span>履约单 #{record.workOrderId}</span> : null}
</div>
{record.reviewNote ? (
<Typography.Text type="danger">审核说明:{record.reviewNote}</Typography.Text>
) : null}
{record.hint ? (
<Typography.Text type="secondary" className="worker-consignment-record-hint">
{record.hint}
</Typography.Text>
) : null}
{cooldown?.text ? (
<Space size={6} wrap>
<Tag color={cooldown.finished ? 'green' : 'gold'}>{cooldown.text}</Tag>
{cooldownDeadline ? (
<Typography.Text type="secondary" className="worker-consignment-record-hint">
冷却截止 {cooldownDeadline},到点后点右上角「刷新」查看最新状态
</Typography.Text>
) : null}
</Space>
) : null}
{shots.length > 0 ? (
<div className="worker-consignment-record-shots">
<Image.PreviewGroup>
@@ -776,9 +920,26 @@ function SubmissionRecordCard({ sub }: { sub: WorkerConsignmentItem }) {
</Image.PreviewGroup>
</div>
) : null}
{record.canConfirmFriend || record.canSubmitEvidence ? (
<div className="worker-consignment-record-actions">
{record.canConfirmFriend ? (
<Button type="primary" onClick={() => onConfirmFriend(record)}>
确认加好友
</Button>
) : null}
{record.canSubmitEvidence ? (
<Button
type={record.canConfirmFriend ? 'default' : 'primary'}
onClick={() => onSubmitEvidence(record)}
>
{record.evidenceButtonLabel}
</Button>
) : null}
</div>
) : null}
</div>
<Typography.Text type="secondary" className="worker-consignment-record-time">
{sub.createdAt}
{formatDateTime(record.createdAt)}
</Typography.Text>
</article>
)
+99 -13
View File
@@ -390,21 +390,68 @@ export type WorkerConsignmentItem = {
createdAt: string
}
export type WorkerInventoryItem = {
id: number | string
inventoryNo: string
/** 寄卖单统一状态机阶段(后端推导,前端只透传展示) */
export type WorkerConsignmentStage =
| 'pending_review'
| 'in_stock'
| 'matched_waiting_friend'
| 'gift_cooldown'
| 'gift_ready'
| 'gift_submitted'
| 'fulfilling'
| 'pending_acceptance'
| 'completed'
| 'rejected'
| 'archived'
| 'cancelled'
export type WorkerConsignmentGroup = 'reviewing' | 'active' | 'finished'
/**
* 「我的寄卖」列表行:**一件货一行**。
*
* 后端已把提报单(SUB…)/ 库存(INV…)/ 绑定工单三套状态聚合成一条记录,
* 以 `consignmentNo` 为唯一锚点;阶段文案、配色与可执行动作也由后端下发,
* 前端不再自己推导状态,避免和客服侧口径不一致。
*/
export type WorkerConsignmentRecord = {
/** 唯一锚点:提报单号(客服手工入库时是库存号),界面只显示它 */
consignmentNo: string
submissionId: number | null
inventoryId: number | null
workOrderId: number | null
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
costPriceFen: number
status: 'in_stock' | 'consumed' | 'archived'
allocationStatus: string
boundPlatformOrderId: string
depositAmountFen?: number
askingPriceFen: number
depositAmountFen: number
reviewNote: string
screenshotsJson: string[]
createdAt: string
stage: WorkerConsignmentStage
/** 中文阶段名,如「已匹配-待加好友」 */
label: string
group: WorkerConsignmentGroup
/** antd Tag 颜色名 */
color: string
/** 给卖家看的下一步提示,直接展示 */
hint: string
canConfirmFriend: boolean
canSubmitEvidence: boolean
evidenceButtonLabel: '提交赠送截图' | '提交验收材料'
/** 赠送冷却截止时间,前端拿它做倒计时 */
giftAvailableAt: string | null
friendAddedAt: string | null
}
export type WorkerConsignmentListResponse = {
publishDepositFen: number
items: WorkerConsignmentRecord[]
/** 各阶段件数,用于筛选 tab 角标 */
stageCounts: Record<string, number>
}
export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayload) {
@@ -412,9 +459,48 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa
}
export function fetchWorkerConsignments() {
return apiGet<{
publishDepositFen: number
submissions: WorkerConsignmentItem[]
inventories: WorkerInventoryItem[]
}>('/api/v1/worker/consignment/my-items')
return apiGet<WorkerConsignmentListResponse>('/api/v1/worker/consignment/my-items')
}
/**
* 卖家确认已在游戏内加好买家好友 → 进入赠送冷却期。
* 注意:确认后若未完成赠送,履约押金不退。
*/
export function confirmWorkerConsignmentFriendAdded(submissionId: number | string) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/worker/consignment/items/${submissionId}/confirm-friend`,
{},
)
}
/** 卖家提交赠送完成截图(后端复用打手验收链路,工单转「待验收」交客服审核)。 */
export function submitWorkerConsignmentGiftEvidence(
submissionId: number | string,
payload: { imageUrls: string[]; note?: string },
) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/worker/consignment/items/${submissionId}/gift-evidence`,
payload,
)
}
/**
* 客服手工入库的现货没有提报单号,按库存号走同一套动作
* (后端单独开路由,不用"一个 id 两义",避免提报单号与库存号撞号时误操作)。
*/
export function confirmWorkerConsignmentInventoryFriendAdded(inventoryId: number | string) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/worker/consignment/inventories/${inventoryId}/confirm-friend`,
{},
)
}
export function submitWorkerConsignmentInventoryGiftEvidence(
inventoryId: number | string,
payload: { imageUrls: string[]; note?: string },
) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/worker/consignment/inventories/${inventoryId}/gift-evidence`,
payload,
)
}
+13
View File
@@ -4429,6 +4429,19 @@ body {
margin-top: 2px;
}
/* 「下一步提示」与倒计时说明:比主信息弱一档的次要文字 */
.worker-consignment-record-hint {
font-size: 12px;
line-height: 1.5;
}
.worker-consignment-record-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 2px;
}
@media (max-width: 768px) {
.worker-consignment-record-card {
flex-direction: column;
+18
View File
@@ -28,6 +28,8 @@ export type ConsignmentInventory = {
boundPlatformOrderId: string
sourceType: 'manual' | 'worker_portal' | 'recycle'
sourceSubmissionId: number | null
/** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */
sourceSubmissionNo?: string
workerId?: number | null
workerName?: string
workerPhone?: string
@@ -40,6 +42,22 @@ export type ConsignmentInventory = {
updatedAt: string
}
/**
* 工单 `material.consignment` 里的寄售履约信息(履约/指派时后端写入)。
* 历史老单没有 sourceSubmissionNo,展示时必须回退到 inventoryNo。
*/
export type ConsignmentFulfillmentInfo = {
inventoryId?: number | null
inventoryNo?: string
sourceSubmissionId?: number | null
/** 来源提报单号(提报单号即寄卖单号);历史老单没有该字段。 */
sourceSubmissionNo?: string
costPriceFen?: number
fulfillmentType?: string
workerId?: number | null
[key: string]: unknown
}
export type ConsignmentSubmission = {
id: number
submissionNo: string
@@ -0,0 +1,72 @@
/**
* 寄卖「加好友赠送冷却」倒计时文案。
*
* 后端只给一个冷却截止时间(giftAvailableAt),卖家端负责把它变成看得懂的剩余时间。
* 这里刻意写成纯函数:不依赖 React / antd,也不使用 `@/` 别名,方便被
* `node --experimental-strip-types --test` 直接跑单测。
*/
export type GiftCooldownRemaining = {
/** 展示文案;无有效截止时间时为空字符串(调用方不渲染倒计时) */
text: string
/** 冷却是否已经结束 */
finished: boolean
}
const MINUTE_MS = 60_000
const HOUR_MS = 3_600_000
/** 冷却已结束的统一文案 */
const FINISHED_TEXT = '冷却已结束,可赠送'
/**
* 计算冷却剩余时间文案。
*
* - 截止时间早于(或等于)now:`冷却已结束,可赠送`,`finished: true`
* - 剩余不足 1 小时:`冷却中,剩余 X 分`
* - 剩余不足 1 天:`冷却中,剩余 X 小时 Y 分`(整小时时省略「0 分」)
* - 剩余 1 天以上:`冷却中,剩余 X 天 Y 小时`(整小时时省略「0 小时」)
*
* 空值 / 非法时间返回 `{ text: '', finished: false }`:既不展示倒计时,也不误判为已结束。
*/
export function formatGiftCooldownRemaining(
giftAvailableAt: string | null | undefined,
now: number = Date.now(),
): GiftCooldownRemaining {
const raw = String(giftAvailableAt ?? '').trim()
if (!raw) {
return { text: '', finished: false }
}
const targetMs = Date.parse(raw)
if (!Number.isFinite(targetMs)) {
return { text: '', finished: false }
}
const nowMs = Number.isFinite(now) ? Number(now) : Date.now()
const remainingMs = targetMs - nowMs
if (remainingMs <= 0) {
return { text: FINISHED_TEXT, finished: true }
}
return { text: `冷却中,剩余 ${formatRemaining(remainingMs)}`, finished: false }
}
function formatRemaining(remainingMs: number) {
// 不足 1 小时只按分钟展示,向上取整避免出现「剩余 0 分」
if (remainingMs < HOUR_MS) {
return `${Math.max(1, Math.ceil(remainingMs / MINUTE_MS))} 分`
}
const totalHours = Math.ceil(remainingMs / HOUR_MS)
if (totalHours < 24) {
const totalMinutes = Math.ceil(remainingMs / MINUTE_MS)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`
}
const days = Math.floor(totalHours / 24)
const hours = totalHours % 24
return hours === 0 ? `${days} 天` : `${days} 天 ${hours} 小时`
}