Files
order_site/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx
T
yml2213 563e548b2e feat(consignment): 补齐寄卖前端权限门控,收口调拨状态与运营体验
权限门控(P1-8 剩余):
- 新增 resolveAdminPermission(sessionPermissions, code):会话接口的最新权限列表优先,
  接口异常才回退登录时的本地快照;空数组也判无权限,避免"刚被收回权限仍能点"。
- 寄售仓库面板:consignment.manage 控制手动入库/编辑/作废/退单回库/匹配开关(无权限时开关禁用)。
- 寄卖提报面板:consignment.review 控制审核入库/驳回。
- 接单平台页:无 consignment.view 不渲染两个寄卖 tab,并把 URL/localStorage 的旧选择拉回工单页。

数据约束(P2-19 剩余):
- 迁移 080:allocation_status 历史非法值归位「待安排」(备注留痕)后加 CHECK(待安排/已安排),
  并顺手修正 077 遗留的"收集链接/玩家提交"权限描述文案。
- 编辑接口拒绝直接改 allocationStatus(400 consignment_allocation_status_update_forbidden)。

运营体验:
- 两个面板补 isError 错误态与重试;审核通过/驳回加 confirmLoading 与提交中守卫(防双提交)。
- 待审徽标改用后端真实总数(原来只统计当前页);编辑弹窗押金不再兜底 ¥100(会误写押金)。
- 履约拦截弹窗冷静期文案不再硬编码"3 天",改为按商品规则设定。
- 核实并撤回"倒计时时区偏移"误报:deadlineAt 为带 Z 的 ISO,前端无偏移问题。

验证:后端 412 例(409 通过)、前端 21 例(新增 5 例权限解析)全绿,
typecheck / lint / format / check:sql / build 通过;迁移 001→080 在全新空库跑通并在 dev 库应用。
2026-09-16 20:39:52 +08:00

