refactor(fulfillment): 移除飞飞/乐玩通道,仅保留 affiliate_dash

新单只路由 affiliate_dash(未命中走 manual_dispatch);历史飞飞/乐玩任务保留只读 stub,避免订单重同步失败。同步清理 cloudtentacles 调度、Admin 写路径与相关 env。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-14 17:29:47 +08:00
co-authored by Cursor
parent 0cbcb89dd8
commit b3aad0044c
155 changed files with 661 additions and 23351 deletions
@@ -1,670 +0,0 @@
import {
DownloadOutlined,
FileImageOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
Card,
Image as AntdImage,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import dayjs, { type Dayjs } from '@/lib/dayjs'
import { showError, showSuccess } from '@/lib/feedback'
import {
fetchAdminCloudtentaclesDeliveryRecords,
fetchAdminCloudtentaclesRecordSources,
} from '@/services/admin'
import type { AdminCloudtentaclesDeliveryRecordItem } from '@/types/admin'
import { getAdminToken } from '@/utils/admin-auth'
import {
buildCloudtentaclesTablePagination,
CLOUDTENTACLES_DEFAULT_PAGE_SIZE,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
type RecordSourceOption = {
key: string
label: string
enabled: boolean
hasToken: boolean
loggedInAt: string
}
type DateRangeValue = [Dayjs | null, Dayjs | null] | null
export default function AdminCloudtentaclesRecordsPage() {
const [loading, setLoading] = useState(false)
const [sourceLoading, setSourceLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [sourceKey, setSourceKey] = useState('')
const [platformOrderId, setPlatformOrderId] = useState('')
const [dateRange, setDateRange] = useState<DateRangeValue>(createDefaultDateRange())
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(CLOUDTENTACLES_DEFAULT_PAGE_SIZE)
const [total, setTotal] = useState(0)
const [items, setItems] = useState<AdminCloudtentaclesDeliveryRecordItem[]>([])
const [sourceOptions, setSourceOptions] = useState<RecordSourceOption[]>([])
const [previewVisible, setPreviewVisible] = useState(false)
const [previewImageUrl, setPreviewImageUrl] = useState('')
const [previewFileName, setPreviewFileName] = useState('')
const currentSourceLabel = useMemo(() => {
const matched = sourceOptions.find((source) => source.key === sourceKey)
return matched?.label || sourceKey || '-'
}, [sourceKey, sourceOptions])
const columns: TableColumnsType<AdminCloudtentaclesDeliveryRecordItem> = [
{
title: '商品 / 角色',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong ellipsis={{ tooltip: row.name || '-' }}>
{row.name || '-'}
</Typography.Text>
<span className="muted">账号:{row.fulfillUser || '-'}</span>
{row.roleName || row.roleId ? (
<span className="muted">
角色:{row.roleName || '-'}
{row.roleId ? `(${row.roleId})` : ''}
</span>
) : null}
</div>
),
},
{
title: '订单 / 店铺',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong ellipsis={{ tooltip: row.platformOrderId || '-' }}>
{row.platformOrderId || '-'}
</Typography.Text>
<span className="muted">订单店铺:{row.orderShopName || row.orderShopId || '-'}</span>
<span className="muted">核销店铺:{row.consumeShopName || row.consumeShopId || '-'}</span>
</div>
),
},
{
title: '虚拟号',
minWidth: 160,
render: (_, row) => (
<div className="cell-stack">
<span>{row.phone || '-'}</span>
<span className="muted">VN {row.virtualNumberId || '-'}</span>
</div>
),
},
{
title: '状态',
width: 140,
render: (_, row) => (
<div className="status-stack">
<Tag color={getStatusColor(row.status)}>{getStatusLabel(row.status)}</Tag>
{row.localDispatchStatus ? (
<span className="muted">{getLocalDispatchStatusLabel(row.localDispatchStatus)}</span>
) : null}
</div>
),
},
{
title: '操作人',
minWidth: 120,
render: (_, row) => row.operatorName || '-',
},
{
title: '发货时间',
minWidth: 170,
render: (_, row) => formatAdminDateTime(row.createdAt),
},
{
title: '记录 ID',
minWidth: 230,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text type="secondary" ellipsis={{ tooltip: row.recordId || '-' }}>
{row.recordId || '-'}
</Typography.Text>
{row.taskNo ? (
<span className="muted">
任务:{row.taskNo}
{getMatchConfidenceLabel(row.matchConfidence)
? ` · 匹配${getMatchConfidenceLabel(row.matchConfidence)}`
: ''}
</span>
) : null}
</div>
),
},
{
title: '操作',
fixed: 'right',
width: 136,
render: (_, row) => (
<Button
size="small"
type="primary"
ghost
icon={<FileImageOutlined />}
onClick={() => generateRecordImage(row)}
>
生成图片
</Button>
),
},
]
useEffect(() => {
void bootstrap()
}, [])
async function bootstrap() {
const nextSourceKey = await loadSources()
if (nextSourceKey) {
await loadRecords(1, { sourceKey: nextSourceKey })
}
}
async function loadSources() {
setSourceLoading(true)
try {
const response = await fetchAdminCloudtentaclesRecordSources()
const nextSources = response.data.sources || []
const usableSources = nextSources.filter((source) => source.enabled && source.hasToken)
const nextSourceKey = sourceKey || usableSources[0]?.key || nextSources[0]?.key || ''
setSourceOptions(nextSources)
setSourceKey(nextSourceKey)
return nextSourceKey
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 账号失败')
return ''
} finally {
setSourceLoading(false)
}
}
async function loadRecords(
nextPage = page,
options: {
sourceKey?: string
pageSize?: number
platformOrderId?: string
dateRange?: DateRangeValue
} = {},
) {
const nextSourceKey = options.sourceKey ?? sourceKey
const nextPageSize = options.pageSize ?? pageSize
const nextDateRange = options.dateRange ?? dateRange
const orderKeyword = (options.platformOrderId ?? platformOrderId).trim()
if (!nextSourceKey && !orderKeyword) {
showError('请先选择 kuaishou-lewan 账号')
return
}
const [startDate, endDate] = nextDateRange || []
if (!startDate || !endDate) {
showError('请选择查询时间范围')
return
}
setLoading(true)
setErrorMessage('')
try {
const response = await fetchAdminCloudtentaclesDeliveryRecords({
sourceKey: nextSourceKey || undefined,
page: nextPage,
size: nextPageSize,
startDate: formatQueryDateTime(startDate),
endDate: formatQueryDateTime(endDate),
platformOrderId: orderKeyword || undefined,
})
if (response.data.sourceKey && response.data.sourceKey !== sourceKey) {
setSourceKey(response.data.sourceKey)
}
setPage(response.data.page)
setPageSize(response.data.size)
setTotal(response.data.total)
setItems(response.data.items || [])
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '查询发货记录失败')
} finally {
setLoading(false)
}
}
function resetFilters() {
const nextDateRange = createDefaultDateRange()
setPlatformOrderId('')
setDateRange(nextDateRange)
setPage(1)
setPageSize(CLOUDTENTACLES_DEFAULT_PAGE_SIZE)
void loadRecords(1, {
pageSize: CLOUDTENTACLES_DEFAULT_PAGE_SIZE,
platformOrderId: '',
dateRange: nextDateRange,
})
}
async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
try {
const dataUrl = await drawRecordImage(record)
setPreviewImageUrl(dataUrl)
setPreviewFileName(buildRecordImageFileName(record))
setPreviewVisible(true)
} catch (error) {
showError(error instanceof Error ? error.message : '生成发货记录图片失败')
}
}
function downloadPreviewImage() {
if (!previewImageUrl) {
return
}
const link = document.createElement('a')
link.href = previewImageUrl
link.download = previewFileName || 'cloudtentacles-delivery-record.png'
link.click()
showSuccess('发货记录图片已生成')
}
return (
<section className="page-stack">
<PageHeader
title="查询发货记录"
description="按订单号、kuaishou-lewan 账号和时间范围查询平台发货记录。"
extra={<span className="total-badge">共 {total} 条记录</span>}
/>
<Card
title="查询条件"
extra={
<Typography.Text type="secondary">
输入订单号时会先关联本地任务,再匹配 kuaishou-lewan 发货记录。
</Typography.Text>
}
>
<div className="cloud-records-filter">
<Select
value={sourceKey || undefined}
loading={sourceLoading}
placeholder="kuaishou-lewan 账号"
options={sourceOptions.map((source) => ({
label: `${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`,
value: source.key,
disabled: !source.enabled || !source.hasToken,
}))}
onChange={setSourceKey}
/>
<Input
allowClear
value={platformOrderId}
placeholder="订单号"
onChange={(event) => setPlatformOrderId(event.target.value)}
onPressEnter={() => loadRecords(1)}
/>
<AdminRangePicker
showTime
value={dateRange}
className="full-width"
onChange={(value) => setDateRange(value)}
/>
<Space>
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
重置
</Button>
<Button
type="primary"
icon={<SearchOutlined />}
loading={loading}
onClick={() => loadRecords(1)}
>
查询
</Button>
</Space>
</div>
</Card>
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
<Card>
<div className="table-toolbar">
<strong>发货记录</strong>
<span>
账号 {currentSourceLabel} · 第 {page} 页,当前展示 {items.length} 条
</span>
</div>
<Table<AdminCloudtentaclesDeliveryRecordItem>
rowKey={(row) => row.recordId || `${row.virtualNumberId}-${row.createdAt}`}
loading={loading}
columns={columns}
dataSource={items}
size="small"
scroll={{ x: 1450 }}
pagination={buildCloudtentaclesTablePagination({
current: page,
pageSize,
total,
onChange: (nextPage, nextPageSize) => {
void loadRecords(nextPage, { pageSize: nextPageSize })
},
})}
locale={{ emptyText: loading ? '发货记录加载中' : '当前筛选条件下暂无发货记录' }}
/>
</Card>
<Modal
title="发货记录图片"
open={previewVisible}
width={780}
destroyOnHidden
onCancel={() => setPreviewVisible(false)}
footer={[
<Button key="close" onClick={() => setPreviewVisible(false)}>
关闭
</Button>,
<Button
key="download"
type="primary"
icon={<DownloadOutlined />}
onClick={downloadPreviewImage}
>
下载图片
</Button>,
]}
>
<div className="record-preview">
{previewImageUrl ? (
<AntdImage src={previewImageUrl} alt="发货记录图片预览" preview={false} />
) : null}
</div>
</Modal>
</section>
)
}
function getStatusLabel(status: number) {
if (status === 4) return '已完成'
if (status === 1) return '处理中'
if (status === 2) return '待处理'
if (status === 3) return '失败'
return `状态 ${status || '-'}`
}
function getStatusColor(status: number) {
if (status === 4) return 'green'
if (status === 3) return 'red'
if (status === 1) return 'orange'
return 'blue'
}
function getLocalDispatchStatusLabel(status: string) {
const normalized = String(status || '').trim()
if (normalized === 'success') return '本地已发货'
if (normalized === 'failed') return '本地发货失败'
if (normalized === 'pending') return '本地待发货'
return normalized || '-'
}
function getMatchConfidenceLabel(value: string) {
if (value === 'high') return '高'
if (value === 'medium') return '中'
if (value === 'low') return '低'
if (value === 'local') return '本地'
return ''
}
function createDefaultDateRange(): DateRangeValue {
const end = dayjs()
return [end.subtract(7, 'day').startOf('day'), end]
}
function formatQueryDateTime(value: Dayjs) {
return value.format('YYYY-MM-DD HH:mm:ss')
}
async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
const productImage = await loadRecordProductImage(record.skuImage)
const canvas = document.createElement('canvas')
const scale = window.devicePixelRatio || 1
const width = 720
const height = 330
canvas.width = width * scale
canvas.height = height * scale
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('当前浏览器不支持图片生成')
}
ctx.scale(scale, scale)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.fillStyle = '#1f2937'
ctx.font = '20px sans-serif'
ctx.fillText('订单详情', 28, 38)
ctx.fillStyle = '#9ca3af'
ctx.font = '24px sans-serif'
ctx.fillText('x', width - 40, 38)
ctx.fillStyle = '#111827'
ctx.font = '13px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`购买时间:${formatAdminDateTime(record.createdAt)}`, width / 2, 88)
ctx.textAlign = 'left'
drawTicketCard(ctx, record, productImage)
drawRecordMeta(ctx, record)
return canvas.toDataURL('image/png')
}
function drawTicketCard(
ctx: CanvasRenderingContext2D,
record: AdminCloudtentaclesDeliveryRecordItem,
productImage: HTMLImageElement | null,
) {
const x = 170
const y = 108
const width = 392
const height = 108
const imageWidth = 106
ctx.save()
ctx.beginPath()
ctx.moveTo(x + 18, y)
ctx.lineTo(x + width, y)
ctx.lineTo(x + width, y + height - 22)
ctx.lineTo(x + width - 18, y + height)
ctx.lineTo(x, y + height)
ctx.lineTo(x, y + 18)
ctx.closePath()
ctx.fillStyle = '#f8fbff'
ctx.fill()
ctx.strokeStyle = '#2f80d1'
ctx.lineWidth = 1
ctx.stroke()
ctx.restore()
const gradient = ctx.createLinearGradient(x, y, x + imageWidth, y + height)
gradient.addColorStop(0, '#e7f1ff')
gradient.addColorStop(1, '#b8d7ff')
ctx.fillStyle = gradient
ctx.fillRect(x, y, imageWidth, height)
if (productImage) {
drawCoverImage(ctx, productImage, x, y, imageWidth, height)
} else {
ctx.fillStyle = '#355d91'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('套装发货记录', x + imageWidth / 2, y + 25)
ctx.fillStyle = '#ffffff'
ctx.fillRect(x + 34, y + 42, 38, 38)
ctx.strokeStyle = '#8ab6ef'
ctx.strokeRect(x + 34, y + 42, 38, 38)
ctx.fillStyle = '#2f80d1'
ctx.font = '20px sans-serif'
ctx.fillText('GP', x + imageWidth / 2, y + 69)
}
ctx.textAlign = 'left'
const contentX = x + imageWidth + 16
const orderText = `订单号:${record.platformOrderId || record.recordId.slice(-8) || '-'}`
ctx.fillStyle = '#1d5ea8'
ctx.font = '16px sans-serif'
drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170)
ctx.fillStyle = '#3478c6'
ctx.font = '13px sans-serif'
ctx.textAlign = 'right'
ctx.fillText(orderText, x + width - 12, y + 30)
ctx.textAlign = 'left'
ctx.strokeStyle = '#c8d8ec'
ctx.beginPath()
ctx.moveTo(contentX, y + 42)
ctx.lineTo(x + width - 12, y + 42)
ctx.stroke()
ctx.fillStyle = '#f59e0b'
ctx.font = '14px sans-serif'
ctx.fillText(`购买机会 x ${record.count || 1}`, contentX, y + 66)
ctx.fillStyle = '#16a34a'
ctx.textAlign = 'right'
ctx.fillText(getStatusLabel(record.status), x + width - 12, y + 66)
ctx.textAlign = 'left'
ctx.fillStyle = '#3478c6'
ctx.font = '14px sans-serif'
drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250)
}
function drawRecordMeta(
ctx: CanvasRenderingContext2D,
record: AdminCloudtentaclesDeliveryRecordItem,
) {
const left = 80
const top = 250
const lineHeight = 22
const values = [
`订单店铺:${record.orderShopName || record.orderShopId || '-'}`,
`核销店铺:${record.consumeShopName || record.consumeShopId || '-'}`,
`绑定角色:${record.roleName || record.fulfillUser || '-'}${record.roleId ? `(${record.roleId})` : ''}`,
]
ctx.fillStyle = '#374151'
ctx.font = '13px sans-serif'
values.forEach((value, index) => {
drawEllipsisText(ctx, value, left, top + index * lineHeight, 560)
})
}
async function loadRecordProductImage(imageUrl: string) {
const normalizedUrl = String(imageUrl || '').trim()
if (!normalizedUrl) {
return null
}
const proxyUrl = `/api/v1/admin/cloudtentacles-records/image?url=${encodeURIComponent(
normalizedUrl,
)}`
return loadProtectedImage(proxyUrl).catch(() => null)
}
async function loadProtectedImage(src: string) {
const token = getAdminToken()
const response = await fetch(src, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!response.ok) {
throw new Error('商品图片加载失败')
}
const objectUrl = URL.createObjectURL(await response.blob())
try {
return await loadImage(objectUrl)
} finally {
URL.revokeObjectURL(objectUrl)
}
}
function loadImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('商品图片加载失败'))
image.src = src
})
}
function drawCoverImage(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
x: number,
y: number,
width: number,
height: number,
) {
const ratio = Math.max(width / image.naturalWidth, height / image.naturalHeight)
const drawWidth = image.naturalWidth * ratio
const drawHeight = image.naturalHeight * ratio
const drawX = x + (width - drawWidth) / 2
const drawY = y + (height - drawHeight) / 2
ctx.save()
ctx.beginPath()
ctx.rect(x, y, width, height)
ctx.clip()
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
ctx.restore()
}
function drawEllipsisText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
) {
let output = text
while (output.length > 0 && ctx.measureText(output).width > maxWidth) {
output = `${output.slice(0, -2)}...`
}
ctx.fillText(output || '-', x, y)
}
function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) {
const name = sanitizeFileName(record.name || '发货记录')
const id = sanitizeFileName(
record.platformOrderId ||
record.recordId.slice(0, 8) ||
String(record.virtualNumberId || 'record'),
)
return `${name}-${id}.png`
}
function sanitizeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48)
}
@@ -22,10 +22,8 @@ import PageHeader from '@/components/admin/PageHeader'
import {
buildOpen91QueryDevMock,
createAffiliateDashDevMock,
createFeifeiDevMock,
createKuaishouIndustryVoucherDevMock,
createLewanDevMock,
fetchDevMockStatus,
createKuaishouIndustryVoucherDevMock,
fetchDevMockStatus,
type DevMockCreateResult,
type DevMockKuaishouIndustryVoucherResult,
type DevMockOpen91QueryResult,
@@ -47,27 +45,7 @@ export default function AdminDevMockPage() {
const status = statusQuery.data?.data
const enabled = Boolean(status?.enabled)
const lewanMutation = useMutation({
mutationFn: createLewanDevMock,
onSuccess: (response) => {
setLastCreate(response.data)
message.success('lewan mock 已生成')
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '生成失败')
},
})
const feifeiMutation = useMutation({
mutationFn: createFeifeiDevMock,
onSuccess: (response) => {
setLastCreate(response.data)
message.success('feifei mock 已生成')
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '生成失败')
},
})
const affiliateDashMutation = useMutation({
mutationFn: createAffiliateDashDevMock,
@@ -133,7 +111,7 @@ export default function AdminDevMockPage() {
<section className="page-stack">
<PageHeader
title="开发 Mock"
description="一键造单、测领取页与 91 查单,无需真实 91 / 快手 / feifei 环境。"
description="一键造单、测领取页与 91 查单,无需真实 91 / affiliate-dash 环境。"
extra={
<Typography.Text type="secondary">NODE_ENV={status?.nodeEnv || '-'}</Typography.Text>
}
@@ -143,32 +121,14 @@ export default function AdminDevMockPage() {
type="info"
showIcon
icon={<BugOutlined />}
message="Mock 会写入开发库真实订单/任务,但 lewan 兑换走 mock 成功路径,feifei 不调用真实平台下单。"
description="建议 UID 与默认角色一致:lewan 默认 10001,feifei 默认 166909256。"
message="Mock 会写入开发库真实订单/任务;当前仅保留 affiliate-dash / 电子凭证 / 91 查单。"
description="affiliate-dash 可用默认 UID 测试绑定与提交发货。"
/>
<Row gutter={[16, 16]}>
<Col xs={24} xl={12}>
<Card title="1. lewan 领取(强制 UID)" bordered={false}>
<LewanMockForm
defaultUid={status?.defaultUid || '10001'}
loading={lewanMutation.isPending}
onSubmit={(values) => lewanMutation.mutate(values)}
/>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card title="2. feifei 领取(拼 UID 到 H5)" bordered={false}>
<FeifeiMockForm
loading={feifeiMutation.isPending}
onSubmit={(values) => feifeiMutation.mutate(values)}
/>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card title="3. affiliate-dash 领取(mock 二维码/提交发货)" bordered={false}>
<Card title="1. affiliate-dash 领取(mock 二维码/提交发货)" bordered={false}>
<AffiliateDashMockForm
loading={affiliateDashMutation.isPending}
onSubmit={(values) => affiliateDashMutation.mutate(values)}
@@ -177,7 +137,7 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24} xl={12}>
<Card title="4. 快手电子凭证测试数据" bordered={false}>
<Card title="2. 快手电子凭证测试数据" bordered={false}>
<KuaishouIndustryVoucherMockForm
loading={voucherMutation.isPending}
onSubmit={(values) => voucherMutation.mutate(values)}
@@ -186,7 +146,7 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24}>
<Card title="5. 模拟 91 查单(open-91/query)" bordered={false}>
<Card title="3. 模拟 91 查单(open-91/query)" bordered={false}>
<Form
layout="inline"
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
@@ -354,139 +314,6 @@ export default function AdminDevMockPage() {
)
}
function LewanMockForm({
defaultUid,
loading,
onSubmit,
}: {
defaultUid: string
loading: boolean
onSubmit: (values: Record<string, unknown>) => void
}) {
const [form] = Form.useForm()
const step = Form.useWatch('step', form)
const uidRequired = step !== 'uid'
return (
<Form
form={form}
layout="vertical"
initialValues={{
step: 'uid',
uid: defaultUid || '10001',
productNo: '套餐_1----3676797936',
}}
onFinish={onSubmit}
>
<Form.Item
label="页面步骤"
name="step"
tooltip="uid=只到填编号第一步;binding=已预填编号且角色匹配;confirm/result=后续步骤"
>
<Select
options={[
{ value: 'uid', label: 'uid · 待填 UID(第一步)' },
{ value: 'binding', label: 'binding · 绑定且 UID 已匹配(推荐)' },
{ value: 'confirm', label: 'confirm · 待确认兑换' },
{ value: 'result', label: 'result · 已完成' },
]}
/>
</Form.Item>
<Form.Item
label="游戏 UID"
name="uid"
required={uidRequired}
rules={
uidRequired
? [{ required: true, whitespace: true, message: '后续步骤请填写游戏 UID' }]
: []
}
extra={
step === 'uid'
? '「待填 UID」步骤可不填,打开领取页会停留在第一步让用户填写'
: '后续步骤用于模拟已提交的编号,建议与默认角色一致(如 10001)'
}
>
<Input allowClear placeholder={defaultUid || '10001'} />
</Form.Item>
<Form.Item label="91 商品 productNo" name="productNo">
<Input placeholder="套餐_1----3676797936" />
</Form.Item>
<Form.Item label="订单号 orderNo(可空自动生成)" name="orderNo">
<Input placeholder="留空自动 MOCK91…" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
生成 lewan Mock
</Button>
</Form>
)
}
function FeifeiMockForm({
loading,
onSubmit,
}: {
loading: boolean
onSubmit: (values: Record<string, unknown>) => void
}) {
const [form] = Form.useForm()
const step = Form.useWatch('step', form)
const uidRequired = step !== 'uid'
return (
<Form
form={form}
layout="vertical"
initialValues={{
step: 'uid',
uid: '166909256',
productName: '套装-浪漫天命',
}}
onFinish={onSubmit}
>
<Form.Item label="页面步骤" name="step">
<Select
options={[
{ value: 'uid', label: 'uid · 待填 UID(第一步)' },
{ value: 'ready', label: 'ready · 已填 UID,可打开 H5(推荐)' },
{ value: 'completed', label: 'completed · 模拟成功' },
{ value: 'failed', label: 'failed · 模拟失败' },
]}
/>
</Form.Item>
<Form.Item
label="游戏 UID"
name="uid"
required={uidRequired}
rules={
uidRequired
? [{ required: true, whitespace: true, message: '后续步骤请填写游戏 UID' }]
: []
}
extra={
step === 'uid'
? '「待填 UID」步骤可不填,打开领取页会停留在第一步让用户填写'
: '后续步骤用于模拟已提交的编号'
}
>
<Input allowClear placeholder="166909256" />
</Form.Item>
<Form.Item label="商品名" name="productName">
<Input />
</Form.Item>
<Form.Item label="H5 链接(可空用 mock 地址)" name="h5Url">
<Input placeholder="http://skin-exchange.../h5/bind?code=..." />
</Form.Item>
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
<Input placeholder="留空自动 MOCKFF…" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
生成 feifei Mock
</Button>
</Form>
)
}
function AffiliateDashMockForm({
loading,
onSubmit,
@@ -4,10 +4,6 @@ import {
CloseCircleOutlined,
CopyOutlined,
ReloadOutlined,
RollbackOutlined,
SendOutlined,
SwapOutlined,
ToolOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
import {
@@ -38,16 +34,11 @@ import {
closeAdminTask,
completeAdminTaskManualDispatch,
consumeAdminTaskKuaishouIndustryVoucher,
dispatchAdminTaskKuaishouCloudFulfillment,
fetchAdminTaskDetail,
fetchAdminTaskScreenshot,
markAdminTaskManualReview,
prepareAdminTaskKuaishouCloudFulfillment,
rebindAdminTaskKuaishouCloudRole,
refreshAdminTaskKuaishouCloudRoleInfo,
resendAdminTaskKuaishouIndustryVoucherCode,
retryAdminTask,
returnNumberAdminTaskKuaishouCloudFulfillment,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
@@ -275,38 +266,6 @@ export default function AdminTaskDetailPage() {
`确认重试任务 ${resolvedDetail.task.taskNo} 吗?`,
)
}
onRebindRole={() =>
runTaskAction(
'rebind-role',
() => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId),
'换绑完成:已生成新的绑定链接',
`确认为任务 ${resolvedDetail.task.taskNo} 换绑吗?会退还当前虚拟号并生成新绑链;若旧号已被系统回收会自动跳过退号并直接新取号。原领取链接可继续使用。`,
)
}
onPrepareKuaishouCloud={() =>
runTaskAction(
'prepare-kuaishou-cloud',
() => prepareAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
'绑定资源已准备完成',
`确认开始为任务 ${resolvedDetail.task.taskNo} 准备绑定资源吗?系统会自动检查库存配置并申请虚拟号。`,
)
}
onDispatchKuaishouCloud={() =>
runTaskAction(
'dispatch-kuaishou-cloud',
() => dispatchAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
'已完成绑定确认并发货',
`确认客户已经完成绑定,并立即为任务 ${resolvedDetail.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`,
)
}
onReturnKuaishouCloud={() =>
runTaskAction(
'return-kuaishou-cloud',
() => returnNumberAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
'号码已退还',
`确认退还任务 ${resolvedDetail.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`,
)
}
onResendIndustryVoucher={() =>
runTaskAction(
'resend-industry-voucher',
@@ -419,23 +378,11 @@ export default function AdminTaskDetailPage() {
items={[
{
key: 'cloud',
label: 'kuaishou-lewan',
label: '乐玩云(历史)',
children: flow ? (
<KuaishouCloudPanel
flow={flow}
canRefresh={resolvedDetail.operations.canRefreshKuaishouCloudRoleInfo}
actionLoadingKey={actionLoadingKey}
onRefreshRole={() =>
runTaskAction(
'refresh-role',
() => refreshAdminTaskKuaishouCloudRoleInfo(resolvedDetail.task.taskId),
'角色信息已刷新',
`确认刷新任务 ${resolvedDetail.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 kuaishou-lewan 的绑定结果。`,
)
}
/>
<KuaishouCloudPanel flow={flow} />
) : (
<Empty description="无 kuaishou-lewan 履约上下文" />
<Empty description="无历史乐玩云履约上下文" />
),
},
{
@@ -496,10 +443,6 @@ function TaskActionPanel({
canCloseTasks,
actionLoadingKey,
onRetry,
onRebindRole,
onPrepareKuaishouCloud,
onDispatchKuaishouCloud,
onReturnKuaishouCloud,
onResendIndustryVoucher,
onConsumeIndustryVoucher,
onMarkManualReview,
@@ -511,10 +454,6 @@ function TaskActionPanel({
canCloseTasks: boolean
actionLoadingKey: string
onRetry: () => void
onRebindRole: () => void
onPrepareKuaishouCloud: () => void
onDispatchKuaishouCloud: () => void
onReturnKuaishouCloud: () => void
onResendIndustryVoucher: () => void
onConsumeIndustryVoucher: () => void
onMarkManualReview: () => void
@@ -522,11 +461,6 @@ function TaskActionPanel({
}) {
const operations = detail.operations
const loading = Boolean(actionLoadingKey)
const hasCloudActions =
operations.canPrepareKuaishouCloudFulfillment ||
operations.canRebindKuaishouCloudRole ||
operations.canDispatchKuaishouCloudFulfillment ||
operations.canReturnKuaishouCloudFulfillment
const hasIndustryVoucherActions =
operations.canResendKuaishouIndustryVoucherCode ||
operations.canConsumeKuaishouIndustryVoucher ||
@@ -560,57 +494,6 @@ function TaskActionPanel({
</section>
) : null}
{hasCloudActions ? (
<section className="task-action-group">
<Typography.Text type="secondary">kuaishou-lewan</Typography.Text>
<Space wrap>
{canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? (
<Button
type="primary"
ghost
icon={<ToolOutlined />}
disabled={loading}
loading={actionLoadingKey === 'prepare-kuaishou-cloud'}
onClick={onPrepareKuaishouCloud}
>
准备资源
</Button>
) : null}
{operations.canRebindKuaishouCloudRole ? (
<Button
icon={<SwapOutlined />}
disabled={loading}
loading={actionLoadingKey === 'rebind-role'}
onClick={onRebindRole}
>
换绑
</Button>
) : null}
{canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? (
<Button
type="primary"
icon={<SendOutlined />}
disabled={loading}
loading={actionLoadingKey === 'dispatch-kuaishou-cloud'}
onClick={onDispatchKuaishouCloud}
>
发货
</Button>
) : null}
{canManageTaskLifecycle && operations.canReturnKuaishouCloudFulfillment ? (
<Button
icon={<RollbackOutlined />}
disabled={loading}
loading={actionLoadingKey === 'return-kuaishou-cloud'}
onClick={onReturnKuaishouCloud}
>
退号
</Button>
) : null}
</Space>
</section>
) : null}
{canOperateIndustryVoucher && hasIndustryVoucherActions ? (
<section className="task-action-group">
<Typography.Text type="secondary">电子凭证</Typography.Text>
@@ -652,10 +535,7 @@ function TaskActionPanel({
</section>
) : null}
{!canManageTaskLifecycle &&
!canOperateIndustryVoucher &&
!canCloseTasks &&
!hasCloudActions ? (
{!canManageTaskLifecycle && !canOperateIndustryVoucher && !canCloseTasks ? (
<Typography.Text type="secondary">当前账号没有可执行的任务操作</Typography.Text>
) : null}
</div>
@@ -964,14 +844,17 @@ function resolveDeliveryStepSummary(detail: AdminTaskDetail) {
function resolveTaskExecutorDisplay(value: unknown) {
const executorKey = String(value || '').trim()
if (executorKey === 'affiliate_dash') {
return { label: 'Affiliate Dash', color: 'green' }
}
if (executorKey === 'kuaishou_ct_assisted') {
return { label: 'kuaishou-lewan', color: 'blue' }
return { label: '乐玩云(已下线)', color: 'default' }
}
if (executorKey === 'kuaishou-industry') {
return { label: '行业电子凭证', color: 'cyan' }
}
if (executorKey === 'kuaishou_feifei') {
return { label: 'kuaishou-feifei', color: 'purple' }
return { label: '飞飞(已下线)', color: 'default' }
}
if (executorKey === 'manual_dispatch') {
return { label: '人工履约', color: 'orange' }
@@ -1152,14 +1035,8 @@ function KuaishouIndustryVoucherPanel({ voucher }: { voucher: KuaishouIndustryVo
function KuaishouCloudPanel({
flow,
canRefresh,
actionLoadingKey,
onRefreshRole,
}: {
flow: NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
canRefresh: boolean
actionLoadingKey: string
onRefreshRole: () => void
}) {
const roleInfoEntries = flow.role.rawInfo
? Object.entries(flow.role.rawInfo).filter(
@@ -1220,19 +1097,7 @@ function KuaishouCloudPanel({
return (
<section className="kuaishou-cloud-stack">
<Card
title="kuaishou-lewan 履约"
extra={
<Button
icon={<ReloadOutlined />}
disabled={Boolean(actionLoadingKey) || !canRefresh}
loading={actionLoadingKey === 'refresh-role'}
onClick={onRefreshRole}
>
刷新角色
</Button>
}
>
<Card title="乐玩云履约(只读历史)">
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
<Descriptions.Item label="来源账号">
{flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'}
@@ -2,7 +2,6 @@ import {
CloseCircleOutlined,
ReloadOutlined,
SearchOutlined,
SwapOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
import {
@@ -32,7 +31,6 @@ import {
closeAdminTask,
fetchAdminTasks,
markAdminTaskManualReview,
rebindAdminTaskKuaishouCloudRole,
retryAdminTask,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
@@ -52,7 +50,7 @@ type TaskFilterForm = {
dateRange?: unknown
}
type TaskListAction = 'retry' | 'rebind_role' | 'manual_review' | 'close'
type TaskListAction = 'retry' | 'manual_review' | 'close'
type TaskListBlocker = {
label: string
color: string
@@ -72,8 +70,6 @@ export default function AdminTasksPage() {
const page = Number(searchParams.get('page') || 1) || 1
const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE)
const canManageTaskLifecycle = hasAdminRole('operator')
// 与详情页一致:客服可换绑协助处理(canOperateAssistedTask)
const canRebindRole = hasAdminRole('support')
const canCloseTasks = hasAdminRole('support')
const filters = {
keyword:
@@ -235,12 +231,11 @@ export default function AdminTasksPage() {
canManageTaskLifecycle &&
row.executorKey !== 'manual_dispatch' &&
['retry_pending', 'manual_review'].includes(row.status)
const canRebind = canRebindListTask(row, canRebindRole)
const canManualReview =
canManageTaskLifecycle &&
!['redeemed', 'closed', 'completed', 'manual_review'].includes(row.status)
const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status)
const hasActions = canRetry || canRebind || canManualReview || canClose
const hasActions = canRetry || canManualReview || canClose
return (
<div className="cell-stack">
@@ -264,17 +259,6 @@ export default function AdminTasksPage() {
重试
</Button>
) : null}
{canRebind ? (
<Button
size="small"
icon={<SwapOutlined />}
disabled={actionRunning}
loading={rowLoading}
onClick={() => handleActionCommand('rebind_role', row)}
>
换绑
</Button>
) : null}
{canManualReview ? (
<Button
size="small"
@@ -351,14 +335,6 @@ export default function AdminTasksPage() {
}
}
if (actionKey === 'rebind_role') {
return {
action: () => rebindAdminTaskKuaishouCloudRole(item.taskId),
confirmText: `确认为任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
successMessage: '角色换绑资源已准备完成',
}
}
if (actionKey === 'manual_review') {
return {
action: () => markAdminTaskManualReview(item.taskId),
@@ -491,24 +467,19 @@ export default function AdminTasksPage() {
)
}
function canRebindListTask(item: AdminTaskListItem, canRebindRole: boolean) {
return (
canRebindRole &&
item.executorKey === 'kuaishou_ct_assisted' &&
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
)
}
function resolveTaskExecutorDisplay(value: unknown) {
const executorKey = String(value || '').trim()
if (executorKey === 'affiliate_dash') {
return { key: executorKey, label: 'Affiliate Dash', color: 'green' }
}
if (executorKey === 'kuaishou_ct_assisted') {
return { key: executorKey, label: '乐玩云', color: 'blue' }
return { key: executorKey, label: '乐玩云(已下线)', color: 'default' }
}
if (executorKey === 'kuaishou-industry') {
return { key: executorKey, label: '行业凭证', color: 'cyan' }
}
if (executorKey === 'kuaishou_feifei') {
return { key: executorKey, label: '飞飞', color: 'purple' }
return { key: executorKey, label: '飞飞(已下线)', color: 'default' }
}
if (executorKey === 'manual_dispatch') {
return { key: executorKey, label: '人工履约', color: 'orange' }
@@ -654,4 +625,4 @@ function cleanParams(params: Record<string, unknown>) {
}
})
return next
}
}
@@ -19,7 +19,6 @@ import {
Spin,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
@@ -29,51 +28,25 @@ import { useEffect, useMemo, useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import { showError, showSuccess } from '@/lib/feedback'
import {
fetchAdminCloudtentaclesOverrideRules,
fetchAdminCloudtentaclesSkuList,
fetchAdminCloudtentaclesSourceConfig,
fetchAdminFulfillmentRoutingConfig,
fetchAdminKuaishouFeifeiConfig,
matchAdminKuaishouFeifeiProduct,
previewAdminFulfillmentRouting,
saveAdminCloudtentaclesOverrideRules,
saveAdminFulfillmentRoutingConfig,
syncAdminKuaishouFeifeiProducts,
} from '@/services/admin'
import type {
AdminCloudtentaclesOverrideDeliveryItem,
AdminCloudtentaclesOverrideRule,
AdminCloudtentaclesSessionItem,
AdminCloudtentaclesSessionsMap,
AdminCloudtentaclesSkuItem,
AdminCloudtentaclesSourceItem,
AdminFulfillmentRoutingConfig,
AdminFulfillmentRoutingPreviewResult,
AdminFulfillmentRoutingRule,
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiProductRule,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
type EditableOverrideRule = Omit<AdminCloudtentaclesOverrideRule, 'normalizedProductName'> & {
normalizedProductName?: string
}
type EditableRoutingRule = Omit<AdminFulfillmentRoutingRule, 'normalizedProductName'> & {
normalizedProductName?: string
}
type SourceRow = AdminCloudtentaclesSourceItem & {
session: AdminCloudtentaclesSessionItem | null
ready: boolean
}
const ROUTING_EXECUTORS = [
{ key: 'kuaishou_ct_assisted', label: 'kuaishou-lewan', color: 'blue' },
{ key: 'kuaishou_feifei', label: 'kuaishou-feifei', color: 'purple' },
{ key: 'affiliate_dash', label: 'affiliate-dash', color: 'gold' },
{ key: 'manual_dispatch', label: '人工履约', color: 'orange' },
{ key: 'manual_dispatch', label: '人工履约', color: 'default' },
]
export default function AdminPlatformFulfillmentPage() {
@@ -81,7 +54,7 @@ export default function AdminPlatformFulfillmentPage() {
<section className="page-stack">
<PageHeader
title="履约配置"
description="维护 91 卡券商品到 kuaishou-feifei 与 kuaishou-lewan 的履约映射规则。"
description="维护 91 卡券商品到 affiliate-dash / 人工履约的路由规则。"
/>
{!hasAdminRole('admin') ? (
@@ -89,27 +62,7 @@ export default function AdminPlatformFulfillmentPage() {
<Empty description="仅管理员可以查看履约配置" />
</Card>
) : (
<Tabs
className="platform-tabs"
destroyOnHidden={false}
items={[
{
key: 'fulfillment-routing',
label: '履约路由',
children: <FulfillmentRoutingPanel />,
},
{
key: 'kuaishou-feifei',
label: 'kuaishou-feifei',
children: <KuaishouFeifeiFulfillmentPanel />,
},
{
key: 'kuaishou-cloud',
label: 'kuaishou-lewan',
children: <KuaishouCloudFulfillmentPanel />,
},
]}
/>
<FulfillmentRoutingPanel />
)}
</section>
)
@@ -121,7 +74,7 @@ function FulfillmentRoutingPanel() {
const [previewing, setPreviewing] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState<string[]>(['kuaishou_ct_assisted', 'kuaishou_feifei'])
const [priority, setPriority] = useState<string[]>(['affiliate_dash'])
const [unmatchedExecutorKey, setUnmatchedExecutorKey] = useState('')
const [executors, setExecutors] = useState<AdminFulfillmentRoutingConfig['executors']>({})
const [rules, setRules] = useState<EditableRoutingRule[]>([])
@@ -256,7 +209,7 @@ function FulfillmentRoutingPanel() {
productName,
normalizedProductName: '',
matchType: 'exact',
executorKey: 'kuaishou_feifei',
executorKey: 'affiliate_dash',
priority: 100,
notes: '',
},
@@ -502,747 +455,6 @@ function FulfillmentRoutingPanel() {
)
}
function KuaishouFeifeiFulfillmentPanel() {
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [matching, setMatching] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [rules, setRules] = useState<AdminKuaishouFeifeiProductRule[]>([])
const [matchInput, setMatchInput] = useState('')
const [matchResult, setMatchResult] = useState<AdminKuaishouFeifeiMatchResult | null>(null)
const [lastSyncText, setLastSyncText] = useState('')
const enabledRuleCount = rules.filter((rule) => rule.enabled !== false).length
const mappingCount = rules.filter(
(rule) => rule.productName.trim() && rule.productCode.trim(),
).length
useEffect(() => {
void loadConfig()
}, [])
async function loadConfig() {
setLoading(true)
setErrorMessage('')
try {
const response = await fetchAdminKuaishouFeifeiConfig()
hydrateConfig(response.data)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-feifei 履约映射失败')
} finally {
setLoading(false)
}
}
async function syncProducts() {
setSyncing(true)
setErrorMessage('')
try {
const response = await syncAdminKuaishouFeifeiProducts({ status: 'on_sale' })
hydrateConfig(response.data)
const text = `商品 ${response.data.sync.productCount} 个,映射 ${response.data.sync.ruleCount} 条`
setLastSyncText(text)
showSuccess(`kuaishou-feifei 商品映射已同步,${text}`)
} catch (error) {
const message = error instanceof Error ? error.message : '同步 kuaishou-feifei 商品映射失败'
setErrorMessage(message)
showError(message)
} finally {
setSyncing(false)
}
}
async function previewMatch() {
const productName = matchInput.trim()
if (!productName) {
showError('请输入 91 商品名')
return
}
setMatching(true)
setMatchResult(null)
try {
const response = await matchAdminKuaishouFeifeiProduct(productName)
setMatchResult(response.data)
} catch (error) {
showError(error instanceof Error ? error.message : '预览 kuaishou-feifei 映射失败')
} finally {
setMatching(false)
}
}
function hydrateConfig(data: AdminKuaishouFeifeiConfigResponse) {
setRules(
Array.isArray(data.source.productRules)
? data.source.productRules.map((rule) => normalizeFeifeiRule(rule))
: [],
)
}
const columns: TableColumnsType<AdminKuaishouFeifeiProductRule> = [
{
title: 'feifei 商品名',
dataIndex: 'productName',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong ellipsis>
{row.productName || row.skuName || '-'}
</Typography.Text>
<Typography.Text type="secondary" ellipsis>
{row.normalizedProductName || '-'}
</Typography.Text>
</div>
),
},
{ title: 'product_code', dataIndex: 'productCode', minWidth: 150 },
{ title: '展示名称', dataIndex: 'skuName', minWidth: 220 },
{
title: '状态',
width: 100,
render: (_, row) => (
<Tag color={row.enabled !== false ? 'green' : 'default'}>
{row.enabled !== false ? '启用' : '停用'}
</Tag>
),
},
{ title: '来源', dataIndex: 'notes', minWidth: 180 },
]
if (loading) {
return <Spin />
}
return (
<section className="platform-panel-stack">
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
<div className="metric-grid three">
<MetricCard
label="名字映射"
value={String(mappingCount)}
detail={`启用 ${enabledRuleCount} 条`}
/>
<MetricCard label="匹配方式" value="同名" detail="使用 91 商品名规范化匹配" />
<Card>
<Space wrap>
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadConfig}>
刷新
</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={syncing}
onClick={syncProducts}
>
同步商品映射
</Button>
</Space>
</Card>
</div>
<Card
title="商品名字映射"
extra={
<Typography.Text type="secondary">
{lastSyncText || 'feifei 商品名会按规范化名字匹配 91 商品名。'}
</Typography.Text>
}
>
<Space.Compact className="full-width">
<Input
allowClear
value={matchInput}
placeholder="91 商品名,例如 套装-Alan Walker"
onChange={(event) => setMatchInput(event.target.value)}
onPressEnter={previewMatch}
/>
<Button icon={<SearchOutlined />} loading={matching} onClick={previewMatch}>
预览命中
</Button>
</Space.Compact>
{matchResult ? (
<Alert
className="platform-section-gap"
type={matchResult.matched ? 'success' : 'warning'}
showIcon
message={matchResult.matched ? '已命中' : '未命中'}
description={
matchResult.match
? `${matchResult.match.productName} -> ${matchResult.match.productCode}`
: matchResult.normalizedProductName || matchResult.productName
}
/>
) : null}
<Table<AdminKuaishouFeifeiProductRule>
rowKey={(row) => row.id || `${row.productName}-${row.productCode}`}
columns={columns}
dataSource={rules}
pagination={buildAdminLocalTablePagination()}
scroll={{ x: 920, y: 520 }}
locale={{ emptyText: '暂无 kuaishou-feifei 商品映射' }}
className="platform-section-gap"
/>
</Card>
</section>
)
}
function KuaishouCloudFulfillmentPanel() {
const [loading, setLoading] = useState(true)
const [skuLoading, setSkuLoading] = useState(false)
const [overrideSaving, setOverrideSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [skuErrorMessage, setSkuErrorMessage] = useState('')
const [overrideErrorMessage, setOverrideErrorMessage] = useState('')
const [selectedSourceKey, setSelectedSourceKey] = useState('')
const [sources, setSources] = useState<AdminCloudtentaclesSourceItem[]>([])
const [sessions, setSessions] = useState<AdminCloudtentaclesSessionsMap>({})
const [skuItems, setSkuItems] = useState<AdminCloudtentaclesSkuItem[]>([])
const [overrideEnabled, setOverrideEnabled] = useState(true)
const [overrideRules, setOverrideRules] = useState<EditableOverrideRule[]>([])
const [matchInput, setMatchInput] = useState('')
const sourceRows = useMemo(
() =>
sources.map((source) => {
const session = sessions[source.key] || null
return {
...source,
session,
ready: source.enabled !== false && Boolean(session?.hasToken),
}
}),
[sessions, sources],
)
const readySources = sourceRows.filter((source) => source.ready)
const selectedSource = sourceRows.find((source) => source.key === selectedSourceKey) || null
const matchedSku = useMemo(() => {
const normalizedInput = normalizeMatchName(matchInput)
if (!normalizedInput) {
return null
}
return (
skuItems.find((item) => String(item.name || '').trim() === matchInput.trim()) ||
skuItems.find((item) => normalizeMatchName(item.name) === normalizedInput) ||
null
)
}, [matchInput, skuItems])
const metrics = {
sourceCount: sources.length,
readySourceCount: readySources.length,
skuCount: skuItems.length,
inventoryTotal: skuItems.reduce(
(sum, item) => sum + Math.max(0, Number(item.inventory || 0)),
0,
),
overrideRuleCount: overrideRules.length,
}
useEffect(() => {
void loadPage()
}, [])
useEffect(() => {
if (selectedSourceKey) {
void loadSkuList(selectedSourceKey)
} else {
setSkuItems([])
}
}, [selectedSourceKey])
async function loadPage() {
setLoading(true)
setErrorMessage('')
try {
const [sourceResponse, overrideResponse] = await Promise.all([
fetchAdminCloudtentaclesSourceConfig(),
fetchAdminCloudtentaclesOverrideRules(),
])
const nextSources = Array.isArray(sourceResponse.data.sources)
? sourceResponse.data.sources
: []
const nextSessions = sourceResponse.data.sessions || {}
const nextRows = nextSources.map((source) => {
const session = nextSessions[source.key] || null
return {
...source,
session,
ready: source.enabled !== false && Boolean(session?.hasToken),
}
})
setSources(nextSources)
setSessions(nextSessions)
setOverrideEnabled(overrideResponse.data.enabled !== false)
setOverrideRules(
Array.isArray(overrideResponse.data.rules)
? overrideResponse.data.rules.map((rule) => normalizeEditableRule(rule))
: [],
)
setSelectedSourceKey(resolveDefaultSourceKey(nextRows, selectedSourceKey))
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 履约配置失败')
} finally {
setLoading(false)
}
}
async function loadSkuList(sourceKey = selectedSourceKey) {
if (!sourceKey) {
setSkuItems([])
return
}
setSkuLoading(true)
setSkuErrorMessage('')
try {
const response = await fetchAdminCloudtentaclesSkuList({ sourceKey })
setSkuItems(Array.isArray(response.data.items) ? response.data.items : [])
} catch (error) {
setSkuItems([])
setSkuErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 商品失败')
} finally {
setSkuLoading(false)
}
}
async function saveOverrideRules() {
setOverrideSaving(true)
setOverrideErrorMessage('')
try {
const response = await saveAdminCloudtentaclesOverrideRules({
enabled: overrideEnabled,
rules: overrideRules.map((rule) => ({
id: rule.id,
enabled: rule.enabled,
productName: rule.productName,
sourceKey: rule.sourceKey,
deliveryItems: rule.deliveryItems,
notes: rule.notes,
})),
})
setOverrideEnabled(response.data.enabled !== false)
setOverrideRules(
Array.isArray(response.data.rules)
? response.data.rules.map((rule) => normalizeEditableRule(rule))
: [],
)
showSuccess(`商品覆盖规则已保存,共 ${response.data.rules.length} 条`)
} catch (error) {
const message = error instanceof Error ? error.message : '保存 kuaishou-lewan 覆盖规则失败'
setOverrideErrorMessage(message)
showError(message)
} finally {
setOverrideSaving(false)
}
}
function addOverrideRule() {
setOverrideRules((current) => [
{
id: createRuleId(),
enabled: true,
productName: matchInput.trim(),
normalizedProductName: normalizeMatchName(matchInput),
sourceKey: selectedSourceKey,
deliveryItems: [createDeliveryItem(skuItems)],
notes: '',
},
...current,
])
}
function updateRule(index: number, patch: Partial<EditableOverrideRule>) {
setOverrideRules((current) =>
current.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, ...patch } : rule)),
)
}
function updateDeliveryItem(
ruleIndex: number,
itemIndex: number,
patch: Partial<AdminCloudtentaclesOverrideDeliveryItem>,
) {
setOverrideRules((current) =>
current.map((rule, currentRuleIndex) => {
if (currentRuleIndex !== ruleIndex) {
return rule
}
return {
...rule,
deliveryItems: rule.deliveryItems.map((item, currentItemIndex) =>
currentItemIndex === itemIndex ? { ...item, ...patch } : item,
),
}
}),
)
}
function removeOverrideRule(index: number) {
setOverrideRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index))
}
function addDeliveryItem(ruleIndex: number) {
setOverrideRules((current) =>
current.map((rule, currentRuleIndex) =>
currentRuleIndex === ruleIndex
? { ...rule, deliveryItems: [...rule.deliveryItems, createDeliveryItem(skuItems)] }
: rule,
),
)
}
function removeDeliveryItem(ruleIndex: number, itemIndex: number) {
setOverrideRules((current) =>
current.map((rule, currentRuleIndex) => {
if (currentRuleIndex !== ruleIndex) {
return rule
}
const nextItems = rule.deliveryItems.filter(
(_, currentItemIndex) => currentItemIndex !== itemIndex,
)
return {
...rule,
deliveryItems: nextItems.length ? nextItems : [createDeliveryItem(skuItems)],
}
}),
)
}
function handleDeliverySkuChange(ruleIndex: number, itemIndex: number, skuId: number) {
const sku = skuItems.find((item) => Number(item.id || 0) === Number(skuId || 0))
updateDeliveryItem(ruleIndex, itemIndex, {
cloudSkuId: Number(skuId || 0) || 0,
cloudSkuName: String(sku?.name || '').trim(),
})
}
if (loading) {
return <Spin />
}
return (
<section className="platform-panel-stack">
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
<div className="metric-grid five">
<MetricCard
label="履约账号"
value={`${metrics.readySourceCount} / ${metrics.sourceCount}`}
/>
<MetricCard label="可发商品" value={String(metrics.skuCount)} />
<MetricCard label="总库存" value={String(metrics.inventoryTotal)} />
<MetricCard label="覆盖规则" value={String(metrics.overrideRuleCount)} />
<Card>
<Button icon={<ReloadOutlined />} loading={loading || skuLoading} onClick={loadPage}>
刷新
</Button>
</Card>
</div>
<div className="platform-split-layout">
<Card title="kuaishou-lewan 账号" className="platform-side-card">
{sourceRows.length === 0 ? (
<Empty description="暂无账号,请先到平台配置登录。" />
) : (
<Space direction="vertical" className="full-width">
{sourceRows.map((source) => (
<button
key={source.key}
type="button"
className={
source.key === selectedSourceKey
? 'platform-select-card active'
: 'platform-select-card'
}
onClick={() => setSelectedSourceKey(source.key)}
>
<span>
<strong>{formatSourceLabel(source)}</strong>
<small>{source.username || source.key}</small>
</span>
<Tag
color={source.ready ? 'green' : source.enabled === false ? 'default' : 'orange'}
>
{source.ready ? '可用' : source.enabled === false ? '已停用' : '未登录'}
</Tag>
</button>
))}
</Space>
)}
</Card>
<section className="platform-panel-stack">
<Card
title="匹配状态与覆盖规则"
extra={
<Space wrap>
<Typography.Text type="secondary">
当前账号:{selectedSource ? formatSourceLabel(selectedSource) : '-'}
</Typography.Text>
<Button
icon={<ReloadOutlined />}
loading={skuLoading}
disabled={!selectedSourceKey}
onClick={() => loadSkuList()}
>
刷新商品
</Button>
</Space>
}
>
{skuErrorMessage ? (
<Alert
type="error"
showIcon
message={skuErrorMessage}
className="platform-section-gap"
/>
) : null}
<Space className="full-width" direction="vertical">
<Space.Compact className="full-width">
<Input
allowClear
value={matchInput}
placeholder="输入 91 卡券 productNo / 商品名字,查看命中的 kuaishou-lewan 商品"
onChange={(event) => setMatchInput(event.target.value)}
/>
<Button icon={<PlusOutlined />} onClick={addOverrideRule}>
生成规则
</Button>
</Space.Compact>
{matchedSku ? (
<Tag color="green">
命中 #{matchedSku.id} {matchedSku.name}
</Tag>
) : matchInput.trim() ? (
<Tag color="orange">未命中</Tag>
) : null}
</Space>
</Card>
<Card
title="商品覆盖规则"
extra={
<Space wrap>
<Switch
checked={overrideEnabled}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={setOverrideEnabled}
/>
<Button icon={<PlusOutlined />} onClick={addOverrideRule}>
新增规则
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={overrideSaving}
onClick={saveOverrideRules}
>
保存规则
</Button>
</Space>
}
>
{overrideErrorMessage ? (
<Alert
type="error"
showIcon
message={overrideErrorMessage}
className="platform-section-gap"
/>
) : null}
{overrideRules.length === 0 ? (
<Empty description="暂无覆盖规则" />
) : (
<Space direction="vertical" className="full-width" size={12}>
{overrideRules.map((rule, ruleIndex) => (
<Card
key={rule.id}
size="small"
title={
<Space>
<Switch
size="small"
checked={rule.enabled !== false}
onChange={(enabled) => updateRule(ruleIndex, { enabled })}
/>
<span>{rule.productName || '未命名规则'}</span>
</Space>
}
extra={
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeOverrideRule(ruleIndex)}
>
删除
</Button>
}
>
<div className="platform-rule-head">
<Input
value={rule.productName}
placeholder="91 商品名 / productNo"
onChange={(event) =>
updateRule(ruleIndex, { productName: event.target.value })
}
/>
<Select
allowClear
value={rule.sourceKey || undefined}
placeholder="全部账号"
options={[
{ label: '全部账号', value: '' },
...sourceRows.map((source) => ({
label: formatSourceLabel(source),
value: source.key,
})),
]}
onChange={(sourceKey) => {
updateRule(ruleIndex, { sourceKey: String(sourceKey || '') })
if (sourceKey) {
setSelectedSourceKey(String(sourceKey))
}
}}
/>
</div>
<Space
direction="vertical"
className="full-width platform-section-gap"
size={8}
>
{rule.deliveryItems.map((item, itemIndex) => (
<div key={`${rule.id}-${itemIndex}`} className="platform-delivery-row">
<Select
showSearch
value={item.cloudSkuId || undefined}
placeholder="选择 kuaishou-lewan 商品"
optionFilterProp="label"
options={skuItems.map((sku) => ({
label: formatSkuOption(sku),
value: sku.id,
}))}
onChange={(skuId) =>
handleDeliverySkuChange(ruleIndex, itemIndex, Number(skuId || 0))
}
/>
<Input
value={item.cloudSkuName}
placeholder="kuaishou-lewan 商品名"
onChange={(event) =>
updateDeliveryItem(ruleIndex, itemIndex, {
cloudSkuName: event.target.value,
})
}
/>
<InputNumber
min={1}
max={999}
value={item.quantity}
onChange={(quantity) =>
updateDeliveryItem(ruleIndex, itemIndex, {
quantity: Math.max(1, Number(quantity || 1) || 1),
})
}
/>
<Button onClick={() => removeDeliveryItem(ruleIndex, itemIndex)}>
删除
</Button>
</div>
))}
<Button onClick={() => addDeliveryItem(ruleIndex)}>增加发货商品</Button>
</Space>
<Input
className="platform-section-gap"
value={rule.notes}
placeholder="备注"
onChange={(event) => updateRule(ruleIndex, { notes: event.target.value })}
/>
</Card>
))}
</Space>
)}
</Card>
<Card title="kuaishou-lewan 商品列表">
<Table<AdminCloudtentaclesSkuItem>
rowKey="id"
loading={skuLoading}
dataSource={skuItems}
columns={[
{
title: 'kuaishou-lewan 商品',
dataIndex: 'name',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong ellipsis>
{row.name || '-'}
</Typography.Text>
<Typography.Text type="secondary" ellipsis>
#{row.id} · {row.description || row.name || '-'}
</Typography.Text>
</div>
),
},
{
title: '91 自动匹配名',
dataIndex: 'name',
minWidth: 220,
render: (value) => <code>{String(value || '-')}</code>,
},
{ title: '价格', dataIndex: 'price', width: 120, render: formatCloudPrice },
{
title: '库存',
dataIndex: 'inventory',
width: 120,
sorter: (a, b) => a.inventory - b.inventory,
},
{
title: '发货限制',
width: 150,
render: (_, row) => `${row.buyLimitMin || 1} - ${row.buyLimitMax || 1}`,
},
{
title: '状态',
width: 120,
render: (_, row) => (
<Tag color={Number(row.inventory || 0) > 0 ? 'green' : 'red'}>
{Number(row.inventory || 0) > 0 ? '可履约' : '无库存'}
</Tag>
),
},
]}
pagination={buildAdminLocalTablePagination()}
scroll={{ x: 1000, y: 420 }}
locale={{ emptyText: '当前账号暂无可展示商品' }}
/>
</Card>
</section>
</div>
</section>
)
}
function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<Card className="metric-card">
@@ -1263,7 +475,7 @@ function normalizeRoutingPriority(value: unknown) {
.map((item) => String(item || '').trim())
.filter((item) => allowed.includes(item) && !seen.has(item) && (seen.add(item), true))
return items.length > 0 ? items : ['kuaishou_ct_assisted', 'kuaishou_feifei']
return items.length > 0 ? items : ['affiliate_dash']
}
function normalizeRoutingExecutors(value: unknown): AdminFulfillmentRoutingConfig['executors'] {
@@ -1293,7 +505,7 @@ function normalizeRoutingRule(rule: AdminFulfillmentRoutingRule): EditableRoutin
productName,
normalizedProductName: String(rule.normalizedProductName || '').trim(),
matchType: rule.matchType === 'contains' ? 'contains' : 'exact',
executorKey: executorKey || 'kuaishou_feifei',
executorKey: executorKey || 'affiliate_dash',
priority: Number(rule.priority || 0) || 0,
notes: String(rule.notes || '').trim(),
}
@@ -1309,87 +521,6 @@ function resolveRoutingExecutorColor(executorKey: unknown) {
return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.color || 'default'
}
function normalizeFeifeiRule(
rule: Partial<AdminKuaishouFeifeiProductRule>,
): AdminKuaishouFeifeiProductRule {
const productName = String(rule.productName || '').trim()
const productCode = String(rule.productCode || '').trim()
return {
id: String(rule.id || productName || productCode).trim(),
enabled: rule.enabled !== false,
productName,
normalizedProductName: String(rule.normalizedProductName || '').trim(),
productCode,
skuName: String(rule.skuName || productName || productCode).trim(),
notes: String(rule.notes || '').trim(),
}
}
function normalizeEditableRule(rule: AdminCloudtentaclesOverrideRule): EditableOverrideRule {
return {
id: String(rule.id || createRuleId()).trim(),
enabled: rule.enabled !== false,
productName: String(rule.productName || '').trim(),
normalizedProductName: String(rule.normalizedProductName || '').trim(),
sourceKey: String(rule.sourceKey || '').trim(),
deliveryItems:
Array.isArray(rule.deliveryItems) && rule.deliveryItems.length > 0
? rule.deliveryItems.map((item) => ({
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
cloudSkuName: String(item.cloudSkuName || '').trim(),
quantity: Math.max(1, Number(item.quantity || 1) || 1),
}))
: [createDeliveryItem([])],
notes: String(rule.notes || '').trim(),
}
}
function createDeliveryItem(
skuItems: AdminCloudtentaclesSkuItem[],
): AdminCloudtentaclesOverrideDeliveryItem {
const firstSku = skuItems[0]
return {
cloudSkuId: Number(firstSku?.id || 0) || 0,
cloudSkuName: String(firstSku?.name || '').trim(),
quantity: 1,
}
}
function resolveDefaultSourceKey(sourceRows: SourceRow[], selectedSourceKey: string) {
const current = sourceRows.find((source) => source.key === selectedSourceKey)
if (current?.ready) {
return current.key
}
return sourceRows.find((source) => source.ready)?.key || sourceRows[0]?.key || ''
}
function normalizeMatchName(value: unknown) {
return String(value || '')
.toLowerCase()
.replace(/[【】[\]()()]/g, ' ')
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function formatSourceLabel(
source: Pick<AdminCloudtentaclesSourceItem, 'key' | 'label' | 'username'>,
) {
return source.label || source.username || source.key
}
function formatCloudPrice(value: unknown) {
const price = Number(value)
return Number.isFinite(price) ? String(price) : '-'
}
function formatSkuOption(sku: AdminCloudtentaclesSkuItem) {
return `#${sku.id} ${sku.name}`
}
function createRuleId() {
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
File diff suppressed because it is too large Load Diff