feat(consignment): 新增和平精英寄卖库存管理、定向收集审核与工单智能匹配履约

This commit is contained in:
yml2213
2026-09-14 11:39:15 +08:00
parent 38c0cf1223
commit 1aa6dba931
30 changed files with 6350 additions and 44 deletions
+2
View File
@@ -38,6 +38,7 @@ const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
const ConsignmentCollectPage = lazy(() => import('@/pages/collect/ConsignmentCollectPage'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage'))
@@ -133,6 +134,7 @@ function AdminApplication() {
</Route>
<Route path="/claim/:token" element={<ClaimPage />} />
<Route path="/collect" element={<CollectPage />} />
<Route path="/consignment-collect/:token" element={<ConsignmentCollectPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
+1 -6
View File
@@ -581,12 +581,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
type NotificationAlertKind =
| 'withdraw'
| 'recharge'
| 'acceptance'
| 'material'
| 'reminder'
| 'default'
'withdraw' | 'recharge' | 'acceptance' | 'material' | 'reminder' | 'default'
function playNotificationSound(kind: NotificationAlertKind) {
try {
+8
View File
@@ -178,6 +178,14 @@ export function apiPut<T>(url: string, data?: unknown) {
})
}
export function apiPatch<T>(url: string, data?: unknown) {
return request<T>({
method: 'PATCH',
url,
data: data as Record<string, unknown>,
})
}
export function apiPostForm<T>(url: string, data: FormData) {
return request<T>({
method: 'POST',
@@ -11,6 +11,8 @@ import AfterSalesPanel, { type AfterSalesPanelHandle } from './panels/AfterSales
import AnnouncementPanel from './panels/AnnouncementPanel'
import CancelRequestsPanel from './panels/CancelRequestsPanel'
import CategoriesPanel from './panels/CategoriesPanel'
import ConsignmentInventoryPanel from './panels/ConsignmentInventoryPanel'
import ConsignmentSubmissionsPanel from './panels/ConsignmentSubmissionsPanel'
import FeedbacksPanel from './panels/FeedbacksPanel'
import FinancePanel from './panels/FinancePanel'
import LeaderboardPanel from './panels/LeaderboardPanel'
@@ -94,6 +96,16 @@ export default function AdminWorkerPlatformPage() {
: isSupport
? [
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
{
key: 'consignment-inventory',
label: '寄售仓库',
children: <ConsignmentInventoryPanel />,
},
{
key: 'consignment-submissions',
label: '寄卖收集与审核',
children: <ConsignmentSubmissionsPanel />,
},
{
key: 'cancel-requests',
label: '打手退单审核',
@@ -104,6 +116,16 @@ export default function AdminWorkerPlatformPage() {
]
: [
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
{
key: 'consignment-inventory',
label: '寄售仓库',
children: <ConsignmentInventoryPanel />,
},
{
key: 'consignment-submissions',
label: '寄卖收集与审核',
children: <ConsignmentSubmissionsPanel />,
},
{
key: 'cancel-requests',
label: '打手退单审核',
@@ -142,6 +164,8 @@ const ACTIVE_TAB_STORAGE_KEY = 'admin-worker-platform-tab'
function loadActiveTabPreference(): string {
const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
return saved === 'orders' ||
saved === 'consignment-inventory' ||
saved === 'consignment-submissions' ||
saved === 'leaderboard' ||
saved === 'cancel-requests' ||
saved === 'feedbacks' ||
@@ -165,6 +189,8 @@ function loadActiveTab(tab: string | null): string {
function isWorkerPlatformTab(tab: string | null): tab is string {
return [
'orders',
'consignment-inventory',
'consignment-submissions',
'leaderboard',
'cancel-requests',
'feedbacks',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,947 @@
import {
CheckCircleOutlined,
CloseCircleOutlined,
CopyOutlined,
EyeOutlined,
LinkOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
App,
Badge,
Button,
Card,
Col,
Descriptions,
Form,
Image,
Input,
InputNumber,
Modal,
Radio,
Row,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import {
approveAdminConsignmentSubmission,
createAdminConsignmentLink,
fetchAdminConsignmentLinks,
fetchAdminConsignmentSubmissions,
rejectAdminConsignmentSubmission,
updateAdminConsignmentLink,
} from '@/services/admin/consignment'
import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment'
const { Text } = Typography
export default function ConsignmentSubmissionsPanel() {
const { message } = App.useApp()
const [activeSubTab, setActiveSubTab] = useState<'submissions' | 'links'>('submissions')
// 提报审核状态
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 [createLinkModalOpen, setCreateLinkModalOpen] = useState(false)
const [createdLinkUrl, setCreatedLinkUrl] = useState<string>('')
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 [linkForm] = Form.useForm()
const [approveForm] = Form.useForm()
// 查询提报列表
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 linksQuery = useQuery({
queryKey: ['admin-consignment-links'],
queryFn: () => fetchAdminConsignmentLinks(),
})
const submissions = submissionsQuery.data?.data.items || []
const submissionTotal = submissionsQuery.data?.data.total || 0
const links = Array.isArray(linksQuery.data?.data)
? linksQuery.data?.data
: linksQuery.data?.data?.items || []
// 复制收集公开链接
function copyPublicLink(token: string) {
const origin = window.location.origin
const url = `${origin}/#/consignment-collect/${token}`
navigator.clipboard.writeText(url)
message.success('收集链接已复制到剪贴板,可直接发给客户!')
}
// 切换链接启用/关闭
async function handleToggleLink(record: ConsignmentCollectLink, enabled: boolean) {
try {
await updateAdminConsignmentLink(record.id, { enabled })
message.success(enabled ? '链接已开启' : '链接已关闭')
linksQuery.refetch()
} catch (err: any) {
message.error(err.message || '更新失败')
}
}
// 创建收集链接
async function handleCreateLinkSubmit() {
try {
const values = await linkForm.validateFields()
const presetPriceFen = Math.round(Number(values.presetPrice || 0) * 100)
const briStr = values.brillianceCount > 0 ? `${values.brillianceCount}璀璨` : ''
const defaultTitle = `${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 寄卖征集`
const res = await createAdminConsignmentLink({
title: values.title || defaultTitle,
system: values.system,
serverZone: values.serverZone,
series: values.series,
diamondLevel: Number(values.diamondLevel || 1),
brillianceCount: Number(values.brillianceCount || 0),
presetPriceFen,
buyerName: values.buyerName,
inboundBatch: values.inboundBatch,
isMultiUse: values.isMultiUse !== false,
})
const token = res.data?.token || ''
const url = `${window.location.origin}/#/consignment-collect/${token}`
setCreatedLinkUrl(url)
try {
navigator.clipboard.writeText(url)
} catch {}
message.success('收集链接生成成功!')
setCreateLinkModalOpen(false)
linkForm.resetFields()
await linksQuery.refetch()
} catch (err: any) {
if (err.errorFields) return
message.error(err.message || '创建收集链接失败')
}
}
// 审核通过并入库
async function handleApproveSubmit() {
if (!approveModalItem) return
try {
const values = await approveForm.validateFields()
const costPriceFen = Math.round(Number(values.costPrice || 0) * 100)
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,
reviewNote: values.reviewNote,
})
message.success('审核通过!已正式存入寄售库存待匹配!')
setApproveModalItem(null)
submissionsQuery.refetch()
} catch (err: any) {
if (err.errorFields) return
message.error(err.message || '审核入库失败')
}
}
// 驳回提报
async function handleRejectConfirm() {
if (!rejectModalItem) return
try {
await rejectAdminConsignmentSubmission(rejectModalItem.id, { reviewNote: rejectNote })
message.success('已驳回该提报申请')
setRejectModalItem(null)
setRejectNote('')
submissionsQuery.refetch()
} catch (err: any) {
message.error(err.message || '驳回失败')
}
}
// 提报列表列定义
const submissionColumns: TableColumnsType<ConsignmentSubmission> = [
{
title: '提报单号',
key: 'submissionNo',
width: 170,
render: (_, record) => (
<div>
<Text strong>{record.submissionNo}</Text>
{record.linkTitle ? (
<div style={{ fontSize: 11, color: '#8c8c8c' }}>来自: {record.linkTitle}</div>
) : null}
</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: 180,
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}
</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: 160,
render: (_, record) => (
<Space size={8}>
<Button
size="small"
type="text"
icon={<EyeOutlined />}
onClick={() => setViewingSubmission(record)}
>
详情
</Button>
{record.status === 'pending_review' ? (
<>
<Button
size="small"
type="primary"
onClick={() => {
setApproveModalItem(record)
approveForm.setFieldsValue({
...record,
costPrice: record.askingPriceFen / 100,
sellerContact: record.contactInfo,
buyerName: '客服',
inboundBatch: new Date().toISOString().slice(0, 7),
})
}}
>
审核入库
</Button>
<Button
size="small"
danger
type="text"
onClick={() => {
setRejectModalItem(record)
setRejectNote('')
}}
>
驳回
</Button>
</>
) : null}
</Space>
),
},
]
// 链接列表列定义
const linkColumns: TableColumnsType<ConsignmentCollectLink> = [
{
title: '收集标题 / 规格',
key: 'title',
width: 280,
render: (_, record) => {
const fullUrl = `${window.location.origin}/#/consignment-collect/${record.token}`
return (
<div>
<div style={{ fontWeight: 600 }}>{record.title}</div>
<Space size={4} wrap style={{ marginTop: 4 }}>
<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 || 1} 钻</Tag>
{record.brillianceCount > 0 ? (
<Tag color="magenta">{record.brillianceCount} 璀璨</Tag>
) : null}
</Space>
<div style={{ marginTop: 6 }}>
<Text
copyable={{ text: fullUrl }}
style={{ fontSize: 11, color: '#1890ff', wordBreak: 'break-all' }}
>
{fullUrl}
</Text>
</div>
</div>
)
},
},
{
title: '指导参考价',
key: 'price',
width: 110,
render: (_, record) => (
<Text strong style={{ color: '#cf1322' }}>
¥{(record.presetPriceFen / 100).toFixed(2)}
</Text>
),
},
{
title: '创建人/批次',
key: 'creator',
width: 140,
render: (_, record) => (
<div>
<div>{record.createdBy || '-'}</div>
{record.inboundBatch ? (
<div style={{ fontSize: 11, color: '#8c8c8c' }}>批次: {record.inboundBatch}</div>
) : null}
</div>
),
},
{
title: '提报数量',
key: 'count',
width: 100,
render: (_, record) => (
<Badge
count={record.submissionCount || 0}
showZero
style={{ backgroundColor: (record.submissionCount || 0) > 0 ? '#52c41a' : '#d9d9d9' }}
/>
),
},
{
title: '状态开关',
key: 'enabled',
width: 100,
render: (_, record) => (
<Switch
checked={record.enabled}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => handleToggleLink(record, checked)}
/>
),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_, record) => (
<Space size={8}>
<Button
size="small"
type="primary"
ghost
icon={<CopyOutlined />}
onClick={() => copyPublicLink(record.token)}
>
复制公开链接
</Button>
</Space>
),
},
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Tabs
activeKey={activeSubTab}
onChange={(k) => setActiveSubTab(k as any)}
items={[
{
key: 'submissions',
label: (
<span>
玩家提报审核{' '}
{submissions.filter((s) => s.status === 'pending_review').length > 0 ? (
<Badge
count={submissions.filter((s) => s.status === 'pending_review').length}
offset={[6, -2]}
/>
) : null}
</span>
),
children: (
<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">待客服审核</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: 240 }}
onSearch={(v) => {
setSubmissionKeyword(v)
setSubmissionPage(1)
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => submissionsQuery.refetch()}>
刷新
</Button>
</Space>
</Card>
{/* 提报审核表格 */}
<Card size="small">
<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>
</div>
),
},
{
key: 'links',
label: '收集链接生成与管理',
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card size="small">
<Space wrap size={12}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
linkForm.resetFields()
linkForm.setFieldsValue({
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: 1,
brillianceCount: 0,
isMultiUse: true,
buyerName: '客服',
})
setCreateLinkModalOpen(true)
}}
>
生成定向收集链接
</Button>
<Button icon={<ReloadOutlined />} onClick={() => linksQuery.refetch()}>
刷新
</Button>
</Space>
</Card>
<Card size="small">
<Table
rowKey="id"
columns={linkColumns}
dataSource={links}
loading={linksQuery.isLoading}
pagination={false}
/>
</Card>
</div>
),
},
]}
/>
{/* 生成收集链接弹窗 */}
<Modal
title="生成寄卖线索收集链接"
open={createLinkModalOpen}
width={600}
onCancel={() => setCreateLinkModalOpen(false)}
onOk={handleCreateLinkSubmit}
destroyOnClose
>
<Form form={linkForm} layout="vertical">
<Row gutter={16}>
<Col span={8}>
<Form.Item name="system" label="指定系统" rules={[{ required: true }]}>
<Select
options={[
{ label: '苹果 (iOS)', value: '苹果' },
{ label: '安卓 (Android)', value: '安卓' },
{ label: '通用', value: '通用' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="serverZone" label="指定区服" rules={[{ required: true }]}>
<Select
options={[
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="series" label="枪系/系列" rules={[{ required: true }]}>
<Select
options={[
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
{ label: '矩阵', value: '矩阵' },
{ label: '其他', value: '其他' },
]}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="diamondLevel" label="钻石等级" rules={[{ required: true }]}>
<Select
options={[
{ label: '1 钻', value: 1 },
{ label: '2 钻', value: 2 },
{ label: '3 钻', value: 3 },
{ label: '4 钻', value: 4 },
{ label: '5 钻', value: 5 },
{ label: '6 钻', value: 6 },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="brillianceCount" label="璀璨数">
<InputNumber min={0} max={6} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="presetPrice" label="指导收购价 (元)">
<InputNumber min={0} style={{ width: '100%' }} placeholder="如 80" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="title"
label="链接标题 (留空则根据规格自动生成)"
tooltip="玩家打开链接时看到的标题"
>
<Input placeholder="如:【高价收】苹果微信暗潮1钻 极速审核" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="buyerName" label="默认收购人">
<Input placeholder="客服姓名" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="inboundBatch" label="归属批次">
<Input placeholder="如 202609-01" />
</Form.Item>
</Col>
</Row>
<Form.Item name="isMultiUse" label="允许多次提报" valuePropName="checked">
<Switch defaultChecked checkedChildren="多人可填" unCheckedChildren="单次失效" />
</Form.Item>
</Form>
</Modal>
{/* 审核入库弹窗 */}
<Modal
title="审核寄卖申请并正式入库"
open={Boolean(approveModalItem)}
width={680}
onCancel={() => setApproveModalItem(null)}
onOk={handleApproveSubmit}
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={6}>
<Form.Item name="system" label="系统">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="serverZone" label="区服">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="series" label="系列">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="diamondLevel" label="钻石等级">
<InputNumber min={1} max={6} style={{ width: '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}
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}>
{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="卖家收款方式" 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>
{/* 链接生成成功提示弹窗 */}
<Modal
title="收集链接生成成功"
open={Boolean(createdLinkUrl)}
onCancel={() => setCreatedLinkUrl('')}
footer={[
<Button
key="copy"
type="primary"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard?.writeText(createdLinkUrl)
message.success('链接已复制到剪贴板!')
}}
>
复制公开链接
</Button>,
<Button key="close" onClick={() => setCreatedLinkUrl('')}>
完成
</Button>,
]}
>
<Alert
type="success"
showIcon
message="定向收集链接已就绪"
description="该链接已自动复制到剪贴板。您可以直接发送给玩家卖家,玩家免登录打开即可登记账号与装备信息并上传凭据截图。"
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 8 }}>
<Text strong>公开收集链接:</Text>
</div>
<div
style={{
padding: '12px 16px',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
wordBreak: 'break-all',
}}
>
<Text
copyable={{ text: createdLinkUrl }}
style={{ fontSize: 13, color: '#389e0d', fontWeight: 500 }}
>
{createdLinkUrl}
</Text>
</div>
</Modal>
</div>
)
}
@@ -11,6 +11,7 @@ import {
UndoOutlined,
UserAddOutlined,
WarningOutlined,
RollbackOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
@@ -72,6 +73,12 @@ import {
updateAdminWorkOrderNote,
updateAdminWorkOrderSharing,
} from '@/services/admin'
import {
checkWorkOrderConsignmentMatches,
fulfillWorkOrderWithConsignment,
revokeWorkOrderConsignmentFulfillment,
} from '@/services/admin/consignment'
import type { 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'
@@ -259,6 +266,35 @@ export default function WorkOrdersPanel() {
unitReward?: number
totalAmount?: number
}>()
// 寄售库存匹配拦截弹窗状态
const [consignmentInterceptModal, setConsignmentInterceptModal] = useState<{
open: boolean
order: WorkOrder | null
matchData: WorkOrderInventoryMatchCheck | null
submitting: boolean
}>({
open: false,
order: null,
matchData: null,
submitting: false,
})
// 寄售履约撤单弹窗状态
const [revokeConsignmentModal, setRevokeConsignmentModal] = useState<{
open: boolean
order: WorkOrder | null
action: 'reopen' | 'cancel_order' | 'archive_inventory'
reason: string
submitting: boolean
}>({
open: false,
order: null,
action: 'reopen',
reason: '',
submitting: false,
})
// 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作)
const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance'
const selectedWorkOrderStatuses = statuses.filter(
@@ -652,6 +688,75 @@ export default function WorkOrdersPanel() {
})
}
async function handlePublishClick(row: WorkOrder) {
try {
const matchRes = await checkWorkOrderConsignmentMatches(row.workOrderId)
if (matchRes.data?.hasMatch && (matchRes.data.matches?.length || 0) > 0) {
setConsignmentInterceptModal({
open: true,
order: row,
matchData: matchRes.data,
submitting: false,
})
return
}
} catch {
// 检查匹配异常降级走正常发布
}
confirmPublishWorkOrder(row)
}
async function handleFulfillWithConsignment(inventoryId: number) {
if (!consignmentInterceptModal.order) return
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true }))
try {
await fulfillWorkOrderWithConsignment({
workOrderId: consignmentInterceptModal.order.workOrderId,
inventoryId,
})
message.success('已使用内部寄售库存直接履约!工单已自动完成,无需打手接单佣金')
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
await refreshAll()
} catch (err: any) {
message.error(err.message || '内部履约失败')
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
}
}
function openRevokeConsignmentModal(row: WorkOrder) {
setRevokeConsignmentModal({
open: true,
order: row,
action: 'reopen',
reason: '',
submitting: false,
})
}
async function handleRevokeConsignmentSubmit() {
if (!revokeConsignmentModal.order) return
setRevokeConsignmentModal((prev) => ({ ...prev, submitting: true }))
try {
await revokeWorkOrderConsignmentFulfillment({
workOrderId: revokeConsignmentModal.order.workOrderId,
action: revokeConsignmentModal.action,
reason: revokeConsignmentModal.reason,
})
message.success('寄售履约已撤销!物品已退单回库,工单已更新')
setRevokeConsignmentModal({
open: false,
order: null,
action: 'reopen',
reason: '',
submitting: false,
})
await refreshAll()
} catch (err: any) {
message.error(err.message || '撤销履约失败')
setRevokeConsignmentModal((prev) => ({ ...prev, submitting: false }))
}
}
function confirmBatchAcceptance() {
const workOrderIds = selectedAcceptanceOrderIds
if (workOrderIds.length === 0) {
@@ -1018,6 +1123,12 @@ export default function WorkOrdersPanel() {
{shopName ? `店铺:${shopName}` : ''}
</Typography.Text>
) : null}
{(row.material as any)?.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)})
</Tag>
) : null}
</div>
)
},
@@ -1249,7 +1360,7 @@ export default function WorkOrdersPanel() {
title={
Number(row.rewardAmount || 0) <= 0 ? '接单金额为 0,不能发布到大厅' : undefined
}
onClick={() => confirmPublishWorkOrder(row)}
onClick={() => handlePublishClick(row)}
>
发布并核销
</Button>
@@ -1301,6 +1412,15 @@ export default function WorkOrdersPanel() {
扣押金
</Button>
) : null}
{(row.material as any)?.consignment ? (
<Button
icon={<RollbackOutlined />}
danger
onClick={() => openRevokeConsignmentModal(row)}
>
撤单/撤销寄售
</Button>
) : null}
{row.status === 'accepted' ? (
<Button
icon={<WarningOutlined />}
@@ -2618,6 +2738,249 @@ export default function WorkOrdersPanel() {
</Form>
) : null}
</Modal>
{/* 内部寄售库存匹配拦截弹窗 */}
<Modal
title={
<Space>
<span>内部寄售库存匹配提醒</span>
<Tag color="purple">和平精英内部仓</Tag>
</Space>
}
open={consignmentInterceptModal.open}
width={760}
onCancel={() => {
setConsignmentInterceptModal({
open: false,
order: null,
matchData: null,
submitting: false,
})
}}
footer={[
<Button
key="ignore-and-publish"
onClick={() => {
const currentOrder = consignmentInterceptModal.order
setConsignmentInterceptModal({
open: false,
order: null,
matchData: null,
submitting: false,
})
if (currentOrder) confirmPublishWorkOrder(currentOrder)
}}
>
忽略内部库存,继续发布到打手抢单大厅
</Button>,
<Button
key="close"
onClick={() => {
setConsignmentInterceptModal({
open: false,
order: null,
matchData: null,
submitting: false,
})
}}
>
取消
</Button>,
]}
>
<Alert
showIcon
type="success"
message="检测到内部寄售仓库中有可直接匹配履约的在库物品!"
description={
<div>
<div>
当前工单原定接单赏金:
<strong>
¥{Number(consignmentInterceptModal.order?.rewardAmount || 0).toFixed(2)}
</strong>
</div>
<div style={{ color: '#389e0d', marginTop: 4 }}>
若使用内部寄售库存履约,<strong>无需向外部打手支付佣金(打手支出 ¥0.00)</strong>
,系统将自动核销工单并归档!
</div>
</div>
}
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 12 }}>
<Space size={8}>
<Typography.Text strong>匹配结果:</Typography.Text>
<Tag color="green">
全部匹配: {consignmentInterceptModal.matchData?.exactCount || 0} 件
</Tag>
<Tag color="gold">
部分匹配(高配充低配): {consignmentInterceptModal.matchData?.partialCount || 0} 件
</Tag>
</Space>
</div>
<div
style={{
maxHeight: 400,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
{(consignmentInterceptModal.matchData?.matches || []).map((m) => {
const inv = m.inventory
const isExact = m.matchType === 'exact'
return (
<Card
key={inv.id}
size="small"
style={{
borderLeft: isExact ? '4px solid #52c41a' : '4px solid #faad14',
backgroundColor: isExact ? '#f6ffed' : '#fffbe6',
}}
>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<div style={{ flex: 1 }}>
<Space size={8} wrap>
<strong style={{ fontSize: 15 }}>{inv.itemName}</strong>
{isExact ? (
<Tag color="green">【全部匹配】</Tag>
) : (
<Tag color="gold">【部分匹配 / 高配充低配】</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{inv.inventoryNo}
</Typography.Text>
</Space>
<div style={{ marginTop: 4 }}>
<Space size={4} wrap>
<Tag color={inv.system === '苹果' ? 'geekblue' : 'green'}>{inv.system}</Tag>
<Tag color="cyan">{inv.serverZone}</Tag>
<Tag color="orange">{inv.series}</Tag>
<Tag color="gold">{inv.diamondLevel} 钻</Tag>
{inv.brillianceCount > 0 ? (
<Tag color="magenta">{inv.brillianceCount} 璀璨</Tag>
) : null}
</Space>
</div>
<div style={{ marginTop: 6, fontSize: 12, color: '#595959' }}>
<Space size={16} wrap>
<span>
内部收购成本:{' '}
<strong style={{ color: '#cf1322' }}>
¥{(inv.costPriceFen / 100).toFixed(2)}
</strong>
</span>
<span>收购人: {inv.buyerName || '-'}</span>
{inv.shopName ? <span>店铺: {inv.shopName}</span> : null}
{inv.inboundBatch ? <span>批次: {inv.inboundBatch}</span> : null}
</Space>
</div>
<div style={{ marginTop: 4, fontSize: 11, color: '#8c8c8c' }}>
匹配分析: {m.matchReasons.join(' · ')}
</div>
</div>
<div style={{ marginLeft: 16 }}>
<Button
type="primary"
loading={consignmentInterceptModal.submitting}
onClick={() => handleFulfillWithConsignment(inv.id)}
>
使用此库存直接履约
</Button>
</div>
</div>
</Card>
)
})}
</div>
</Modal>
{/* 撤单 / 撤销寄售履约弹窗 */}
<Modal
title={`撤单 / 撤销寄售履约 · ${getDisplayOrderNo(revokeConsignmentModal.order)}`}
open={revokeConsignmentModal.open}
onCancel={() => {
setRevokeConsignmentModal({
open: false,
order: null,
action: 'reopen',
reason: '',
submitting: false,
})
}}
onOk={handleRevokeConsignmentSubmit}
confirmLoading={revokeConsignmentModal.submitting}
okText="确认撤单"
okType="danger"
destroyOnClose
>
<Alert
showIcon
type="warning"
message="撤销后,关联寄售物品将退单回库并重新进入待匹配池"
description="适用场景:买家退款、客服误选库存、账号密码错误失效或规格不符等情况。"
style={{ marginBottom: 16 }}
/>
<Form layout="vertical">
<Form.Item label="处理流转方式" required>
<Radio.Group
value={revokeConsignmentModal.action}
onChange={(e) =>
setRevokeConsignmentModal((prev) => ({ ...prev, action: e.target.value }))
}
>
<Space direction="vertical">
<Radio value="reopen">
<div>
<strong>退单回库并恢复工单为未指派 (推荐)</strong>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
物品重新变为【在库】待匹配,工单恢复为【未指派】,可重新选择其他库存或发布到打手大厅。
</div>
</div>
</Radio>
<Radio value="cancel_order">
<div>
<strong>退单回库并取消整单</strong>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
买家退款时使用。物品退回复库,工单状态变更为【已取消】。
</div>
</div>
</Radio>
<Radio value="archive_inventory">
<div>
<strong>报废该物品并恢复工单</strong>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
如果该寄售物品无法登录/被找回已彻底失效,将该物品标记为【作废】,工单恢复为【未指派】重选。
</div>
</div>
</Radio>
</Space>
</Radio.Group>
</Form.Item>
<Form.Item label="撤单原因说明" required>
<Input.TextArea
rows={3}
value={revokeConsignmentModal.reason}
onChange={(e) =>
setRevokeConsignmentModal((prev) => ({ ...prev, reason: e.target.value }))
}
placeholder="请输入撤单详细原因,如:买家申请退款、选错账号规格、账号密码错误等..."
/>
</Form.Item>
</Form>
</Modal>
</section>
)
}
@@ -0,0 +1,453 @@
import {
CheckCircleOutlined,
CloudUploadOutlined,
LockOutlined,
TrophyOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Col,
Descriptions,
Form,
Image,
Input,
InputNumber,
Radio,
Result,
Row,
Select,
Space,
Spin,
Tag,
Typography,
Upload,
} from 'antd'
import type { UploadFile } from 'antd'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router'
import {
fetchPublicConsignmentLink,
submitPublicConsignment,
uploadConsignmentImage,
} from '@/services/consignment-public'
import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment'
const { Title, Paragraph, Text } = Typography
export default function ConsignmentCollectPage() {
const { message } = App.useApp()
const { token } = useParams<{ token: string }>()
const [loading, setLoading] = useState(true)
const [errorMsg, setErrorMsg] = useState('')
const [linkInfo, setLinkInfo] = useState<ConsignmentCollectLink | null>(null)
const [submittedResult, setSubmittedResult] = useState<ConsignmentSubmission | null>(null)
const [fileList, setFileList] = useState<UploadFile[]>([])
const [submitting, setSubmitting] = useState(false)
const [form] = Form.useForm()
useEffect(() => {
if (!token) {
setErrorMsg('缺少收集凭证')
setLoading(false)
return
}
fetchPublicConsignmentLink(token)
.then((res) => {
const data = res.data
setLinkInfo(data)
const briStr = data.brillianceCount > 0 ? `${data.brillianceCount}璀璨` : ''
form.setFieldsValue({
itemName:
data.title ||
`${data.system}${data.serverZone}${data.series}${data.diamondLevel}钻${briStr}`,
system: data.system || '苹果',
serverZone: data.serverZone || '微信区',
series: data.series || '暗潮',
diamondLevel: data.diamondLevel || 1,
brillianceCount: data.brillianceCount || 0,
askingPrice: data.presetPriceFen > 0 ? data.presetPriceFen / 100 : undefined,
loginMethod: 'wechat_qrcode',
})
})
.catch((err: any) => {
setErrorMsg(err.message || '获取收集信息失败或链接已失效')
})
.finally(() => {
setLoading(false)
})
}, [token, form])
async function handleSubmit(values: any) {
if (!token) return
setSubmitting(true)
try {
const askingPriceFen = Math.round(Number(values.askingPrice || 0) * 100)
const screenshots = fileList
.filter((f) => f.status === 'done' && (f.response?.url || f.url))
.map((f) => f.response?.url || f.url)
const payload = {
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,
contactInfo: values.sellerContact,
sellerPhone: values.sellerPhone,
sellerGameId: values.sellerGameId,
sellerPaymentInfo: values.sellerPaymentInfo,
askingPriceFen,
screenshotsJson: screenshots,
contentJson: {
loginMethod: values.loginMethod,
account: values.account,
password: values.password,
authNote: values.authNote,
sellerNote: values.sellerNote,
},
}
const res = await submitPublicConsignment(token, payload)
setSubmittedResult(res.data)
message.success('寄卖申请提交成功!')
} catch (err: any) {
message.error(err.message || '提交失败,请重试')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div style={{ padding: '60px 20px', textAlign: 'center' }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: '#8c8c8c' }}>正在加载寄卖登记信息...</div>
</div>
)
}
if (errorMsg) {
return (
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 16px' }}>
<Result
status="warning"
title="链接无法访问"
subTitle={errorMsg}
extra={<Text type="secondary">如有疑问,请联系客服重新获取有效链接</Text>}
/>
</div>
)
}
if (submittedResult) {
return (
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 16px' }}>
<Card
bordered={false}
style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.05)', borderRadius: 12 }}
>
<Result
status="success"
title="寄卖登记提交成功!"
subTitle={
<div>
<div>
您的登记单号:
<Text strong copyable>
{submittedResult.submissionNo}
</Text>
</div>
<div style={{ marginTop: 8 }}>
客服将在核实账号及物品品质后,通过您填写的联系方式与您确认交接并打款。
</div>
</div>
}
extra={[
<Button key="back" type="primary" onClick={() => window.location.reload()}>
返回重新填写
</Button>,
]}
/>
</Card>
</div>
)
}
return (
<div
style={{
minHeight: '100vh',
background: '#f5f7fa',
padding: '24px 16px 60px',
}}
>
<div style={{ maxWidth: 680, margin: '0 auto' }}>
{/* 顶部标题区 */}
<Card
bordered={false}
style={{
borderRadius: 12,
marginBottom: 16,
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
color: '#fff',
}}
>
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TrophyOutlined style={{ fontSize: 24, color: '#ffd666' }} />
<Title level={4} style={{ color: '#fff', margin: 0 }}>
{linkInfo?.title || '和平精英 装备/账号 寄卖登记'}
</Title>
</div>
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', margin: 0, fontSize: 13 }}>
平台保障 · 极速审核 · 验货即打款 · 请如实填写以下信息
</Paragraph>
{linkInfo ? (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{linkInfo.system ? <Tag color="blue">{linkInfo.system}</Tag> : null}
{linkInfo.serverZone ? <Tag color="cyan">{linkInfo.serverZone}</Tag> : null}
{linkInfo.series ? <Tag color="orange">{linkInfo.series}</Tag> : null}
{linkInfo.diamondLevel ? <Tag color="gold">{linkInfo.diamondLevel} 钻</Tag> : null}
{linkInfo.brillianceCount > 0 ? (
<Tag color="magenta">{linkInfo.brillianceCount} 璀璨</Tag>
) : null}
{linkInfo.presetPriceFen > 0 ? (
<Tag color="volcano" style={{ fontWeight: 600 }}>
指导收购价 ¥{(linkInfo.presetPriceFen / 100).toFixed(0)}
</Tag>
) : null}
</div>
) : null}
</Space>
</Card>
{/* 表单填写区 */}
<Card
bordered={false}
style={{
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
}}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Title level={5} style={{ marginBottom: 16 }}>
1. 物品与规格确认
</Title>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="system" label="操作系统" rules={[{ required: true }]}>
<Select
options={[
{ label: '苹果 (iOS)', value: '苹果' },
{ label: '安卓 (Android)', value: '安卓' },
{ label: '通用/双端', value: '通用' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="serverZone" label="游戏区服" rules={[{ required: true }]}>
<Select
options={[
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
]}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={8}>
<Form.Item name="series" label="系列/枪系" rules={[{ required: true }]}>
<Select
options={[
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
{ label: '矩阵', value: '矩阵' },
{ label: '其他', value: '其他' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="diamondLevel" label="钻石等级" rules={[{ required: true }]}>
<Select
options={[
{ label: '1 钻', value: 1 },
{ label: '2 钻', value: 2 },
{ label: '3 钻', value: 3 },
{ label: '4 钻', value: 4 },
{ label: '5 钻', value: 5 },
{ label: '6 钻', value: 6 },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="brillianceCount" label="璀璨数">
<InputNumber min={0} max={6} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="itemName"
label="物品/账号名称"
rules={[{ required: true, message: '请输入物品名称' }]}
>
<Input placeholder="如:苹果微信暗潮1钻" />
</Form.Item>
<Form.Item
name="askingPrice"
label="期望转让/收购价格 (元)"
rules={[{ required: true, message: '请填写出价' }]}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder="请输入您的心理价位 (元)"
/>
</Form.Item>
<Title level={5} style={{ margin: '24px 0 16px' }}>
2. 卖家联系与收款方式
</Title>
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="sellerContact"
label="您的微信号/QQ号"
rules={[{ required: true, message: '请填写联系微信号或QQ' }]}
>
<Input placeholder="方便客服核实联系" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="sellerPhone" label="联系电话">
<Input placeholder="手机号 (选填)" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="sellerGameId"
label="游戏内角色名 / 角色数字ID"
rules={[{ required: true, message: '请填写游戏内角色名称或ID' }]}
>
<Input placeholder="如:和平精英-玩家张三 (用于核实号主)" />
</Form.Item>
<Form.Item
name="sellerPaymentInfo"
label="收款账户 (支付宝账号及姓名 或 微信收款卡号)"
rules={[{ required: true, message: '请填写收款信息' }]}
>
<Input placeholder="如:支付宝 13800000000 (张三) / 工商银行 6222... (张三)" />
</Form.Item>
<Title level={5} style={{ margin: '24px 0 16px' }}>
3. 凭据截图与账号交接 (仅内部审核验号可见)
</Title>
<Alert
showIcon
icon={<LockOutlined />}
type="info"
message="信息安全保障"
description="您填写的账号凭据与截图受平台严格加密保护,仅由官方指定客服在验货核实时可见,绝不泄露给任何无关第三方。"
style={{ marginBottom: 16 }}
/>
<Form.Item label="游戏内物品/品质/仓库截图证明 (最多4张)">
<Upload
listType="picture-card"
fileList={fileList}
maxCount={4}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const res = await uploadConsignmentImage(file as File)
onSuccess?.(res)
} catch (err: any) {
onError?.(err)
message.error(err.message || '上传失败')
}
}}
onChange={({ fileList: nextList }) => setFileList(nextList)}
>
{fileList.length < 4 ? (
<div>
<CloudUploadOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ marginTop: 6, fontSize: 12 }}>上传截图</div>
</div>
) : null}
</Upload>
</Form.Item>
<Form.Item name="loginMethod" label="交付/上号方式">
<Radio.Group>
<Radio value="wechat_qrcode">微信扫码登录</Radio>
<Radio value="qq_qrcode">QQ扫码登录</Radio>
<Radio value="account_pwd">账号密码交付</Radio>
<Radio value="in_game_transfer">游戏内转赠/投喂</Radio>
</Radio.Group>
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="account" label="游戏账号/上号手机号">
<Input placeholder="如账号密码交付请填写" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="password" label="上号密码">
<Input.Password placeholder="如需密码登录请填写" />
</Form.Item>
</Col>
</Row>
<Form.Item name="authNote" label="上号与二次验证说明">
<Input placeholder="如:需发微信确认登录、备用安全码、在线时间段等" />
</Form.Item>
<Form.Item name="sellerNote" label="其他留言与补充">
<Input.TextArea rows={2} placeholder="如有其他装备搭配或说明可在此补充" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={submitting}
style={{
marginTop: 16,
height: 48,
fontSize: 16,
borderRadius: 8,
}}
>
提交寄卖申请并登记
</Button>
</Form>
</Card>
</div>
</div>
)
}
@@ -41,11 +41,7 @@ type AfterSalesResponseFormValues = {
}
type StatusFilter =
| 'all'
| 'awaiting_worker_response'
| 'under_review'
| 'recovery_pending'
| 'closed'
'all' | 'awaiting_worker_response' | 'under_review' | 'recovery_pending' | 'closed'
/** 待办优先:待说明 > 处理中 > 追缴中 > 已关闭,同状态按发起时间倒序。 */
const STATUS_WEIGHT: Record<string, number> = {
@@ -306,17 +306,15 @@ export function getUniqueAcceptanceFiles(source?: WorkerAcceptance | null): Uplo
if (!source) return []
const files = [
...(Array.isArray(source.files) ? source.files : []),
...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map(
(url): UploadedFile => ({
objectKey: '',
url: String(url || ''),
thumbnailUrl: String(url || ''),
mediumUrl: String(url || ''),
filename: '图片',
contentType: '',
size: 0,
}),
),
...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map((url): UploadedFile => ({
objectKey: '',
url: String(url || ''),
thumbnailUrl: String(url || ''),
mediumUrl: String(url || ''),
filename: '图片',
contentType: '',
size: 0,
})),
]
const identities = new Set<string>()
return files.filter((file) => {
@@ -0,0 +1,146 @@
import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/http'
import type {
ConsignmentCollectLink,
ConsignmentInventory,
ConsignmentStatsSummary,
ConsignmentSubmission,
WorkOrderInventoryMatchCheck,
} from '@/types/consignment'
// --- 库存管理 ---
export function fetchAdminConsignmentInventories(params?: Record<string, unknown>) {
return apiGet<{ items: ConsignmentInventory[]; total: number }>(
'/api/v1/admin/worker-platform/consignment/inventories',
params,
)
}
export function fetchAdminConsignmentStats() {
return apiGet<ConsignmentStatsSummary>('/api/v1/admin/worker-platform/consignment/stats')
}
export function getAdminConsignmentInventory(id: number | string) {
return apiGet<ConsignmentInventory>(`/api/v1/admin/worker-platform/consignment/inventories/${id}`)
}
export function createAdminConsignmentInventory(payload: Record<string, unknown>) {
return apiPost<ConsignmentInventory>(
'/api/v1/admin/worker-platform/consignment/inventories',
payload,
)
}
export function updateAdminConsignmentInventory(
id: number | string,
payload: Record<string, unknown>,
) {
return apiPut<ConsignmentInventory>(
`/api/v1/admin/worker-platform/consignment/inventories/${id}`,
payload,
)
}
export function toggleAdminConsignmentMatching(id: number | string, allowMatching: boolean) {
return apiPatch<ConsignmentInventory>(
`/api/v1/admin/worker-platform/consignment/inventories/${id}/matching`,
{ allowMatching },
)
}
export function archiveAdminConsignmentInventory(id: number | string) {
return apiPost<ConsignmentInventory>(
`/api/v1/admin/worker-platform/consignment/inventories/${id}/archive`,
{},
)
}
export function returnAdminConsignmentInventory(id: number | string, payload: { reason?: string }) {
return apiPost<ConsignmentInventory>(
`/api/v1/admin/worker-platform/consignment/inventories/${id}/return`,
payload,
)
}
// --- 收集链接 ---
export function fetchAdminConsignmentLinks() {
return apiGet<{ items: ConsignmentCollectLink[] }>(
'/api/v1/admin/worker-platform/consignment/links',
)
}
export function createAdminConsignmentLink(payload: Record<string, unknown>) {
return apiPost<ConsignmentCollectLink>('/api/v1/admin/worker-platform/consignment/links', payload)
}
export function updateAdminConsignmentLink(id: number | string, payload: Record<string, unknown>) {
return apiPut<ConsignmentCollectLink>(
`/api/v1/admin/worker-platform/consignment/links/${id}`,
payload,
)
}
// --- 提报审核 ---
export function fetchAdminConsignmentSubmissions(params?: Record<string, unknown>) {
return apiGet<{ items: ConsignmentSubmission[]; total: number }>(
'/api/v1/admin/worker-platform/consignment/submissions',
params,
)
}
export function getAdminConsignmentSubmission(id: number | string) {
return apiGet<ConsignmentSubmission>(
`/api/v1/admin/worker-platform/consignment/submissions/${id}`,
)
}
export function approveAdminConsignmentSubmission(
id: number | string,
payload: Record<string, unknown>,
) {
return apiPost<{ submission: ConsignmentSubmission; inventory: ConsignmentInventory }>(
`/api/v1/admin/worker-platform/consignment/submissions/${id}/approve`,
payload,
)
}
export function rejectAdminConsignmentSubmission(
id: number | string,
payload: { reviewNote?: string },
) {
return apiPost<ConsignmentSubmission>(
`/api/v1/admin/worker-platform/consignment/submissions/${id}/reject`,
payload,
)
}
// --- 匹配与履约 ---
export function checkWorkOrderConsignmentMatches(workOrderId: number | string) {
return apiGet<WorkOrderInventoryMatchCheck>(
`/api/v1/admin/worker-platform/consignment/matches/${workOrderId}`,
)
}
export function fulfillWorkOrderWithConsignment(payload: {
workOrderId: number | string
inventoryId: number | string
}) {
return apiPost<{ workOrder: unknown; inventory: ConsignmentInventory }>(
'/api/v1/admin/worker-platform/consignment/fulfill',
payload,
)
}
export function revokeWorkOrderConsignmentFulfillment(payload: {
workOrderId: number | string
action?: 'reopen' | 'cancel_order' | 'archive_inventory'
reason?: string
}) {
return apiPost<{ workOrder: unknown; inventory?: ConsignmentInventory | null }>(
'/api/v1/admin/worker-platform/consignment/revoke',
payload,
)
}
@@ -0,0 +1,34 @@
import { apiGet, apiPost } from '@/lib/http'
import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment'
export function fetchPublicConsignmentLink(token: string) {
return apiGet<ConsignmentCollectLink>(`/api/v1/consignment/${encodeURIComponent(token)}`)
}
export function submitPublicConsignment(token: string, payload: Record<string, unknown>) {
return apiPost<ConsignmentSubmission>(
`/api/v1/consignment/${encodeURIComponent(token)}/submit`,
payload,
)
}
export async function uploadConsignmentImage(
file: File,
): Promise<{ url: string; assetId?: string }> {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', 'consignment-material')
const response = await fetch('/api/v1/consignment/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errJson = await response.json().catch(() => ({}))
throw new Error(errJson?.message || '图片上传失败')
}
const result = await response.json()
return result?.data || result
}
@@ -1,9 +1,5 @@
export type AdminKuaishouIndustryAccessTokenStatus =
| 'missing'
| 'unknown'
| 'expired'
| 'expiring'
| 'valid'
'missing' | 'unknown' | 'expired' | 'expiring' | 'valid'
export interface AdminKuaishouIndustryShopConfig {
enabled: boolean
+1 -5
View File
@@ -193,11 +193,7 @@ export interface ClaimDetailData {
tokenStatus: ClaimTokenStatus
claimUrl: string
flowType:
| 'kuaishou_cloud'
| 'kuaishou_ct_assisted'
| 'kuaishou_feifei'
| 'affiliate_dash'
| (string & {})
'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | 'affiliate_dash' | (string & {})
claimIdentity: ClaimIdentityInfo | null
task: ClaimTaskInfo
order: ClaimOrderInfo
+120
View File
@@ -0,0 +1,120 @@
export type ConsignmentInventory = {
id: number
inventoryNo: string
gameName: string
categoryId: number | null
categoryName?: string
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
costPriceFen: number
buyerName: string
shopName: string
inboundBatch: string
sellerContact: string
sellerGameId: string
sellerPaymentInfo: string
sellerPhone: string
credentialsJson: Record<string, unknown>
screenshotsJson: string[]
remark: string
allowMatching: boolean
status: 'in_stock' | 'consumed' | 'archived'
allocationStatus: string
boundWorkOrderId: number | null
boundPlatformOrderId: string
sourceType: 'manual' | 'collection_link' | 'recycle'
sourceSubmissionId: number | null
inboundAt: string
consumedAt: string | null
createdAt: string
updatedAt: string
}
export type ConsignmentCollectLink = {
id: number
token: string
title: string
gameName: string
categoryId: number | null
categoryName?: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
presetPriceFen: number
inboundBatch: string
buyerName: string
templateJson: Record<string, unknown>
isMultiUse: boolean
expiresAt: string | null
enabled: boolean
createdBy: string
createdAt: string
updatedAt: string
submissionCount?: number
}
export type ConsignmentSubmission = {
id: number
submissionNo: string
linkId: number | null
linkTitle?: string
gameName: string
categoryId: number | null
categoryName?: string
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
sellerGameId: string
sellerPaymentInfo: string
sellerPhone: string
contactInfo: string
contentJson: Record<string, unknown>
screenshotsJson: string[]
askingPriceFen: number
status: 'pending_review' | 'approved' | 'rejected'
reviewNote: string
reviewerName: string
reviewedAt: string | null
resultInventoryId: number | null
createdAt: string
updatedAt: string
}
export type ConsignmentStatsSummary = {
totalInStockCount: number
totalInStockCostFen: number
totalConsumedCount: number
totalConsumedCostFen: number
byBuyer: Array<{
buyer_name: string
count: number
total_cost_fen: number
consumed_count: number
}>
byShop: Array<{ shop_name: string; count: number }>
byBatch: Array<{ inbound_batch: string; count: number; total_cost_fen: number }>
bySeries: Array<{ series: string; diamond_level: number; count: number }>
}
export type ConsignmentMatchResult = {
inventory: ConsignmentInventory
matchType: 'exact' | 'partial'
matchScore: number
matchReasons: string[]
}
export type WorkOrderInventoryMatchCheck = {
hasMatch: boolean
exactCount: number
partialCount: number
matches: ConsignmentMatchResult[]
}
+1 -5
View File
@@ -531,11 +531,7 @@ export type WorkOrderShare = {
}
export type WorkOrderGiftPhase =
| 'material_required'
| 'material_submitted'
| 'friend_countdown'
| 'gift_ready'
| string
'material_required' | 'material_submitted' | 'friend_countdown' | 'gift_ready' | string
/** 好友赠送验收模式的打手本人资料 */
export type BoosterMaterial = {