695 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CheckCircleOutlined,
CloseCircleOutlined,
EyeOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Badge,
Button,
Card,
Col,
Descriptions,
Form,
Image,
Input,
InputNumber,
Modal,
Radio,
Row,
Select,
Space,
Table,
Tag,
Typography,
App,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import {
approveAdminConsignmentSubmission,
fetchAdminConsignmentSubmissions,
rejectAdminConsignmentSubmission,
} from '@/services/admin/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import type { ConsignmentSubmission } from '@/types/consignment'
import { resolveAdminPermission } from '@/utils/admin-auth'
const { Text } = Typography
export default function ConsignmentSubmissionsPanel() {
const { message } = App.useApp()
const [submissionStatus, setSubmissionStatus] = useState<string>('pending_review')
const [submissionKeyword, setSubmissionKeyword] = useState<string>('')
const [submissionPage, setSubmissionPage] = useState<number>(1)
const [submissionPageSize, setSubmissionPageSize] = useState<number>(20)
const [approveModalItem, setApproveModalItem] = useState<ConsignmentSubmission | null>(null)
const [rejectModalItem, setRejectModalItem] = useState<ConsignmentSubmission | null>(null)
const [viewingSubmission, setViewingSubmission] = useState<ConsignmentSubmission | null>(null)
const [rejectNote, setRejectNote] = useState('')
const [approveForm] = Form.useForm()
const [approving, setApproving] = useState(false)
const [rejecting, setRejecting] = useState(false)
// 通过/驳回要 consignment.review:后端接口已挂权限,前端不隐藏会让人点了必 403
const adminSessionQuery = useQuery({
queryKey: ['admin-session'],
queryFn: fetchAdminSession,
retry: false,
})
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
const canReviewConsignment = resolveAdminPermission(sessionPermissions, 'consignment.review')
const workersQuery = useQuery({
queryKey: ['admin-worker-users-consignment-options'],
queryFn: () => fetchAdminWorkerUsers({ page: 1, pageSize: 200, status: 'active' }),
})
const workerOptions = (workersQuery.data?.data.items || []).map((w) => ({
label: `${w.displayName || w.username} (${w.phone || '无手机号'}) [ID:${w.workerId}]`,
value: w.workerId,
phone: w.phone,
name: w.displayName || w.username,
}))
const submissionsQuery = useQuery({
queryKey: [
'admin-consignment-submissions',
submissionStatus,
submissionKeyword,
submissionPage,
submissionPageSize,
],
queryFn: () =>
fetchAdminConsignmentSubmissions({
status: submissionStatus === 'all' ? undefined : submissionStatus,
keyword: submissionKeyword || undefined,
page: submissionPage,
pageSize: submissionPageSize,
}),
})
const submissions = submissionsQuery.data?.data.items || []
const submissionTotal = submissionsQuery.data?.data.total || 0
// 待审徽标必须用后端总数:原来只统计当前页(pageSize=20 时最多显示 20,且翻页就变)。
const pendingTotalQuery = useQuery({
queryKey: ['admin-consignment-submissions-pending-total'],
queryFn: () =>
fetchAdminConsignmentSubmissions({
status: 'pending_review',
page: 1,
pageSize: 1,
}),
refetchInterval: 60_000,
})
const pendingCount = pendingTotalQuery.data?.data.total || 0
async function handleApproveSubmit() {
if (!approveModalItem || approving) return
setApproving(true)
try {
const values = await approveForm.validateFields()
const costPriceFen = Math.round(Number(values.costPrice || 0) * 100)
const depositAmountFen = values.depositAmount
? Math.round(Number(values.depositAmount) * 100)
: undefined
const selectedWorker = workerOptions.find((w) => w.value === values.workerId)
await approveAdminConsignmentSubmission(approveModalItem.id, {
costPriceFen,
buyerName: values.buyerName,
shopName: values.shopName,
inboundBatch: values.inboundBatch,
itemName: values.itemName,
system: values.system,
serverZone: values.serverZone,
series: values.series,
diamondLevel: Number(values.diamondLevel || 1),
brillianceCount: Number(values.brillianceCount || 0),
sellerContact: values.sellerContact,
sellerGameId: values.sellerGameId,
sellerPaymentInfo: values.sellerPaymentInfo,
sellerPhone: values.sellerPhone,
workerId: values.workerId || undefined,
workerName: selectedWorker?.name || approveModalItem.workerName || undefined,
workerPhone: selectedWorker?.phone || approveModalItem.workerPhone || undefined,
depositAmountFen,
reviewNote: values.reviewNote,
})
message.success('审核通过!已正式存入寄售库存待匹配!')
setApproveModalItem(null)
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
} catch (err: any) {
if (err.errorFields) return
message.error(err.message || '审核入库失败')
} finally {
setApproving(false)
}
}
async function handleRejectConfirm() {
if (!rejectModalItem || rejecting) return
setRejecting(true)
try {
await rejectAdminConsignmentSubmission(rejectModalItem.id, { reviewNote: rejectNote })
message.success('已驳回该提报申请')
setRejectModalItem(null)
setRejectNote('')
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
} catch (err: any) {
message.error(err.message || '驳回失败')
} finally {
setRejecting(false)
}
}
function resolveSubmissionSource(record: ConsignmentSubmission) {
const channel = (record.contentJson as { channel?: string } | undefined)?.channel
if (channel === 'worker_portal' || record.workerId) return '打手端提报'
return '其他渠道'
}
const submissionColumns: TableColumnsType<ConsignmentSubmission> = [
{
title: '提报单号',
key: 'submissionNo',
width: 180,
render: (_, record) => (
<div>
<Text strong>{record.submissionNo}</Text>
<div style={{ fontSize: 11, color: '#8c8c8c' }}>{resolveSubmissionSource(record)}</div>
</div>
),
},
{
title: '提报物品规格',
key: 'specs',
width: 210,
render: (_, record) => (
<Space direction="vertical" size={2}>
<div style={{ fontWeight: 600 }}>{record.itemName}</div>
<Space size={4} wrap>
<Tag color={record.system === '苹果' ? 'geekblue' : 'green'}>{record.system}</Tag>
<Tag color="cyan">{record.serverZone}</Tag>
<Tag color="orange">{record.series}</Tag>
<Tag color="gold">{record.diamondLevel} 钻</Tag>
{record.brillianceCount > 0 ? (
<Tag color="magenta">{record.brillianceCount} 璀璨</Tag>
) : null}
</Space>
</Space>
),
},
{
title: '卖家报价',
key: 'price',
width: 110,
render: (_, record) => (
<Text strong style={{ color: '#cf1322', fontSize: 15 }}>
¥{(record.askingPriceFen / 100).toFixed(2)}
</Text>
),
},
{
title: '卖家与关联打手',
key: 'contact',
width: 200,
render: (_, record) => (
<div style={{ fontSize: 12 }}>
<div>联系: {record.contactInfo}</div>
{record.sellerGameId ? <div>游戏ID: {record.sellerGameId}</div> : null}
{record.sellerPhone ? <div>电话: {record.sellerPhone}</div> : null}
{record.workerId ? (
<div style={{ marginTop: 4 }}>
<Tag color="blue">
打手: {record.workerName || `ID:${record.workerId}`}
{record.depositAmountFen
? ` (押¥${(record.depositAmountFen / 100).toFixed(0)})`
: ''}
</Tag>
</div>
) : null}
</div>
),
},
{
title: '凭据截图',
key: 'screens',
width: 110,
render: (_, record) => {
const screens = Array.isArray(record.screenshotsJson) ? record.screenshotsJson : []
if (screens.length === 0) return <Text type="secondary">无截图</Text>
return (
<Image.PreviewGroup>
<Image
src={screens[0]}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
</Image.PreviewGroup>
)
},
},
{
title: '状态',
key: 'status',
width: 100,
render: (_, record) => {
if (record.status === 'pending_review') return <Tag color="gold">待客服审核</Tag>
if (record.status === 'approved') return <Tag color="green">已入库</Tag>
return <Tag color="red">已驳回</Tag>
},
},
{
title: '提报时间',
dataIndex: 'createdAt',
width: 150,
render: (v) => <Text style={{ fontSize: 12 }}>{v}</Text>,
},
{
title: '操作',
key: 'actions',
width: 200,
render: (_, record) => (
<Space size={8}>
<Button
size="small"
type="text"
icon={<EyeOutlined />}
onClick={() => setViewingSubmission(record)}
>
详情
</Button>
{record.status === 'pending_review' && canReviewConsignment ? (
<>
<Button
size="small"
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => {
setApproveModalItem(record)
approveForm.setFieldsValue({
...record,
costPrice: record.askingPriceFen / 100,
sellerContact: record.contactInfo,
buyerName: '客服',
inboundBatch: new Date().toISOString().slice(0, 7),
workerId: record.workerId || undefined,
depositAmount: record.depositAmountFen
? record.depositAmountFen / 100
: record.askingPriceFen / 100,
})
}}
>
审核入库
</Button>
<Button
size="small"
danger
type="text"
icon={<CloseCircleOutlined />}
onClick={() => {
setRejectModalItem(record)
setRejectNote('')
}}
>
驳回
</Button>
</>
) : null}
</Space>
),
},
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card size="small">
<Space wrap size={12}>
<Radio.Group
value={submissionStatus}
onChange={(e) => {
setSubmissionStatus(e.target.value)
setSubmissionPage(1)
}}
buttonStyle="solid"
>
<Radio.Button value="pending_review">
待客服审核
{pendingCount > 0 ? (
<Badge count={pendingCount} size="small" offset={[6, -2]} />
) : null}
</Radio.Button>
<Radio.Button value="approved">已审核入库</Radio.Button>
<Radio.Button value="rejected">已驳回</Radio.Button>
<Radio.Button value="all">全部</Radio.Button>
</Radio.Group>
<Input.Search
placeholder="搜索提报单号/微信号/游戏ID/打手"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setSubmissionKeyword(v)
setSubmissionPage(1)
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => submissionsQuery.refetch()}>
刷新
</Button>
</Space>
</Card>
<Card size="small">
{submissionsQuery.isError ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 12 }}
message="读取寄卖提报单失败"
description={
(submissionsQuery.error as any)?.message ||
'请检查网络或稍后重试;也可能是当前账号缺少 consignment.view 权限。'
}
action={
<Button size="small" onClick={() => void submissionsQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<Table
rowKey="id"
columns={submissionColumns}
dataSource={submissions}
loading={submissionsQuery.isLoading}
pagination={{
current: submissionPage,
pageSize: submissionPageSize,
total: submissionTotal,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条提报记录`,
onChange: (p, ps) => {
setSubmissionPage(p)
setSubmissionPageSize(ps)
},
}}
/>
</Card>
<Modal
title="审核寄卖申请并正式入库"
open={Boolean(approveModalItem)}
width={680}
onCancel={() => setApproveModalItem(null)}
onOk={handleApproveSubmit}
confirmLoading={approving}
okText="审核通过并正式入库"
okType="primary"
destroyOnClose
>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">
提报人联系方式:<strong>{approveModalItem?.contactInfo}</strong> | 游戏ID:
<strong>{approveModalItem?.sellerGameId || '-'}</strong> | 卖家要价:
<strong style={{ color: '#cf1322' }}>
¥{((approveModalItem?.askingPriceFen || 0) / 100).toFixed(2)}
</strong>
</Text>
</div>
<Form form={approveForm} layout="vertical">
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="itemName"
label="正式入库物品名"
rules={[{ required: true, message: '请输入入库物品名' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
name="costPrice"
label="实际收购成本 (元)"
rules={[{ required: true, message: '请输入实际收购成本' }]}
>
<InputNumber min={0} step={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={5}>
<Form.Item name="system" label="系统">
<Input />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item name="serverZone" label="区服">
<Input />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item name="series" label="系列">
<Input />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item name="diamondLevel" label="钻石等级 (1-7)">
<InputNumber min={1} max={7} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item name="brillianceCount" label="璀璨数 (0-7)">
<InputNumber min={0} max={7} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="workerId"
label="走打手接单平台 (关联履约打手)"
tooltip="指派该打手接单丢枪交货时,接单平台将自动冻结其押金并担保交付"
>
<Select
allowClear
showSearch
filterOption={(input, option) =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
placeholder="可选择关联的接单打手 (选填)"
options={workerOptions}
/>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
name="depositAmount"
label="履约押金金额 (元)"
tooltip="指派该打手交枪履约时,系统冻结的押金金额"
>
<InputNumber
min={0}
step={10}
addonBefore="¥"
style={{ width: '100%' }}
placeholder="如 100"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="buyerName"
label="收购人 (客服)"
rules={[{ required: true, message: '请填写收购人' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="shopName" label="关联店铺">
<Input placeholder="店铺名" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="inboundBatch" label="收购批次">
<Input placeholder="批次" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="sellerContact" label="卖家微信号">
<Input />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="sellerPhone" label="卖家手机">
<Input />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="sellerGameId" label="卖家游戏ID">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item name="sellerPaymentInfo" label="卖家收款信息">
<Input />
</Form.Item>
<Form.Item name="reviewNote" label="审核备注">
<Input.TextArea rows={2} placeholder="审核核准说明" />
</Form.Item>
</Form>
</Modal>
<Modal
title="驳回寄卖提报"
open={Boolean(rejectModalItem)}
onCancel={() => setRejectModalItem(null)}
onOk={handleRejectConfirm}
confirmLoading={rejecting}
okText="确认驳回"
okType="danger"
>
<div style={{ marginBottom: 12 }}>
<Text>
确认驳回 <strong>[{rejectModalItem?.submissionNo}]</strong> 的寄卖申请吗?
</Text>
</div>
<Form layout="vertical">
<Form.Item label="驳回原因说明">
<Input.TextArea
rows={3}
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="如:要价过高、物品规格不符、账号存在实名绑定限制等"
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={`提报详情 [${viewingSubmission?.submissionNo}]`}
open={Boolean(viewingSubmission)}
width={680}
footer={[
<Button key="close" onClick={() => setViewingSubmission(null)}>
关闭
</Button>,
]}
onCancel={() => setViewingSubmission(null)}
>
{viewingSubmission ? (
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="提报单号" span={2}>
<Text copyable>{viewingSubmission.submissionNo}</Text>
</Descriptions.Item>
<Descriptions.Item label="来源" span={2}>
{resolveSubmissionSource(viewingSubmission)}
</Descriptions.Item>
<Descriptions.Item label="物品名称" span={2}>
{viewingSubmission.itemName}
</Descriptions.Item>
<Descriptions.Item label="规格">
{viewingSubmission.system} / {viewingSubmission.serverZone} /{' '}
{viewingSubmission.series} / {viewingSubmission.diamondLevel}钻 (
{viewingSubmission.brillianceCount}璀璨)
</Descriptions.Item>
<Descriptions.Item label="卖家报价">
<Text strong style={{ color: '#cf1322' }}>
¥{(viewingSubmission.askingPriceFen / 100).toFixed(2)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家微信号">
<Text copyable>{viewingSubmission.contactInfo}</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家电话">
<Text copyable>{viewingSubmission.sellerPhone || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="卖家游戏ID" span={2}>
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="关联接单打手">
{viewingSubmission.workerId ? (
<Tag color="blue">
{viewingSubmission.workerName || `ID:${viewingSubmission.workerId}`} (
{viewingSubmission.workerPhone || '无手机号'})
</Tag>
) : (
<Text type="secondary">未关联打手账号</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求">
{viewingSubmission.depositAmountFen ? (
<Tag color="gold">¥{(viewingSubmission.depositAmountFen / 100).toFixed(0)}</Tag>
) : (
<Text type="secondary">按平台默认规则</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="卖家收款方式" span={2}>
<Text copyable>{viewingSubmission.sellerPaymentInfo || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="凭据截图" span={2}>
{Array.isArray(viewingSubmission.screenshotsJson) &&
viewingSubmission.screenshotsJson.length > 0 ? (
<Image.PreviewGroup>
<Space wrap>
{viewingSubmission.screenshotsJson.map((src, i) => (
<Image
key={i}
src={src}
width={80}
height={80}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Text type="secondary">无截图</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="审核状态">
{viewingSubmission.status === 'pending_review' ? (
<Tag color="gold">待审核</Tag>
) : viewingSubmission.status === 'approved' ? (
<Tag color="green">已入库</Tag>
) : (
<Tag color="red">已驳回</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="审核人">
{viewingSubmission.reviewerName || '-'}
</Descriptions.Item>
<Descriptions.Item label="审核备注" span={2}>
{viewingSubmission.reviewNote || '-'}
</Descriptions.Item>
</Descriptions>
) : null}
</Modal>
</div>
)
}