新增:半自动履约工作台与虎牙客户授权流程

- 新增 153 半自动履约迁移、后台任务接口与客户履约接口
- 接入虎牙 Connector 登录会话、绑定与一次性 CK 注入票据
- 新增后台半自动履约页面和客户履约入口
- 完善手工履约配置、成本确认事务与运行时连接器配置
- 同步权威文档、环境示例、部署脚本和迁移展示逻辑
This commit is contained in:
yml2213
2026-11-09 15:29:20 +08:00
parent 4159a435d5
commit cb9538a775
38 changed files with 3697 additions and 30 deletions
+24
View File
@@ -12,6 +12,7 @@ import {
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
import { isCustomerFulfillmentPath } from '@/utils/customer-fulfillment-link'
import {
isLegacySupplementPath,
isOrderServicePath,
@@ -42,12 +43,16 @@ const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPlatformShopsPage'))
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
const AdminSemiAutoTasksPage = lazy(() => import('@/pages/admin/AdminSemiAutoTasksPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage'))
const OrderServicePage = lazy(() => import('@/pages/public/OrderServicePage'))
const CustomerFulfillmentPage = lazy(
() => import('@/pages/customer-fulfillment/CustomerFulfillmentPage'),
)
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
@@ -131,6 +136,10 @@ export default function App() {
return <OrderServiceApplication />
}
if (isCustomerFulfillmentPath(window.location.pathname)) {
return <CustomerFulfillmentApplication />
}
// 旧 /supplement 短期兼容:统一跳到 /o。
if (isLegacySupplementPath(window.location.pathname)) {
return <CollectRedirectApplication />
@@ -158,6 +167,20 @@ function OrderServiceApplication() {
)
}
function CustomerFulfillmentApplication() {
useClearStrayHash()
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/c/:orderNo" element={<CustomerFulfillmentPage />} />
<Route path="*" element={<CustomerFulfillmentPage />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
function CollectRedirectApplication() {
useEffect(() => {
const orderNo = resolveLegacySupplementOrderNo(window.location.pathname)
@@ -180,6 +203,7 @@ function AdminApplication() {
<Route path="dashboard" element={<AdminDashboardPage />} />
<Route path="tasks" element={<AdminTasksPage />} />
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
<Route path="semi-auto-tasks" element={<AdminSemiAutoTasksPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
<Route path="orders" element={<AdminOrdersPage />} />
+11
View File
@@ -12,6 +12,7 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
OrderedListOutlined,
RobotOutlined,
SafetyCertificateOutlined,
SettingOutlined,
SoundOutlined,
@@ -229,6 +230,11 @@ export default function AdminLayout() {
label: '我的钱包',
},
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
{
key: '/admin/semi-auto-tasks',
icon: <RobotOutlined />,
label: '半自动履约',
},
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
{
key: '/admin/kuaishou-industry',
@@ -246,6 +252,11 @@ export default function AdminLayout() {
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
financeSubMenu,
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
{
key: '/admin/semi-auto-tasks',
icon: <RobotOutlined />,
label: '半自动履约',
},
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
{
key: '/admin/kuaishou-industry',
@@ -0,0 +1,585 @@
import {
CheckCircleOutlined,
ChromeOutlined,
CopyOutlined,
ReloadOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
import {
App,
Button,
Card,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Tag,
Timeline,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import ImageUpload from '@/components/files/ImageUpload'
import PageHeader from '@/components/admin/PageHeader'
import PercentTable from '@/components/common/PercentTable'
import { uploadFile } from '@/services/files'
import {
cancelAdminSemiAutoTask,
claimAdminSemiAutoTask,
completeAdminSemiAutoTask,
createAdminSemiAutoBrowserSession,
fetchAdminSemiAutoTask,
fetchAdminSemiAutoTasks,
syncAdminSemiAutoTask,
} from '@/services/admin/semi-auto'
import type { SemiAutoTask } from '@/types/admin/semi-auto'
import type { UploadedFile } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
const { Text } = Typography
const STATUS_COLORS: Record<string, string> = {
waiting_customer: 'blue',
waiting_login: 'blue',
login_scanned: 'cyan',
waiting_binding: 'gold',
binding_confirm: 'orange',
waiting_operator: 'volcano',
processing: 'processing',
completed: 'success',
failed: 'error',
cancelled: 'default',
}
const PLATFORM_LABELS: Record<string, string> = {
huya: '虎牙',
weibo: '微博',
}
type CompleteFormValues = {
note?: string
externalOrderNo?: string
costYuan?: number
}
export default function AdminSemiAutoTasksPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [keyword, setKeyword] = useState('')
const [platform, setPlatform] = useState('')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [detailTaskId, setDetailTaskId] = useState<number | null>(null)
const [completeTask, setCompleteTask] = useState<SemiAutoTask | null>(null)
const [completeFiles, setCompleteFiles] = useState<UploadedFile[]>([])
const [completeForm] = Form.useForm<CompleteFormValues>()
const params = useMemo(
() => ({
page,
pageSize,
keyword: keyword.trim() || undefined,
platform: platform || undefined,
status: status || undefined,
}),
[keyword, page, pageSize, platform, status],
)
const listQuery = useQuery({
queryKey: ['admin-semi-auto-tasks', params],
queryFn: () => fetchAdminSemiAutoTasks(params),
refetchInterval: 3000,
})
const detailQuery = useQuery({
queryKey: ['admin-semi-auto-task', detailTaskId],
queryFn: () => fetchAdminSemiAutoTask(Number(detailTaskId)),
enabled: Boolean(detailTaskId),
refetchInterval: detailTaskId ? 3000 : false,
})
const list = listQuery.data?.data?.items || []
const pagination = listQuery.data?.data?.pagination
const detail = detailQuery.data?.data
useEffect(() => {
if (detailTaskId && listQuery.data && !list.some((item) => item.id === detailTaskId)) {
setDetailTaskId(null)
}
}, [detailTaskId, list, listQuery.data])
useEffect(() => {
const handleResult = (event: Event) => {
const detail = (event as CustomEvent<string>).detail
if (typeof detail !== 'string') return
try {
const result = JSON.parse(detail) as {
ok?: boolean
written?: number
error?: string
}
if (!result || typeof result !== 'object') return
if (result.ok) {
message.success(`浏览器登录态已注入(${Number(result.written || 0)} 个 Cookie)`)
} else {
message.error(result.error || '浏览器登录态注入失败')
}
} catch {
// 忽略其他页面事件
}
}
window.addEventListener('huya-exchange-result', handleResult)
return () => window.removeEventListener('huya-exchange-result', handleResult)
}, [message])
const syncMutation = useMutation({
mutationFn: (taskId: number) => syncAdminSemiAutoTask(taskId),
onSuccess: () => {
message.success('状态已刷新')
void refreshAll()
},
onError: (error) => message.error(error instanceof Error ? error.message : '刷新失败'),
})
const claimMutation = useMutation({
mutationFn: (taskId: number) => claimAdminSemiAutoTask(taskId),
onSuccess: () => {
message.success('任务已领取')
void refreshAll()
},
onError: (error) => message.error(error instanceof Error ? error.message : '领取失败'),
})
const injectionMutation = useMutation({
mutationFn: (task: SemiAutoTask) =>
createAdminSemiAutoBrowserSession(task.id, firstTargetUrl(task)),
onSuccess: (response) => {
const ticket = String(response.data?.ticket || '').trim()
if (!ticket) {
message.error('注入票据生成失败')
return
}
window.postMessage({ type: 'ORDER_SITE_SEMI_AUTO_INJECT', ticket }, window.location.origin)
message.info('正在通过虎牙履约助手注入登录态,请稍候')
void refreshAll()
},
onError: (error) =>
message.error(error instanceof Error ? error.message : '打开浏览器登录态失败'),
})
const completeMutation = useMutation({
mutationFn: async (values: CompleteFormValues) => {
if (!completeTask) throw new Error('任务不存在')
const costFen =
values.costYuan !== undefined && values.costYuan !== null
? Math.round(Number(values.costYuan) * 100)
: undefined
return completeAdminSemiAutoTask(completeTask.id, {
note: values.note || '',
externalOrderNo: values.externalOrderNo || '',
...(costFen !== undefined ? { costFen } : {}),
screenshots: completeFiles.map((file) => ({ ...file })),
})
},
onSuccess: () => {
message.success('半自动履约已完成')
setCompleteTask(null)
setCompleteFiles([])
completeForm.resetFields()
void refreshAll()
},
onError: (error) => message.error(error instanceof Error ? error.message : '完成履约失败'),
})
const cancelMutation = useMutation({
mutationFn: ({ taskId, reason }: { taskId: number; reason: string }) =>
cancelAdminSemiAutoTask(taskId, reason),
onSuccess: () => {
message.success('任务已取消')
void refreshAll()
},
onError: (error) => message.error(error instanceof Error ? error.message : '取消失败'),
})
async function refreshAll() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-semi-auto-tasks'] }),
detailTaskId
? queryClient.invalidateQueries({ queryKey: ['admin-semi-auto-task', detailTaskId] })
: Promise.resolve(),
])
}
function copyCustomerLink(task: SemiAutoTask) {
const url = `${window.location.origin}/c/${encodeURIComponent(task.orderNo)}`
void navigator.clipboard
.writeText(url)
.then(() => message.success('客户授权链接已复制'))
.catch(() => message.error('复制失败,请手动复制'))
}
function openComplete(task: SemiAutoTask) {
setCompleteTask(task)
setCompleteFiles([])
completeForm.resetFields()
}
function confirmCancel(task: SemiAutoTask) {
let reason = ''
modal.confirm({
title: '取消半自动履约任务',
content: (
<Input.TextArea
rows={3}
placeholder="请填写取消原因(可选)"
onChange={(event) => {
reason = event.target.value
}}
/>
),
okText: '确认取消',
okButtonProps: { danger: true },
cancelText: '返回',
onOk: () => cancelMutation.mutateAsync({ taskId: task.id, reason }),
})
}
const columns: TableColumnsType<SemiAutoTask> = [
{
title: '订单 / 商品',
key: 'order',
width: 260,
render: (_, task) => (
<Space direction="vertical" size={2}>
<Text strong>{task.orderNo}</Text>
<Text>{task.productName || task.skuName || task.skuCode}</Text>
<Text type="secondary">SKU:{task.skuCode || '-'}</Text>
</Space>
),
},
{
title: '平台',
dataIndex: 'platform',
width: 90,
render: (value: string) => <Tag color="purple">{PLATFORM_LABELS[value] || value}</Tag>,
},
{
title: '客户登录',
key: 'login',
width: 150,
render: (_, task) => (
<Space direction="vertical" size={2}>
<Tag>{task.displayStatus.label}</Tag>
<Text type="secondary">{task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'}</Text>
</Space>
),
},
{
title: '绑定角色',
key: 'binding',
width: 190,
render: (_, task) =>
task.bindGameRole ? (
<Space direction="vertical" size={2}>
<Text strong>{task.bindGameRole}</Text>
<Text type="secondary">
{[task.bindGameAccount, task.bindGameChannel].filter(Boolean).join(' · ') || '-'}
</Text>
</Space>
) : (
<Text type="secondary">未绑定</Text>
),
},
{
title: '客服',
dataIndex: 'assignedAdminName',
width: 110,
render: (value: string) => value || '未领取',
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 155,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 360,
render: (_, task) => (
<Space wrap>
<Button size="small" onClick={() => setDetailTaskId(task.id)}>
详情
</Button>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyCustomerLink(task)}>
客户链接
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
loading={syncMutation.isPending}
onClick={() => syncMutation.mutate(task.id)}
>
刷新
</Button>
{!task.assignedAdminUserId ? (
<Button
size="small"
icon={<UserSwitchOutlined />}
loading={claimMutation.isPending}
onClick={() => claimMutation.mutate(task.id)}
>
领取
</Button>
) : null}
<Button
size="small"
icon={<ChromeOutlined />}
disabled={task.bindingStatus !== 'confirmed'}
loading={injectionMutation.isPending}
onClick={() => injectionMutation.mutate(task)}
>
注入浏览器
</Button>
{task.executionStatus !== 'completed' && task.executionStatus !== 'cancelled' ? (
<>
<Button
size="small"
type="primary"
icon={<CheckCircleOutlined />}
disabled={task.bindingStatus !== 'confirmed'}
onClick={() => openComplete(task)}
>
完成
</Button>
<Button size="small" danger onClick={() => confirmCancel(task)}>
取消
</Button>
</>
) : null}
</Space>
),
},
]
return (
<section className="page-stack">
<PageHeader
title="半自动履约"
description="人工客服渠道下的平台账号授权、扫码绑定和浏览器辅助履约。"
/>
<Card size="small">
<Space wrap style={{ marginBottom: 16 }}>
<Input.Search
allowClear
placeholder="订单号 / 商品 / UID / 角色"
value={keyword}
onChange={(event) => {
setKeyword(event.target.value)
setPage(1)
}}
style={{ width: 280 }}
/>
<Select
allowClear
placeholder="全部平台"
value={platform || undefined}
onChange={(value) => {
setPlatform(value || '')
setPage(1)
}}
options={[
{ value: 'huya', label: '虎牙' },
{ value: 'weibo', label: '微博' },
]}
style={{ width: 130 }}
/>
<Select
allowClear
placeholder="全部状态"
value={status || undefined}
onChange={(value) => {
setStatus(value || '')
setPage(1)
}}
options={[
{ value: 'waiting_customer', label: '等待客户' },
{ value: 'waiting_operator', label: '等待客服' },
{ value: 'processing', label: '处理中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]}
style={{ width: 130 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => void listQuery.refetch()}>
刷新
</Button>
</Space>
<PercentTable<SemiAutoTask>
rowKey="id"
loading={listQuery.isLoading}
columns={columns}
dataSource={list}
scroll={{ x: 1400 }}
pagination={{
current: pagination?.page || page,
pageSize: pagination?.pageSize || pageSize,
total: pagination?.total || 0,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Card>
<Drawer
open={Boolean(detailTaskId)}
width={720}
title={detail?.task ? `半自动履约 · ${detail.task.orderNo}` : '半自动履约详情'}
onClose={() => setDetailTaskId(null)}
>
{detailQuery.isLoading ? (
<Text type="secondary">加载中...</Text>
) : detail?.task ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="商品" span={2}>
{detail.task.productName}
</Descriptions.Item>
<Descriptions.Item label="平台">
{PLATFORM_LABELS[detail.task.platform] || detail.task.platform}
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<Tag color={STATUS_COLORS[detail.task.displayStatus.key] || 'default'}>
{detail.task.displayStatus.label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="虎牙 UID">{detail.task.huyaUid || '-'}</Descriptions.Item>
<Descriptions.Item label="平台昵称">
{detail.task.platformNickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="游戏账号">
{detail.task.bindGameAccount || '-'}
</Descriptions.Item>
<Descriptions.Item label="游戏角色">
{detail.task.bindGameRole || '-'}
</Descriptions.Item>
<Descriptions.Item label="区服" span={2}>
{detail.task.bindGameChannel || '-'}
</Descriptions.Item>
<Descriptions.Item label="客服">
{detail.task.assignedAdminName || '未领取'}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatAdminDateTime(detail.task.updatedAt)}
</Descriptions.Item>
</Descriptions>
{detail.task.lastError ? (
<Card size="small" title="最近错误">
<Text type="danger">{detail.task.lastError}</Text>
</Card>
) : null}
<Card size="small" title="操作">
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => copyCustomerLink(detail.task)}>
复制客户链接
</Button>
<Button
icon={<ReloadOutlined />}
loading={syncMutation.isPending}
onClick={() => syncMutation.mutate(detail.task.id)}
>
刷新客户状态
</Button>
<Button
icon={<ChromeOutlined />}
disabled={detail.task.bindingStatus !== 'confirmed'}
loading={injectionMutation.isPending}
onClick={() => injectionMutation.mutate(detail.task)}
>
注入浏览器
</Button>
</Space>
</Card>
<Card size="small" title="时间线">
<Timeline
items={(detail.events || []).map((event) => ({
color: event.eventType.includes('failed') ? 'red' : 'blue',
children: (
<Space direction="vertical" size={2}>
<Text strong>{event.eventType}</Text>
<Text type="secondary">
{formatAdminDateTime(event.createdAt)} · {event.actorType}
{event.actorId ? ` ${event.actorId}` : ''}
</Text>
</Space>
),
}))}
/>
</Card>
</Space>
) : (
<Text type="secondary">暂无任务详情</Text>
)}
</Drawer>
<Modal
open={Boolean(completeTask)}
title="确认完成半自动履约"
okText="确认完成"
cancelText="取消"
confirmLoading={completeMutation.isPending}
onCancel={() => {
setCompleteTask(null)
setCompleteFiles([])
completeForm.resetFields()
}}
onOk={() => {
void completeForm.validateFields().then((values) => completeMutation.mutate(values))
}}
>
<Form<CompleteFormValues> form={completeForm} layout="vertical">
<Form.Item name="externalOrderNo" label="外部订单号 / 流水号(可选)">
<Input maxLength={200} />
</Form.Item>
<Form.Item name="note" label="备注(可选)">
<Input.TextArea rows={3} maxLength={1000} showCount />
</Form.Item>
<Form.Item
name="costYuan"
label="本次实际成本(元,可选)"
extra="成本不固定的 SKU 可在这里填写;固定成本不需要填写。"
>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="完成截图(可选)">
<ImageUpload
value={completeFiles}
onChange={setCompleteFiles}
scene="semi-auto-evidence"
scope="admin"
maxCount={6}
showPasteHint
captureGlobalPaste
/>
</Form.Item>
</Form>
</Modal>
</section>
)
}
function firstTargetUrl(task: SemiAutoTask) {
const targets = Array.isArray(task.semiAutoConfig?.browserTargets)
? task.semiAutoConfig.browserTargets
: []
return String(targets.find((item) => item?.url)?.url || '')
}
@@ -31,6 +31,7 @@ import {
Row,
Select,
Space,
Switch,
Statistic,
Tag,
Typography,
@@ -65,6 +66,14 @@ type ConfigFormValues = {
creditQuantity: number
}>
note?: string
semiAutoEnabled?: boolean
semiAutoPlatform?: '' | 'huya' | 'weibo'
semiAutoConfig?: {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
}
}
const PLATFORM_TAG_COLORS: Record<string, string> = {
@@ -142,6 +151,9 @@ export function ManualSkuConfigPanel() {
creditQuantity: Number(channel.creditQuantity || 0),
})),
note: values.note || '',
semiAutoEnabled: values.semiAutoEnabled === true,
semiAutoPlatform: values.semiAutoEnabled ? values.semiAutoPlatform || '' : '',
semiAutoConfig: values.semiAutoConfig || {},
}),
onSuccess: (response) => {
const summary = response.data.syncSummary
@@ -201,6 +213,9 @@ export function ManualSkuConfigPanel() {
creditQuantity: channel.creditQuantity,
})),
note: item.config?.note || '',
semiAutoEnabled: item.config?.semiAutoEnabled === true,
semiAutoPlatform: item.config?.semiAutoPlatform || '',
semiAutoConfig: item.config?.semiAutoConfig || {},
})
setModalOpen(true)
}
@@ -212,6 +227,9 @@ export function ManualSkuConfigPanel() {
skuKey: skuKey.trim(),
channels: [{ platformKey: '', creditQuantity: 0 }],
note: '',
semiAutoEnabled: false,
semiAutoPlatform: '',
semiAutoConfig: {},
})
setModalOpen(true)
}
@@ -636,6 +654,54 @@ export function ManualSkuConfigPanel() {
</Space>
)}
</Form.List>
<Card size="small" title="半自动履约(可选)" style={{ marginTop: 14 }}>
<Form.Item
name="semiAutoEnabled"
label="启用半自动履约"
valuePropName="checked"
extra="订单进入人工渠道后,客户可通过 /c/订单号 登录平台并完成绑定。"
>
<Switch />
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() =>
form.getFieldValue('semiAutoEnabled') ? (
<>
<Form.Item
name="semiAutoPlatform"
label="目标平台"
rules={[{ required: true, message: '请选择目标平台' }]}
>
<Select
options={[
{ value: 'huya', label: '虎牙' },
{ value: 'weibo', label: '微博(预留)' },
]}
/>
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name={['semiAutoConfig', 'pageTitle']} label="客户页标题">
<Input maxLength={60} placeholder="例如:虎牙账号授权" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name={['semiAutoConfig', 'pageDescription']} label="客户页说明">
<Input maxLength={500} placeholder="展示在客户链接顶部" />
</Form.Item>
</Col>
</Row>
<Form.Item name={['semiAutoConfig', 'loginInstructions']} label="登录指引">
<Input.TextArea rows={2} maxLength={1000} showCount />
</Form.Item>
<Form.Item name={['semiAutoConfig', 'bindingInstructions']} label="绑定指引">
<Input.TextArea rows={2} maxLength={1000} showCount />
</Form.Item>
</>
) : null
}
</Form.Item>
</Card>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
@@ -0,0 +1,425 @@
import {
CheckCircleFilled,
LoadingOutlined,
QrcodeOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Col,
Image,
Result,
Row,
Space,
Spin,
Steps,
Tag,
Typography,
} from 'antd'
import QRCode from 'qrcode'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router'
import {
confirmCustomerBinding,
fetchCustomerFulfillment,
markCustomerFulfillmentOpened,
pollCustomerBinding,
pollCustomerLogin,
startCustomerBinding,
startCustomerLogin,
} from '@/services/customer-fulfillment'
import type {
CustomerBindingInfo,
CustomerLoginInfo,
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
import { formatAdminDateTime } from '@/utils/admin-time'
const { Text, Title, Paragraph } = Typography
const POLL_INTERVAL_MS = 2000
export default function CustomerFulfillmentPage() {
const { message } = App.useApp()
const { orderNo: routeOrderNo } = useParams<{ orderNo?: string }>()
const orderNo = String(routeOrderNo || '').trim()
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [tasks, setTasks] = useState<CustomerSemiAutoTask[]>([])
const [selectedTaskId, setSelectedTaskId] = useState(0)
const [loginInfo, setLoginInfo] = useState<CustomerLoginInfo | null>(null)
const [bindingInfo, setBindingInfo] = useState<CustomerBindingInfo | null>(null)
const [loginQrImage, setLoginQrImage] = useState('')
const startingRef = useRef(false)
const selectedTask = useMemo(
() => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null,
[selectedTaskId, tasks],
)
const load = useCallback(
async (silent = false) => {
if (!orderNo) {
setLoadError('订单号无效,请确认订单号是否正确')
setLoading(false)
return
}
if (!silent) setLoading(true)
try {
const response = await fetchCustomerFulfillment(orderNo)
const nextTasks = response.data || []
setTasks(nextTasks)
setLoadError('')
setSelectedTaskId((current) => {
if (nextTasks.some((task) => task.id === current)) return current
return nextTasks[0]?.id || 0
})
} catch (error) {
if (!silent) {
setTasks([])
setLoadError(error instanceof Error ? error.message : '读取订单履约信息失败')
}
} finally {
if (!silent) setLoading(false)
}
},
[orderNo],
)
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (!selectedTask) return
void markCustomerFulfillmentOpened(orderNo, selectedTask.id).catch(() => undefined)
}, [orderNo, selectedTask?.id])
useEffect(() => {
if (!loginInfo?.qrContent) {
setLoginQrImage('')
return
}
void QRCode.toDataURL(loginInfo.qrContent, {
width: 300,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
}).then(setLoginQrImage)
}, [loginInfo?.qrContent])
useEffect(() => {
if (!selectedTask || !orderNo) return
if (!['qr_pending', 'scanned'].includes(selectedTask.loginStatus)) return
const tick = async () => {
try {
const response = await pollCustomerLogin(orderNo, selectedTask.id)
if (response.data.task) {
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
}
if (response.data.login) setLoginInfo(response.data.login)
} catch {
// 短暂轮询失败不打断客户页面,下一轮继续。
}
}
const timer = window.setInterval(() => {
void tick()
}, POLL_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [orderNo, selectedTask?.id, selectedTask?.loginStatus])
useEffect(() => {
if (!selectedTask || !orderNo) return
if (
selectedTask.loginStatus !== 'authenticated' ||
selectedTask.bindingStatus === 'confirmed'
) {
return
}
if (selectedTask.bindingStatus === 'not_started' && !startingRef.current) {
startingRef.current = true
void startCustomerBinding(orderNo, selectedTask.id)
.then((response) => {
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setBindingInfo(response.data.binding)
})
.catch((error) => {
message.error(error instanceof Error ? error.message : '创建绑定二维码失败')
})
.finally(() => {
startingRef.current = false
})
return
}
if (!['qr_pending', 'scanned'].includes(selectedTask.bindingStatus)) return
const tick = async () => {
try {
const response = await pollCustomerBinding(orderNo, selectedTask.id)
if (response.data.task) {
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
}
if (response.data.binding) setBindingInfo(response.data.binding)
} catch {
// 同上,下一轮继续。
}
}
const timer = window.setInterval(() => {
void tick()
}, POLL_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [message, orderNo, selectedTask?.bindingStatus, selectedTask?.id, selectedTask?.loginStatus])
async function handleStartLogin() {
if (!selectedTask) return
setLoading(true)
try {
const response = await startCustomerLogin(orderNo, selectedTask.id)
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setLoginInfo(response.data.login)
setBindingInfo(null)
} catch (error) {
message.error(error instanceof Error ? error.message : '创建登录二维码失败')
} finally {
setLoading(false)
}
}
async function handleConfirmBinding() {
if (!selectedTask) return
try {
const response = await confirmCustomerBinding(orderNo, selectedTask.id)
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setBindingInfo(response.data.binding)
message.success('绑定已确认,等待客服处理')
} catch (error) {
message.error(error instanceof Error ? error.message : '确认绑定失败')
}
}
if (loading) {
return (
<div className="customer-fulfillment-page customer-fulfillment-centered">
<Spin indicator={<LoadingOutlined spin />} tip="正在读取订单履约信息..." />
</div>
)
}
if (loadError || tasks.length === 0) {
return (
<div className="customer-fulfillment-page customer-fulfillment-centered">
<Result
status="warning"
title="暂时无法打开订单"
subTitle={loadError || '该订单没有需要客户授权或绑定的半自动履约任务。'}
/>
</div>
)
}
if (selectedTask?.executionStatus === 'completed') {
return (
<div className="customer-fulfillment-page">
<Result
status="success"
icon={<CheckCircleFilled />}
title="履约已完成"
subTitle={
selectedTask.completionNote
? selectedTask.completionNote
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。'
}
extra={
selectedTask.completedAt
? `完成时间:${formatAdminDateTime(selectedTask.completedAt)}`
: undefined
}
/>
</div>
)
}
const config = selectedTask?.semiAutoConfig || {}
const title = config.pageTitle || '账号授权与角色绑定'
const description =
config.pageDescription || '请按页面指引完成平台登录和游戏角色绑定,客服随后会处理您的订单。'
const currentStep = resolveStep(selectedTask, bindingInfo)
const loginQrVisible =
selectedTask && ['qr_pending', 'scanned'].includes(selectedTask.loginStatus)
const bindingQrVisible =
selectedTask?.loginStatus === 'authenticated' &&
['qr_pending', 'scanned', 'role_ready'].includes(selectedTask.bindingStatus)
return (
<div className="customer-fulfillment-page">
<Space direction="vertical" size={18} style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 4 }}>
{title}
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{description}
</Paragraph>
</div>
{tasks.length > 1 ? (
<Card size="small" title="请选择需要处理的产品">
<Space wrap>
{tasks.map((task) => (
<Button
key={task.id}
type={task.id === selectedTask?.id ? 'primary' : 'default'}
onClick={() => {
setSelectedTaskId(task.id)
setLoginInfo(null)
setBindingInfo(null)
}}
>
{task.productName || task.skuName} × {task.quantity}
</Button>
))}
</Space>
</Card>
) : null}
<Card>
<Row gutter={[24, 20]}>
<Col xs={24} lg={8}>
<Space direction="vertical" size={10}>
<Text type="secondary">订单号</Text>
<Text strong style={{ fontSize: 18 }}>
{selectedTask?.orderNo}
</Text>
<Text type="secondary">商品</Text>
<Text strong>{selectedTask?.productName || selectedTask?.skuName}</Text>
<Tag color="purple">{selectedTask?.platformLabel || selectedTask?.platform}</Tag>
<Tag color="blue">{selectedTask?.displayStatus.label}</Tag>
</Space>
</Col>
<Col xs={24} lg={16}>
<Steps
current={currentStep}
items={[
{ title: '登录平台', icon: <QrcodeOutlined /> },
{ title: '扫码绑定', icon: <SafetyCertificateOutlined /> },
{ title: '客服处理', icon: <LoadingOutlined /> },
{ title: '完成' },
]}
/>
</Col>
</Row>
</Card>
{selectedTask && selectedTask.executionStatus === 'failed' ? (
<Alert
type="error"
showIcon
message="当前流程需要客服处理"
description={selectedTask.displayStatus.label}
/>
) : null}
{selectedTask?.loginStatus === 'waiting_login' ||
selectedTask?.loginStatus === 'failed' ||
selectedTask?.loginStatus === 'expired' ? (
<Card title="第一步:登录平台账号">
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="请使用本人账号登录"
description={
config.loginInstructions ||
'点击下方按钮后使用虎牙 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
}
/>
<Button type="primary" size="large" onClick={() => void handleStartLogin()}>
获取登录二维码
</Button>
</Space>
</Card>
) : null}
{loginQrVisible ? (
<Card title="使用虎牙 App 扫码登录">
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
{loginQrImage ? (
<Image src={loginQrImage} alt="虎牙登录二维码" width={260} preview={false} />
) : (
<Spin tip="生成二维码..." />
)}
<Text type="secondary">{loginInfo?.message || '等待扫码'}</Text>
{selectedTask.loginStatus === 'scanned' ? (
<Tag color="cyan">已扫码,请在 App 确认</Tag>
) : null}
<Button onClick={() => void handleStartLogin()}>重新获取二维码</Button>
</Space>
</Card>
) : null}
{bindingQrVisible ? (
<Card title="第二步:扫码绑定游戏角色">
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
{bindingInfo?.qrImage ? (
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={260} />
) : (
<Spin tip="生成绑定二维码..." />
)}
<Text type="secondary">
{bindingInfo?.message ||
config.bindingInstructions ||
'请使用虎牙 App 扫码完成游戏角色绑定。'}
</Text>
{bindingInfo?.bindGameRole ? (
<Alert
type="success"
showIcon
message={`已识别角色:${bindingInfo.bindGameRole}`}
description={[bindingInfo.bindGameAccount, bindingInfo.bindGameChannel]
.filter(Boolean)
.join(' · ')}
/>
) : null}
{selectedTask.bindingStatus === 'role_ready' ? (
<Button type="primary" onClick={() => void handleConfirmBinding()}>
确认绑定角色
</Button>
) : null}
</Space>
</Card>
) : null}
{selectedTask?.bindingStatus === 'confirmed' &&
selectedTask.executionStatus !== 'completed' ? (
<Card>
<Result
status="info"
title="账号已绑定,客服正在处理"
subTitle="客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。"
/>
</Card>
) : null}
</Space>
</div>
)
}
function resolveStep(task: CustomerSemiAutoTask | null, binding: CustomerBindingInfo | null) {
if (!task) return 0
if (task.executionStatus === 'completed') return 3
if (task.bindingStatus === 'confirmed') return 2
if (task.loginStatus === 'authenticated' || binding) return 1
return 0
}
@@ -362,6 +362,9 @@ export function saveAdminManualConfig(payload: {
canonicalProductId?: number | null
enabled?: boolean
note?: string
semiAutoEnabled?: boolean
semiAutoPlatform?: '' | 'huya' | 'weibo'
semiAutoConfig?: Record<string, unknown>
}) {
return apiPost<ManualFulfillmentConfig>('/api/v1/admin/cost-center/manual-configs', payload)
}
@@ -0,0 +1,41 @@
import { apiGet, apiPost } from '@/lib/http'
import type { SemiAutoTaskDetail, SemiAutoTaskListResult } from '@/types/admin/semi-auto'
export function fetchAdminSemiAutoTasks(params: Record<string, unknown> = {}) {
return apiGet<SemiAutoTaskListResult>('/api/v1/admin/semi-auto/tasks', params)
}
export function fetchAdminSemiAutoTask(taskId: number) {
return apiGet<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}`)
}
export function claimAdminSemiAutoTask(taskId: number) {
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/claim`, {})
}
export function syncAdminSemiAutoTask(taskId: number) {
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/sync`, {})
}
export function createAdminSemiAutoBrowserSession(taskId: number, targetUrl?: string) {
return apiPost<{ ticket: string; expiresAt: string; targetUrl: string }>(
`/api/v1/admin/semi-auto/tasks/${taskId}/browser-session`,
{ targetUrl: targetUrl || '' },
)
}
export function completeAdminSemiAutoTask(
taskId: number,
payload: {
note?: string
externalOrderNo?: string
costFen?: number
screenshots?: Array<Record<string, unknown>>
},
) {
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/complete`, payload)
}
export function cancelAdminSemiAutoTask(taskId: number, reason: string) {
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/cancel`, { reason })
}
@@ -0,0 +1,52 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
CustomerBindingInfo,
CustomerLoginInfo,
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
export function fetchCustomerFulfillment(orderNo: string) {
return apiGet<CustomerSemiAutoTask[]>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}`,
)
}
export function markCustomerFulfillmentOpened(orderNo: string, taskId: number) {
return apiPost<{ ok: boolean }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/open`,
{},
)
}
export function startCustomerLogin(orderNo: string, taskId: number) {
return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/login/start`,
{},
)
}
export function pollCustomerLogin(orderNo: string, taskId: number) {
return apiGet<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo | null }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/login/status`,
)
}
export function startCustomerBinding(orderNo: string, taskId: number) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/start`,
{},
)
}
export function pollCustomerBinding(orderNo: string, taskId: number) {
return apiGet<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo | null }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/status`,
)
}
export function confirmCustomerBinding(orderNo: string, taskId: number) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/confirm`,
{},
)
}
+23
View File
@@ -1435,3 +1435,26 @@
max-width: 116px;
}
}
.customer-fulfillment-page {
min-height: 100vh;
padding: clamp(18px, 3vw, 42px) clamp(14px, 4vw, 64px);
background:
radial-gradient(circle at 10% 0%, rgba(249, 115, 22, 0.14), transparent 30%),
linear-gradient(180deg, #fffaf5 0%, #f8fafc 45%, #f1f5f9 100%);
}
.customer-fulfillment-page > * {
width: min(1080px, 100%);
margin-inline: auto;
}
.customer-fulfillment-centered {
display: grid;
place-items: center;
}
.customer-fulfillment-centered .ant-result,
.customer-fulfillment-centered .ant-spin-nested-loading {
width: min(720px, 100%);
}
@@ -430,6 +430,15 @@ export interface ManualFulfillmentConfig {
/** 平台换算率(分/积分):南航/乐玩 1,ROCK 100(1 积分 = 1 元)。 */
creditUnitPriceFen: number
enabled: boolean
semiAutoEnabled: boolean
semiAutoPlatform: '' | 'huya' | 'weibo'
semiAutoConfig: {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
browserTargets?: Array<{ key: string; label: string; url: string }>
}
note?: string
createdAt?: string
updatedAt?: string
@@ -0,0 +1,74 @@
export type SemiAutoPlatform = 'huya' | 'weibo'
export type SemiAutoDisplayStatus = {
key: string
label: string
}
export type SemiAutoTask = {
id: number
orderId: number
orderItemId: number
fulfillmentId: number | null
platform: SemiAutoPlatform
platformLabel: string
loginStatus: string
bindingStatus: string
executionStatus: string
displayStatus: SemiAutoDisplayStatus
connectorReady: boolean
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
targetQuantity: number
completedQuantity: number
assignedAdminUserId: number | null
assignedAdminName: string
customerLinkOpenedAt: string | null
authStartedAt: string | null
authenticatedAt: string | null
bindingStartedAt: string | null
boundAt: string | null
completedAt: string | null
completionNote: string
externalOrderNo: string
completionEvidence: Record<string, unknown>
lastError: string
createdAt: string
updatedAt: string
orderNo: string
provider: string
shopId: string
skuCode: string
skuName: string
productName: string
quantity: number
semiAutoConfig: {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
browserTargets?: Array<{ key: string; label: string; url: string }>
}
}
export type SemiAutoTaskEvent = {
id: number
eventType: string
actorType: string
actorId: string
detail: Record<string, unknown>
createdAt: string
}
export type SemiAutoTaskListResult = {
items: SemiAutoTask[]
pagination: { page: number; pageSize: number; total: number }
}
export type SemiAutoTaskDetail = {
task: SemiAutoTask
events: SemiAutoTaskEvent[]
}
@@ -0,0 +1,44 @@
export type CustomerSemiAutoTask = {
id: number
platform: 'huya' | 'weibo'
platformLabel: string
orderNo: string
productName: string
skuName: string
skuCode: string
quantity: number
loginStatus: string
bindingStatus: string
executionStatus: string
displayStatus: { key: string; label: string }
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
completedAt: string | null
completionNote: string
semiAutoConfig: {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
}
}
export type CustomerLoginInfo = {
status: string
qrContent: string
expiresAt: string
message: string
}
export type CustomerBindingInfo = {
status: string
qrImage: string
qrExpiresAt: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
message: string
}
@@ -0,0 +1,6 @@
/** 客户半自动履约入口:/c/:orderNo。 */
export function isCustomerFulfillmentPath(pathname: unknown): boolean {
const normalized = String(pathname || '').trim()
if (!normalized.startsWith('/')) return false
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)[0] === 'c'
}
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isCustomerFulfillmentPath } from '../src/utils/customer-fulfillment-link.ts'
test('客户半自动履约入口:/c 命名空间整段命中', () => {
assert.equal(isCustomerFulfillmentPath('/c/263123'), true)
assert.equal(isCustomerFulfillmentPath('/c'), true)
assert.equal(isCustomerFulfillmentPath('/c/a/b'), true)
})
test('客户半自动履约入口不误伤相似前缀和其他路径', () => {
assert.equal(isCustomerFulfillmentPath('/claim/x'), false)
assert.equal(isCustomerFulfillmentPath('/customer/x'), false)
assert.equal(isCustomerFulfillmentPath('/admin/c/1'), false)
assert.equal(isCustomerFulfillmentPath('c/1'), false)
})