feat:寄卖增加对接客服,db脚本迁移修复

This commit is contained in:
2026-10-27 13:39:48 +08:00
parent 0de0fd7b58
commit 622bd779dd
25 changed files with 1515 additions and 106 deletions
@@ -0,0 +1,343 @@
import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Tag,
Typography,
} from 'antd'
import { useEffect, useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import PercentTable from '@/components/common/PercentTable'
import {
createAdminConsignmentContact,
deleteAdminConsignmentContact,
fetchAdminConsignmentContacts,
setAdminConsignmentContactEnabled,
updateAdminConsignmentContact,
} from '@/services/admin/consignment'
import type { ConsignmentContactSupporter } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
import type { TableColumnsType } from 'antd'
const { Text } = Typography
type ContactForm = {
name: string
phone?: string
wechat?: string
note?: string
sortOrder?: number
enabled?: boolean
}
const ENABLED_FILTER_OPTIONS = [
{ label: '全部状态', value: '' },
{ label: '启用中', value: 'true' },
{ label: '已禁用', value: 'false' },
]
/**
* 配置 → 对接客服。
*
* 独立名单(不复用后台账号):打手发布寄卖时必选,后台按人统计提成与快速联系。
* 已被寄卖记录引用的客服只能禁用,不能删除,保证历史单归属不丢。
*/
export default function AdminConsignmentContactsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ContactForm>()
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
const [enabledFilter, setEnabledFilter] = useState('')
const [editingContact, setEditingContact] = useState<ConsignmentContactSupporter | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [actionId, setActionId] = useState<number | null>(null)
useEffect(() => {
const timer = window.setTimeout(() => setKeyword(keywordInput.trim()), 300)
return () => window.clearTimeout(timer)
}, [keywordInput])
const query = useQuery({
queryKey: ['admin-consignment-contacts', keyword, enabledFilter],
queryFn: () =>
fetchAdminConsignmentContacts({
keyword: keyword || undefined,
enabled: enabledFilter || undefined,
}),
})
const items = query.data?.data.items || []
const total = Number(query.data?.data.total || 0)
const enabledCount = items.filter((item) => item.enabled).length
async function reload() {
await queryClient.invalidateQueries({ queryKey: ['admin-consignment-contacts'] })
}
function openCreate() {
setEditingContact(null)
form.resetFields()
form.setFieldsValue({ enabled: true, sortOrder: 0 })
setFormOpen(true)
}
function openEdit(record: ConsignmentContactSupporter) {
setEditingContact(record)
form.setFieldsValue({
name: record.name,
phone: record.phone,
wechat: record.wechat,
note: record.note,
sortOrder: record.sortOrder,
enabled: record.enabled,
})
setFormOpen(true)
}
async function handleSubmit() {
try {
const values = await form.validateFields()
setSubmitting(true)
const payload = {
name: values.name,
phone: values.phone || '',
wechat: values.wechat || '',
note: values.note || '',
sortOrder: Number(values.sortOrder || 0),
enabled: values.enabled !== false,
}
if (editingContact) {
await updateAdminConsignmentContact(editingContact.id, payload)
message.success('对接客服已更新')
} else {
await createAdminConsignmentContact(payload)
message.success('对接客服已新增')
}
setFormOpen(false)
setEditingContact(null)
await reload()
} catch (error: any) {
if (error?.errorFields) return
message.error(error?.message || '保存对接客服失败')
} finally {
setSubmitting(false)
}
}
async function handleToggleEnabled(record: ConsignmentContactSupporter, enabled: boolean) {
setActionId(record.id)
try {
await setAdminConsignmentContactEnabled(record.id, enabled)
message.success(enabled ? '已启用该对接客服' : '已禁用该对接客服')
await reload()
} catch (error: any) {
message.error(error?.message || '更新对接客服状态失败')
} finally {
setActionId(null)
}
}
function handleDelete(record: ConsignmentContactSupporter) {
modal.confirm({
title: `删除对接客服「${record.name}」?`,
content:
'只有未被任何寄卖记录使用的客服可以删除。已被使用的请改为「禁用」,历史寄卖单仍会保留对接客服信息。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminConsignmentContact(record.id)
message.success('对接客服已删除')
await reload()
} catch (error: any) {
message.error(error?.message || '删除对接客服失败')
throw error
}
},
})
}
const columns: TableColumnsType<ConsignmentContactSupporter> = [
{
title: '姓名',
key: 'name',
width: 160,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text strong>{record.name}</Text>
{record.note ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.note}
</Text>
) : null}
</Space>
),
},
{
title: '手机号',
dataIndex: 'phone',
width: 150,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '微信号',
dataIndex: 'wechat',
width: 160,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 80,
render: (value: number) => value || 0,
},
{
title: '状态',
key: 'enabled',
width: 110,
render: (_, record) => (
<Space size={6}>
<Switch
size="small"
checked={record.enabled}
loading={actionId === record.id}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => void handleToggleEnabled(record, checked)}
/>
<Tag color={record.enabled ? 'green' : 'default'} style={{ marginInlineEnd: 0 }}>
{record.enabled ? '启用中' : '已禁用'}
</Tag>
</Space>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (value: string) => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (_, record) => (
<Space size={4}>
<Button size="small" type="link" onClick={() => openEdit(record)}>
编辑
</Button>
<Button size="small" type="link" danger onClick={() => handleDelete(record)}>
删除
</Button>
</Space>
),
},
]
return (
<div>
<PageHeader
title="对接客服"
description="打手发布寄卖时必须选择对接客服;本名单独立于后台账号,用于提成归属与联系。"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新增对接客服
</Button>
}
/>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size={12}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索姓名 / 手机 / 微信 / 备注"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
style={{ width: 280 }}
/>
<Select
value={enabledFilter}
options={ENABLED_FILTER_OPTIONS}
onChange={setEnabledFilter}
style={{ width: 140 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
<Text type="secondary">
共 {total} 个,本页启用 {enabledCount} 个
</Text>
</Space>
</Card>
<PercentTable<ConsignmentContactSupporter>
rowKey="id"
loading={query.isLoading}
columns={columns}
dataSource={items}
pagination={false}
size="small"
/>
<Modal
title={editingContact ? `编辑对接客服 · ${editingContact.name}` : '新增对接客服'}
open={formOpen}
onCancel={() => {
setFormOpen(false)
setEditingContact(null)
}}
onOk={() => void handleSubmit()}
confirmLoading={submitting}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请填写对接客服姓名' }]}
>
<Input placeholder="如:小张" maxLength={60} />
</Form.Item>
<Space size={12} style={{ width: '100%' }} align="start">
<Form.Item name="phone" label="手机号" style={{ width: 240 }}>
<Input placeholder="选填" maxLength={30} />
</Form.Item>
<Form.Item name="wechat" label="微信号" style={{ width: 240 }}>
<Input placeholder="选填" maxLength={60} />
</Form.Item>
</Space>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} placeholder="如:负责苏尔南寄卖 / 白班" maxLength={200} />
</Form.Item>
<Space size={24} align="start">
<Form.Item
name="sortOrder"
label="排序"
tooltip="数字越小越靠前;打手端默认选中第一个启用项"
>
<InputNumber min={0} max={9999} style={{ width: 140 }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
)
}
@@ -29,6 +29,7 @@ import { useState } from 'react'
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
import {
archiveAdminConsignmentInventory,
fetchAdminConsignmentContacts,
fetchAdminConsignmentInventories,
fetchAdminConsignmentStats,
returnAdminConsignmentInventory,
@@ -40,6 +41,7 @@ import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMat
import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time'
@@ -174,6 +176,18 @@ export default function ConsignmentInventoryPanel() {
name: w.displayName || w.username,
}))
// 对接客服名单:编辑现货时可改派对接人(含已禁用项,历史数据需要能显示原值)
const contactsQuery = useQuery({
queryKey: ['admin-consignment-contacts'],
queryFn: () => fetchAdminConsignmentContacts(),
})
const contactSupporterOptions = (contactsQuery.data?.data.items || []).map((item) => ({
label: `${item.name}${item.enabled ? '' : '(已禁用)'}${
item.phone ? ` · ${item.phone}` : item.wechat ? ` · ${item.wechat}` : ''
}`,
value: item.id,
}))
// 数据查询
const inventoriesQuery = useQuery({
queryKey: [
@@ -255,6 +269,7 @@ export default function ConsignmentInventoryPanel() {
workerId: values.workerId || undefined,
workerName: selectedWorker?.name || editingItem.workerName || undefined,
workerPhone: selectedWorker?.phone || editingItem.workerPhone || undefined,
contactSupporterId: values.contactSupporterId ?? null,
remark: values.remark,
allowMatching: values.allowMatching !== false,
})
@@ -456,6 +471,22 @@ export default function ConsignmentInventoryPanel() {
</div>
),
},
{
title: '对接客服',
key: 'contactSupporter',
width: 150,
render: (_, record) => {
const contact = formatConsignmentContactSupporter(record)
return (
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{contact.name || contact.text}</div>
{contact.contact ? (
<div style={{ color: '#8c8c8c', marginTop: 2 }}>{contact.contact}</div>
) : null}
</div>
)
},
},
{
title: '凭证',
key: 'evidence',
@@ -990,6 +1021,24 @@ export default function ConsignmentInventoryPanel() {
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="contactSupporterId"
label="对接客服"
tooltip="用于提成归属与联系;发布寄卖时由卖家必选"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择对接客服(选填)"
options={contactSupporterOptions}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="sellerPaymentInfo" label="卖家收款信息">
<Input />
</Form.Item>
@@ -1082,6 +1131,23 @@ export default function ConsignmentInventoryPanel() {
<Text type="secondary">内部自营现货</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="对接客服">
{(() => {
const contact = formatConsignmentContactSupporter(viewingItem)
return contact.name ? (
<Space direction="vertical" size={2}>
<Text strong>{contact.name}</Text>
{contact.contact ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{contact.contact}
</Text>
) : null}
</Space>
) : (
<Text type="secondary">未指定</Text>
)
})()}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求">
{viewingItem.depositAmountFen ? (
<Tag color="gold">¥{(viewingItem.depositAmountFen / 100).toFixed(0)}</Tag>
@@ -40,6 +40,7 @@ import { fetchAdminSession } from '@/services/admin/auth'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/consignment'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time'
@@ -271,6 +272,22 @@ export default function ConsignmentSubmissionsPanel() {
</div>
),
},
{
title: '对接客服',
key: 'contactSupporter',
width: 150,
render: (_, record) => {
const contact = formatConsignmentContactSupporter(record)
return (
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{contact.name || contact.text}</div>
{contact.contact ? (
<div style={{ color: '#8c8c8c', marginTop: 2 }}>{contact.contact}</div>
) : null}
</div>
)
},
},
{
title: '提报凭证(游戏主页截图)',
key: 'submissionScreenshots',
@@ -536,6 +553,10 @@ export default function ConsignmentSubmissionsPanel() {
<span>
联系方式:<Text strong>{approveModalItem?.contactInfo || '-'}</Text>
</span>
<span>
对接客服:
<Text strong>{formatConsignmentContactSupporter(approveModalItem).text}</Text>
</span>
<span>
游戏ID:<Text strong>{approveModalItem?.sellerGameId || '-'}</Text>
</span>
@@ -852,6 +873,23 @@ export default function ConsignmentSubmissionsPanel() {
<Text type="secondary">未关联打手账号</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="对接客服">
{(() => {
const contact = formatConsignmentContactSupporter(viewingSubmission)
return contact.name ? (
<Space direction="vertical" size={2}>
<Text strong>{contact.name}</Text>
{contact.contact ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{contact.contact}
</Text>
) : null}
</Space>
) : (
<Text type="secondary">未指定</Text>
)
})()}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求">
{viewingSubmission.depositAmountFen ? (
<Tag color="gold">¥{(viewingSubmission.depositAmountFen / 100).toFixed(0)}</Tag>
@@ -92,6 +92,7 @@ import { notifyVoucherConsume } from '@/utils/voucher-consume'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import {
isConsignmentOrder,
isInternalConsignmentOrder,
@@ -1644,10 +1645,15 @@ export default function WorkOrdersPanel() {
</Typography.Text>
) : null}
{consignment ? (
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
</Tag>
<Space size={[4, 4]} wrap>
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
</Tag>
<Tag color="cyan" style={{ marginTop: 2, width: 'fit-content' }}>
对接客服: {formatConsignmentContactSupporter(consignment).text}
</Tag>
</Space>
) : null}
</div>
)
@@ -2317,6 +2323,11 @@ export default function WorkOrdersPanel() {
</Typography.Text>
</Descriptions.Item>
) : null}
{detailConsignment ? (
<Descriptions.Item label="对接客服">
{formatConsignmentContactSupporter(detailConsignment).text}
</Descriptions.Item>
) : null}
<Descriptions.Item label="状态">
<Tag color={resolveStatusColor(detailOrder.status)}>
{formatStatus(detailOrder.status)}
@@ -2655,6 +2666,13 @@ export default function WorkOrdersPanel() {
</Space>
</Descriptions.Item>
) : null}
{isConsignment ? (
<Descriptions.Item label="对接客服" span={2}>
<span className="worker-order-detail-value">
{formatConsignmentContactSupporter(consignmentInfo).text}
</span>
</Descriptions.Item>
) : null}
<Descriptions.Item
label={isConsignment ? '卖家备注' : '打手备注'}
span={2}
@@ -4095,6 +4113,9 @@ export default function WorkOrdersPanel() {
供货打手: {inv.workerName} ({inv.workerPhone || '无手机号'})
</Tag>
) : null}
<Tag color="cyan">
对接客服: {formatConsignmentContactSupporter(inv).text}
</Tag>
{inv.depositAmountFen != null ? (
<Tag color="warning">
履约押金: ¥{(inv.depositAmountFen / 100).toFixed(2)}
@@ -31,6 +31,7 @@ import {
Pagination,
Radio,
Row,
Select,
Space,
Tabs,
Tag,
@@ -43,6 +44,7 @@ import ImageUpload from '@/components/files/ImageUpload'
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
import {
confirmWorkerConsignmentFriendAdded,
fetchWorkerConsignmentContacts,
fetchWorkerConsignmentPriceSuggestion,
fetchWorkerConsignments,
fetchWorkerProfile,
@@ -56,6 +58,7 @@ import {
SURNAN_PUBLISH_SYSTEM_OPTIONS,
SURNAN_PUBLISH_ZONE_OPTIONS,
} from '@/utils/consignment-spec'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
import { formatDateTime } from '@/utils/date-time'
@@ -199,6 +202,15 @@ export default function WorkerConsignmentPage() {
}),
})
// 发布寄卖必填「对接客服」:只取启用名单,默认选第一个
const consignmentContactsQuery = useQuery({
queryKey: ['worker-consignment-contacts'],
queryFn: () => fetchWorkerConsignmentContacts(),
staleTime: 60_000,
})
const contactSupporters = consignmentContactsQuery.data?.data.items || []
const firstContactSupporterId = contactSupporters[0]?.id ?? null
// 切换标签页时自动刷新最新数据
useEffect(() => {
if (activeTab === 'records') {
@@ -261,6 +273,14 @@ export default function WorkerConsignmentPage() {
}
}, [worker, form])
// 名单异步加载完成后补默认值;用户已经选过则不覆盖
useEffect(() => {
if (firstContactSupporterId == null) return
if (form.getFieldValue('contactSupporterId') == null) {
form.setFieldsValue({ contactSupporterId: firstContactSupporterId })
}
}, [firstContactSupporterId, form])
useEffect(() => {
if (profileQuery.isLoading) return
if (typeof window === 'undefined') return
@@ -368,6 +388,15 @@ export default function WorkerConsignmentPage() {
return
}
if (contactSupporters.length === 0) {
message.error('暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布')
return
}
if (values.contactSupporterId == null) {
message.error('请选择对接客服')
return
}
const urls = screenshots.map((f) => f.url).filter(Boolean)
if (urls.length === 0) {
message.error('请上传游戏主页截图以供核验')
@@ -412,10 +441,14 @@ export default function WorkerConsignmentPage() {
screenshotsJson: urls,
sellerNote: values.sellerNote,
depositAmountFen: publishDepositFen,
contactSupporterId: Number(values.contactSupporterId),
})
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
form.resetFields()
if (firstContactSupporterId != null) {
form.setFieldsValue({ contactSupporterId: firstContactSupporterId })
}
setPriceState({ status: 'idle' })
lastPriceQueryKeyRef.current = ''
setScreenshots([])
@@ -662,6 +695,14 @@ export default function WorkerConsignmentPage() {
}
/>
{!consignmentContactsQuery.isLoading && contactSupporters.length === 0 ? (
<Alert
type="warning"
showIcon
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
/>
) : null}
<Divider plain className="worker-consignment-divider">
规格与价格
</Divider>
@@ -807,6 +848,35 @@ export default function WorkerConsignmentPage() {
</Divider>
<Row gutter={[12, 0]}>
<Col xs={24} sm={12}>
<Form.Item
name="contactSupporterId"
label="对接客服"
rules={[{ required: true, message: '请选择对接客服' }]}
extra={
contactSupporters.length === 0
? '暂无可选客服,请联系管理员配置'
: '用于后续提成归属与联系'
}
>
<Select
placeholder="请选择对接客服"
options={contactSupporters.map((item) => ({
value: item.id,
label: `${item.name}${
item.phone
? ` · ${item.phone}`
: item.wechat
? ` · ${item.wechat}`
: ''
}`,
}))}
notFoundContent="暂无启用中的对接客服"
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="sellerContact"
@@ -1334,6 +1404,9 @@ function ConsignmentRecordCard({
<span>
押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong>
</span>
<span>
对接客服 <strong>{formatConsignmentContactSupporter(record).text}</strong>
</span>
{record.workOrderId ? <span>履约单 #{record.workOrderId}</span> : null}
</div>
{record.reviewNote ? (