合并 dev_11.9_2 到 dev_11.13
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
@@ -23,10 +24,12 @@ import {
|
||||
buildOpen91QueryDevMock,
|
||||
createAffiliateDashDevMock,
|
||||
createKuaishouIndustryVoucherDevMock,
|
||||
createSemiAutoDevMock,
|
||||
fetchDevMockStatus,
|
||||
type DevMockCreateResult,
|
||||
type DevMockKuaishouIndustryVoucherResult,
|
||||
type DevMockOpen91QueryResult,
|
||||
type DevMockSemiAutoResult,
|
||||
} from '@/services/admin'
|
||||
|
||||
export default function AdminDevMockPage() {
|
||||
@@ -35,6 +38,7 @@ export default function AdminDevMockPage() {
|
||||
const [lastVoucherCreate, setLastVoucherCreate] =
|
||||
useState<DevMockKuaishouIndustryVoucherResult | null>(null)
|
||||
const [lastQuery, setLastQuery] = useState<DevMockOpen91QueryResult | null>(null)
|
||||
const [lastSemiAuto, setLastSemiAuto] = useState<DevMockSemiAutoResult | null>(null)
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['admin-dev-mock-status'],
|
||||
@@ -78,6 +82,17 @@ export default function AdminDevMockPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const semiAutoMutation = useMutation({
|
||||
mutationFn: createSemiAutoDevMock,
|
||||
onSuccess: (response) => {
|
||||
setLastSemiAuto(response.data)
|
||||
message.success('半自动履约 Mock 已生成')
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '生成失败')
|
||||
},
|
||||
})
|
||||
|
||||
const defaultOrderNo = useMemo(() => lastCreate?.orderNo || '', [lastCreate?.orderNo])
|
||||
|
||||
if (statusQuery.isLoading) {
|
||||
@@ -109,7 +124,7 @@ export default function AdminDevMockPage() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="开发 Mock"
|
||||
description="一键造单、测领取页与 91 查单,无需真实 91 / affiliate-dash 环境。"
|
||||
description="一键造单与测试数据;半自动履约可选择只 Mock 订单,登录、绑定走真实 Connector。"
|
||||
extra={
|
||||
<Typography.Text type="secondary">NODE_ENV={status?.nodeEnv || '-'}</Typography.Text>
|
||||
}
|
||||
@@ -119,8 +134,8 @@ export default function AdminDevMockPage() {
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<BugOutlined />}
|
||||
message="Mock 会写入开发库真实订单/任务;当前仅保留 affiliate-dash / 电子凭证 / 91 查单。"
|
||||
description="affiliate-dash 可用默认 UID 测试绑定与提交发货。"
|
||||
message="Mock 会写入开发库真实订单/任务;除订单外,登录、绑定、票据注入和完成确认都可以走真实流程。"
|
||||
description="默认使用 order_site 内置真实 Connector,不依赖 live-hub-py;只有显式选择内置 Mock 时才会 Mock 登录和绑定。"
|
||||
/>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -134,7 +149,19 @@ export default function AdminDevMockPage() {
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={12}>
|
||||
<Card title="2. 快手电子凭证测试数据" bordered={false}>
|
||||
<Card title="2. 半自动履约测试单(可选 Mock 范围)" bordered={false}>
|
||||
<SemiAutoMockForm
|
||||
loading={semiAutoMutation.isPending}
|
||||
defaultConnectorMode={status?.semiAuto?.defaultConnectorMode}
|
||||
externalConnectorConfigured={status?.semiAuto?.externalConnectorConfigured}
|
||||
mockConnectorAvailable={status?.semiAuto?.mockConnectorAvailable}
|
||||
onSubmit={(values) => semiAutoMutation.mutate(values)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={12}>
|
||||
<Card title="3. 快手电子凭证测试数据" bordered={false}>
|
||||
<KuaishouIndustryVoucherMockForm
|
||||
loading={voucherMutation.isPending}
|
||||
onSubmit={(values) => voucherMutation.mutate(values)}
|
||||
@@ -143,7 +170,7 @@ export default function AdminDevMockPage() {
|
||||
</Col>
|
||||
|
||||
<Col xs={24}>
|
||||
<Card title="3. 模拟 91 查单(open-91/query)" bordered={false}>
|
||||
<Card title="4. 模拟 91 查单(open-91/query)" bordered={false}>
|
||||
<Form
|
||||
layout="inline"
|
||||
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
|
||||
@@ -260,6 +287,92 @@ export default function AdminDevMockPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{lastSemiAuto ? (
|
||||
<Card
|
||||
title="最近生成的半自动履约 Mock"
|
||||
extra={<Link to={lastSemiAuto.adminPath}>打开后台任务</Link>}
|
||||
>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Typography.Text type="secondary">订单号</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text copyable strong>
|
||||
{lastSemiAuto.orderNo}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Typography.Text type="secondary">内部 SKU / 任务</Typography.Text>
|
||||
<div>
|
||||
{lastSemiAuto.semiAutoPlatform === 'weibo' ? '微博' : '虎牙'} ·{' '}
|
||||
{lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Typography.Text type="secondary">客户履约入口</Typography.Text>
|
||||
<div>
|
||||
<Typography.Link
|
||||
href={`${window.location.origin}${lastSemiAuto.customerPath}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<LinkOutlined /> {window.location.origin}
|
||||
{lastSemiAuto.customerPath}
|
||||
</Typography.Link>
|
||||
</div>
|
||||
<Space style={{ marginTop: 8 }} wrap>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`${window.location.origin}${lastSemiAuto.customerPath}`,
|
||||
)
|
||||
message.success('已复制客户履约链接')
|
||||
}}
|
||||
>
|
||||
复制客户链接
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
href={`${window.location.origin}${lastSemiAuto.customerPath}`}
|
||||
target="_blank"
|
||||
>
|
||||
打开客户页
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Alert
|
||||
type={
|
||||
lastSemiAuto.connectorMode === 'mock'
|
||||
? 'warning'
|
||||
: lastSemiAuto.connectorConfigured
|
||||
? 'success'
|
||||
: 'error'
|
||||
}
|
||||
showIcon
|
||||
message={
|
||||
lastSemiAuto.connectorMode === 'mock'
|
||||
? '当前使用内置 Mock Connector'
|
||||
: lastSemiAuto.connectorConfigured
|
||||
? lastSemiAuto.connectorMode === 'external'
|
||||
? '当前使用远程真实 Connector'
|
||||
: '当前使用 order_site 内置真实 Connector'
|
||||
: '当前选择远程真实 Connector,但尚未配置'
|
||||
}
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{lastSemiAuto.tips.map((tip) => (
|
||||
<li key={tip}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{lastVoucherCreate ? (
|
||||
<Card
|
||||
title="最近生成的电子凭证 Mock"
|
||||
@@ -400,3 +513,169 @@ function KuaishouIndustryVoucherMockForm({
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
function SemiAutoMockForm({
|
||||
loading,
|
||||
defaultConnectorMode,
|
||||
externalConnectorConfigured,
|
||||
mockConnectorAvailable,
|
||||
onSubmit,
|
||||
}: {
|
||||
loading: boolean
|
||||
defaultConnectorMode?: 'embedded' | 'external' | 'mock'
|
||||
externalConnectorConfigured?: boolean
|
||||
mockConnectorAvailable?: boolean
|
||||
onSubmit: (values: {
|
||||
platform: 'huya' | 'weibo'
|
||||
orderNo?: string
|
||||
skuName?: string
|
||||
quantity?: number
|
||||
pageTitle?: string
|
||||
browserTargetUrl?: string
|
||||
connectorMode?: 'embedded' | 'external' | 'mock'
|
||||
}) => void
|
||||
}) {
|
||||
const [form] = Form.useForm()
|
||||
const selectedPlatform =
|
||||
(Form.useWatch('platform', form) as 'huya' | 'weibo' | undefined) || 'huya'
|
||||
const defaultMode =
|
||||
defaultConnectorMode === 'mock' && mockConnectorAvailable
|
||||
? 'mock'
|
||||
: defaultConnectorMode === 'external'
|
||||
? 'external'
|
||||
: 'embedded'
|
||||
const selectedMode =
|
||||
(Form.useWatch('connectorMode', form) as 'embedded' | 'external' | 'mock' | undefined) ||
|
||||
defaultMode
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
platform: 'huya',
|
||||
quantity: 1,
|
||||
pageTitle: '虎牙账号授权测试',
|
||||
browserTargetUrl: 'https://www.huya.com/',
|
||||
connectorMode: defaultMode,
|
||||
}}
|
||||
onFinish={onSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
label="履约平台"
|
||||
name="platform"
|
||||
extra="微博 Mock 单登录成功即视为授权完成,不再有角色绑定步骤。"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'huya', label: '虎牙(扫码登录 + 游戏角色绑定)' },
|
||||
{ value: 'weibo', label: '微博(短信 / 扫码登录,登录即授权)' },
|
||||
]}
|
||||
onChange={(value: 'huya' | 'weibo') => {
|
||||
form.setFieldsValue({
|
||||
pageTitle: value === 'weibo' ? '微博发帖授权测试' : '虎牙账号授权测试',
|
||||
browserTargetUrl: value === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/',
|
||||
...(value === 'weibo' ? { connectorMode: 'embedded' } : {}),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedPlatform === 'huya' ? (
|
||||
<Form.Item
|
||||
label="Mock 范围"
|
||||
name="connectorMode"
|
||||
extra="只 Mock 订单时,登录、角色绑定和浏览器票据注入可走内置真实 Connector,也可显式使用远程 Connector。"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: 'embedded',
|
||||
label: '仅 Mock 订单(内置真实登录 / 绑定 / 注入)',
|
||||
},
|
||||
{
|
||||
value: 'external',
|
||||
label: '仅 Mock 订单(远程真实 Connector)',
|
||||
},
|
||||
{
|
||||
value: 'mock',
|
||||
label: '订单 + 登录绑定都 Mock(内置 Connector)',
|
||||
disabled: mockConnectorAvailable === false,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="微博目前只支持内置 Connector,连接模式固定为 embedded。"
|
||||
/>
|
||||
)}
|
||||
<Form.Item
|
||||
label="内部 SKU 名称(可空)"
|
||||
name="skuName"
|
||||
extra={`留空自动生成“${
|
||||
selectedPlatform === 'weibo' ? '微博半自动' : '虎牙半自动'
|
||||
}测试-时间戳”;必须包含中文。`}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={selectedPlatform === 'weibo' ? '微博半自动测试' : '虎牙半自动测试'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={selectedPlatform === 'weibo' ? '留空自动 MOCKWB…' : '留空自动 MOCKSA…'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item label="数量" name="quantity" style={{ flex: 1 }}>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="客户页标题" name="pageTitle" style={{ flex: 2 }}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item
|
||||
label="客服浏览器目标页"
|
||||
name="browserTargetUrl"
|
||||
extra="客服点击“打开浏览器登录态”时,扩展会注入到此页面。"
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
selectedPlatform === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type={
|
||||
selectedPlatform === 'weibo'
|
||||
? 'info'
|
||||
: selectedMode === 'mock'
|
||||
? 'warning'
|
||||
: selectedMode === 'external' && !externalConnectorConfigured
|
||||
? 'error'
|
||||
: 'success'
|
||||
}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
selectedPlatform === 'weibo'
|
||||
? '微博使用内置真实 Connector:短信验证码会真实发送到客户手机号,扫码需在微博 App 确认;登录成功即视为授权完成。'
|
||||
: selectedMode === 'mock'
|
||||
? '登录和绑定会使用内置 Mock Connector,仅用于联调页面。'
|
||||
: selectedMode === 'external'
|
||||
? externalConnectorConfigured
|
||||
? '远程真实 Connector 已配置;只有订单由 Mock 生成。'
|
||||
: '远程真实 Connector 未配置:请设置 SEMI_AUTO_CONNECTOR_BASE_URL 和 API_KEY。'
|
||||
: '使用 order_site 内置真实 Connector;无需启动 live-hub-py,只有订单由 Mock 生成。'
|
||||
}
|
||||
/>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
生成半自动履约 Mock
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ChromeOutlined,
|
||||
CopyOutlined,
|
||||
KeyOutlined,
|
||||
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,
|
||||
exportAdminSemiAutoCredential,
|
||||
fetchAdminSemiAutoTask,
|
||||
fetchAdminSemiAutoTasks,
|
||||
syncAdminSemiAutoTask,
|
||||
} from '@/services/admin/semi-auto'
|
||||
import type { SemiAutoCredentialExport, SemiAutoTask } from '@/types/admin/semi-auto'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
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 [credentialExport, setCredentialExport] = useState<SemiAutoCredentialExport | null>(null)
|
||||
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 credentialExportMutation = useMutation({
|
||||
mutationFn: (taskId: number) => exportAdminSemiAutoCredential(taskId),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) {
|
||||
message.error('账号 CK 导出失败')
|
||||
return
|
||||
}
|
||||
setCredentialExport(response.data)
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '导出账号 CK 失败'),
|
||||
})
|
||||
|
||||
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 copyCredentialText(text: string, label: string) {
|
||||
void navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => message.success(`${label}已复制`))
|
||||
.catch(() => message.error('复制失败,请手动选择文本'))
|
||||
}
|
||||
|
||||
function confirmExportCredential(task: SemiAutoTask) {
|
||||
modal.confirm({
|
||||
title: '导出账号 CK',
|
||||
content:
|
||||
'CK 等同于平台登录态。导出后会显示在当前页面,请勿发给无关人员;关闭弹窗后不会保留在页面状态中。',
|
||||
okText: '确认导出',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => credentialExportMutation.mutateAsync(task.id),
|
||||
})
|
||||
}
|
||||
|
||||
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: 'Connector',
|
||||
dataIndex: 'connectorMode',
|
||||
width: 110,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === 'mock' ? 'orange' : 'green'}>
|
||||
{value === 'mock' ? '内置 Mock' : '真实'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '客户登录',
|
||||
key: 'login',
|
||||
width: 180,
|
||||
render: (_, task) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag>{task.displayStatus.label}</Tag>
|
||||
<Text type="secondary">{task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'}</Text>
|
||||
<Text type="secondary">
|
||||
CK:
|
||||
{task.credentialStatus === 'valid'
|
||||
? '有效'
|
||||
: task.credentialStatus === 'invalid'
|
||||
? '失效'
|
||||
: task.credentialStatus === 'error'
|
||||
? '检测异常'
|
||||
: '未保存'}
|
||||
</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">{task.platform === 'weibo' ? '未授权' : '未绑定'}</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="Connector">
|
||||
<Tag color={detail.task.connectorMode === 'mock' ? 'orange' : 'green'}>
|
||||
{detail.task.connectorMode === 'mock'
|
||||
? '内置 Mock'
|
||||
: detail.task.connectorMode === 'external'
|
||||
? '远程真实'
|
||||
: '内置真实'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="当前状态">
|
||||
<Tag color={STATUS_COLORS[detail.task.displayStatus.key] || 'default'}>
|
||||
{detail.task.displayStatus.label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博 UID' : '虎牙 UID'}>
|
||||
{detail.task.huyaUid || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台昵称">
|
||||
{detail.task.platformNickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账号 CK">
|
||||
<Space size={6}>
|
||||
<Tag
|
||||
color={
|
||||
detail.task.credentialStatus === 'valid'
|
||||
? 'green'
|
||||
: detail.task.credentialStatus === 'invalid'
|
||||
? 'red'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{detail.task.credentialStatus === 'valid'
|
||||
? '有效'
|
||||
: detail.task.credentialStatus === 'invalid'
|
||||
? '失效'
|
||||
: detail.task.credentialStatus === 'error'
|
||||
? '检测异常'
|
||||
: '未保存'}
|
||||
</Tag>
|
||||
<Text type="secondary">
|
||||
{detail.task.credentialSource
|
||||
? `${detail.task.credentialSource} · ${formatAdminDateTime(detail.task.credentialUpdatedAt)}`
|
||||
: '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
|
||||
{detail.task.bindGameAccount || '-'}
|
||||
</Descriptions.Item>
|
||||
{detail.task.platform === 'weibo' ? (
|
||||
<Descriptions.Item label="授权状态">
|
||||
{detail.task.bindingStatus === 'confirmed' ? '登录即授权' : '未授权'}
|
||||
</Descriptions.Item>
|
||||
) : (
|
||||
<Descriptions.Item label="游戏角色">
|
||||
{detail.task.bindGameRole || '-'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{detail.task.platform !== 'weibo' ? (
|
||||
<Descriptions.Item label="区服" span={2}>
|
||||
{detail.task.bindGameChannel || '-'}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<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>
|
||||
{detail.task.credentialReady &&
|
||||
hasAdminPermission('semi_auto.credential.export') ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<KeyOutlined />}
|
||||
loading={credentialExportMutation.isPending}
|
||||
onClick={() => confirmExportCredential(detail.task)}
|
||||
>
|
||||
导出 CK
|
||||
</Button>
|
||||
) : null}
|
||||
</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(credentialExport)}
|
||||
title={
|
||||
credentialExport
|
||||
? `账号 CK · ${credentialExport.platform === 'weibo' ? '微博' : '平台'} ${credentialExport.platformUid}`
|
||||
: '账号 CK'
|
||||
}
|
||||
width={760}
|
||||
footer={null}
|
||||
onCancel={() => setCredentialExport(null)}
|
||||
>
|
||||
{credentialExport ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Text type="danger">
|
||||
以下内容包含平台登录态,仅用于本次受控操作。关闭弹窗后请勿在聊天、日志或工单中留存。
|
||||
</Text>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Text strong>{credentialExport.targetUrl || 'Cookie Header'}</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyCredentialText(credentialExport.cookieHeader, 'Cookie Header')}
|
||||
>
|
||||
复制 Header
|
||||
</Button>
|
||||
</Space>
|
||||
<Input.TextArea
|
||||
value={credentialExport.cookieHeader}
|
||||
readOnly
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Text strong>完整域名 Cookie</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() =>
|
||||
copyCredentialText(
|
||||
JSON.stringify(credentialExport.cookies, null, 2),
|
||||
'完整 Cookie JSON',
|
||||
)
|
||||
}
|
||||
>
|
||||
复制 JSON
|
||||
</Button>
|
||||
</Space>
|
||||
<Input.TextArea
|
||||
value={JSON.stringify(credentialExport.cookies, null, 2)}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 18 }}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<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,810 @@
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
QrcodeOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Image,
|
||||
Input,
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Steps,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import QRCode from 'qrcode'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
|
||||
import {
|
||||
confirmCustomerBinding,
|
||||
fetchCustomerFulfillment,
|
||||
fetchCustomerWeiboCountryCodes,
|
||||
loginCustomerWeiboSms,
|
||||
markCustomerFulfillmentOpened,
|
||||
pollCustomerBinding,
|
||||
pollCustomerLogin,
|
||||
pollCustomerWeiboQrLogin,
|
||||
sendCustomerWeiboSmsCode,
|
||||
startCustomerBinding,
|
||||
startCustomerLogin,
|
||||
startCustomerWeiboQrLogin,
|
||||
} from '@/services/customer-fulfillment'
|
||||
import type { CustomerWeiboCountryCode } from '@/services/customer-fulfillment'
|
||||
import type {
|
||||
CustomerBindingInfo,
|
||||
CustomerLoginInfo,
|
||||
CustomerSemiAutoTask,
|
||||
} from '@/types/customer-fulfillment'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { runWeiboYidunChallenge } from '@/utils/weibo-yidun'
|
||||
|
||||
const { Text, Title, Paragraph } = Typography
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
const DEFAULT_WEIBO_COUNTRY_CODES: CustomerWeiboCountryCode[] = [
|
||||
{ code: '86', name: '中国' },
|
||||
{ code: '852', name: '中国香港' },
|
||||
{ code: '886', name: '中国台湾' },
|
||||
{ code: '853', name: '中国澳门' },
|
||||
{ code: '81', name: '日本' },
|
||||
]
|
||||
|
||||
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 [bindingActionLoading, setBindingActionLoading] = useState(false)
|
||||
const [loginMode, setLoginMode] = useState<'sms' | 'qr'>('sms')
|
||||
const [mobile, setMobile] = useState('')
|
||||
const [countryCode, setCountryCode] = useState('86')
|
||||
const [countryCodes, setCountryCodes] = useState<CustomerWeiboCountryCode[]>(
|
||||
DEFAULT_WEIBO_COUNTRY_CODES,
|
||||
)
|
||||
const [smsCode, setSmsCode] = useState('')
|
||||
const [smsSending, setSmsSending] = useState(false)
|
||||
const [smsLogging, setSmsLogging] = useState(false)
|
||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||
const [smsMfaRequired, setSmsMfaRequired] = useState(false)
|
||||
const startingRef = useRef(false)
|
||||
const hydratingBindingRef = useRef(false)
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null,
|
||||
[selectedTaskId, tasks],
|
||||
)
|
||||
const isWeibo = selectedTask?.platform === 'weibo'
|
||||
|
||||
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 (!selectedTask || selectedTask.platform !== 'weibo' || !orderNo) return
|
||||
let cancelled = false
|
||||
void fetchCustomerWeiboCountryCodes(orderNo, selectedTask.id)
|
||||
.then((response) => {
|
||||
if (cancelled) return
|
||||
const list = response.data?.countryCodes || []
|
||||
if (list.length > 0) setCountryCodes(list)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [orderNo, selectedTask?.id, selectedTask?.platform])
|
||||
|
||||
useEffect(() => {
|
||||
if (loginInfo?.qrImage) {
|
||||
setLoginQrImage(loginInfo.qrImage)
|
||||
return
|
||||
}
|
||||
if (!loginInfo?.qrContent) {
|
||||
setLoginQrImage('')
|
||||
return
|
||||
}
|
||||
void QRCode.toDataURL(loginInfo.qrContent, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: { dark: '#111827', light: '#ffffff' },
|
||||
}).then(setLoginQrImage)
|
||||
}, [loginInfo?.qrContent, loginInfo?.qrImage])
|
||||
|
||||
useEffect(() => {
|
||||
if (smsCountdown <= 0) return
|
||||
const timer = window.setTimeout(() => setSmsCountdown((value) => value - 1), 1000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [smsCountdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || !orderNo) return
|
||||
if (!['qr_pending', 'scanned'].includes(selectedTask.loginStatus)) return
|
||||
const tick = async () => {
|
||||
try {
|
||||
const response =
|
||||
selectedTask.platform === 'weibo'
|
||||
? await pollCustomerWeiboQrLogin(orderNo, selectedTask.id)
|
||||
: 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, selectedTask?.platform])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask || !orderNo || selectedTask.platform === 'weibo') 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!selectedTask ||
|
||||
!orderNo ||
|
||||
selectedTask.platform === 'weibo' ||
|
||||
bindingInfo ||
|
||||
hydratingBindingRef.current
|
||||
)
|
||||
return
|
||||
if (
|
||||
selectedTask.loginStatus !== 'authenticated' ||
|
||||
selectedTask.bindingStatus !== 'role_ready'
|
||||
) {
|
||||
return
|
||||
}
|
||||
hydratingBindingRef.current = true
|
||||
void pollCustomerBinding(orderNo, selectedTask.id)
|
||||
.then((response) => {
|
||||
setTasks((current) =>
|
||||
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
|
||||
)
|
||||
if (response.data.binding) setBindingInfo(response.data.binding)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
hydratingBindingRef.current = false
|
||||
})
|
||||
}, [
|
||||
bindingInfo,
|
||||
orderNo,
|
||||
selectedTask?.bindingStatus,
|
||||
selectedTask?.id,
|
||||
selectedTask?.loginStatus,
|
||||
])
|
||||
|
||||
async function handleStartLogin() {
|
||||
if (!selectedTask) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const response =
|
||||
selectedTask.platform === 'weibo'
|
||||
? await startCustomerWeiboQrLogin(orderNo, selectedTask.id)
|
||||
: 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 handleSendWeiboSms() {
|
||||
if (!selectedTask) return
|
||||
const phone = mobile.trim()
|
||||
if (!isValidWeiboMobile(countryCode, phone)) {
|
||||
message.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
setSmsSending(true)
|
||||
try {
|
||||
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
|
||||
if (response.data.mfaRequired) {
|
||||
setSmsMfaRequired(true)
|
||||
if (!response.data.mfaId || !response.data.captchaId) {
|
||||
throw new Error('微博返回的安全验证参数不完整')
|
||||
}
|
||||
await runWeiboYidunChallenge(response.data.mfaId, response.data.captchaId)
|
||||
response = await sendCustomerWeiboSmsCode(
|
||||
orderNo,
|
||||
selectedTask.id,
|
||||
phone,
|
||||
countryCode,
|
||||
response.data.mfaId,
|
||||
)
|
||||
if (response.data.mfaRequired) {
|
||||
throw new Error('微博安全验证未通过,请重新获取验证码')
|
||||
}
|
||||
}
|
||||
message.success(response.data.message || '验证码已发送,请注意查收')
|
||||
setSmsMfaRequired(false)
|
||||
setSmsCountdown(60)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '发送微博验证码失败')
|
||||
} finally {
|
||||
setSmsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginWeiboSms() {
|
||||
if (!selectedTask) return
|
||||
const phone = mobile.trim()
|
||||
if (!isValidWeiboMobile(countryCode, phone)) {
|
||||
message.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
if (!smsCode.trim()) {
|
||||
message.warning('请输入微博短信验证码')
|
||||
return
|
||||
}
|
||||
setSmsLogging(true)
|
||||
try {
|
||||
const response = await loginCustomerWeiboSms(orderNo, selectedTask.id, {
|
||||
mobile: phone,
|
||||
code: smsCode.trim(),
|
||||
countryCode,
|
||||
})
|
||||
setTasks((current) =>
|
||||
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
|
||||
)
|
||||
setLoginInfo(response.data.login)
|
||||
setBindingInfo(null)
|
||||
message.success('微博登录成功,等待客服处理')
|
||||
} catch (error) {
|
||||
const errorCode = (error as { errorCode?: string } | null)?.errorCode || ''
|
||||
if (errorCode === 'weibo_sms_mfa_required') {
|
||||
setSmsMfaRequired(true)
|
||||
message.warning('微博要求二次验证,请改用扫码登录')
|
||||
return
|
||||
}
|
||||
message.error(error instanceof Error ? error.message : '微博短信登录失败')
|
||||
} finally {
|
||||
setSmsLogging(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmBinding() {
|
||||
if (!selectedTask) return
|
||||
setBindingActionLoading(true)
|
||||
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 : '确认绑定失败')
|
||||
} finally {
|
||||
setBindingActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartRebind() {
|
||||
if (!selectedTask) return
|
||||
setBindingActionLoading(true)
|
||||
try {
|
||||
const response = await startCustomerBinding(orderNo, selectedTask.id, {
|
||||
forceRebind: true,
|
||||
})
|
||||
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 {
|
||||
setBindingActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
: isWeibo
|
||||
? '客服已完成本订单的微博发帖操作,请到微博账号中确认。'
|
||||
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。'
|
||||
}
|
||||
extra={
|
||||
selectedTask.completedAt
|
||||
? `完成时间:${formatAdminDateTime(selectedTask.completedAt)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const config = selectedTask?.semiAutoConfig || {}
|
||||
const title = config.pageTitle || (isWeibo ? '微博账号授权' : '账号授权与角色绑定')
|
||||
const description =
|
||||
config.pageDescription ||
|
||||
(isWeibo
|
||||
? '请使用本人微博账号完成登录,登录成功后客服会处理您的订单。'
|
||||
: '请按页面指引完成平台登录和游戏角色绑定,客服随后会处理您的订单。')
|
||||
const currentStep = resolveStep(selectedTask, bindingInfo)
|
||||
const loginQrVisible =
|
||||
selectedTask && ['qr_pending', 'scanned'].includes(selectedTask.loginStatus)
|
||||
const bindingQrVisible =
|
||||
selectedTask?.platform !== 'weibo' &&
|
||||
selectedTask?.loginStatus === 'authenticated' &&
|
||||
['qr_pending', 'scanned', 'role_ready'].includes(selectedTask.bindingStatus)
|
||||
const bindingQrPending = ['qr_pending', 'scanned'].includes(selectedTask?.bindingStatus || '')
|
||||
const bindingRole = bindingInfo?.bindGameRole || selectedTask?.bindGameRole || ''
|
||||
const bindingAccount = bindingInfo?.bindGameAccount || selectedTask?.bindGameAccount || ''
|
||||
const bindingChannel = bindingInfo?.bindGameChannel || selectedTask?.bindGameChannel || ''
|
||||
const roleChoiceRequired =
|
||||
selectedTask?.bindingStatus === 'role_ready' &&
|
||||
bindingInfo?.hasExistingBinding === true &&
|
||||
bindingInfo?.rebindRequested !== true
|
||||
|
||||
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={
|
||||
isWeibo
|
||||
? [
|
||||
{ title: '登录微博', icon: <QrcodeOutlined /> },
|
||||
{ title: '客服处理', icon: <LoadingOutlined /> },
|
||||
{ title: '完成' },
|
||||
]
|
||||
: [
|
||||
{ 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={isWeibo ? '第一步:登录微博账号' : '第一步:登录平台账号'}>
|
||||
{isWeibo ? (
|
||||
<Tabs
|
||||
activeKey={loginMode}
|
||||
onChange={(key) => setLoginMode(key as 'sms' | 'qr')}
|
||||
items={[
|
||||
{
|
||||
key: 'sms',
|
||||
label: '手机号登录',
|
||||
children: (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="使用微博绑定手机号登录"
|
||||
description={
|
||||
config.loginInstructions ||
|
||||
'验证码将发送到你的微博绑定手机号。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
|
||||
}
|
||||
/>
|
||||
{smsMfaRequired ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="正在等待微博安全验证"
|
||||
description="请在安全验证弹窗中完成验证,验证通过后会继续发送短信验证码。"
|
||||
/>
|
||||
) : null}
|
||||
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
|
||||
<Select
|
||||
value={countryCode}
|
||||
onChange={(value) => setCountryCode(String(value))}
|
||||
style={{ width: 110 }}
|
||||
options={countryCodes.map((item) => ({
|
||||
value: item.code,
|
||||
label: `+${item.code}`,
|
||||
name: item.name,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<Space size={6}>
|
||||
<span>{(option.data as { name?: string }).name}</span>
|
||||
<Text type="secondary">+{option.data.value}</Text>
|
||||
</Space>
|
||||
)}
|
||||
filterOption={(input, option) =>
|
||||
String(
|
||||
(option as { name?: string } | undefined)?.name || '',
|
||||
).includes(input) || String(option?.value || '').includes(input)
|
||||
}
|
||||
showSearch
|
||||
popupMatchSelectWidth={240}
|
||||
/>
|
||||
<Input
|
||||
value={mobile}
|
||||
onChange={(event) => setMobile(event.target.value)}
|
||||
placeholder="手机号"
|
||||
maxLength={countryCode === '86' ? 11 : 15}
|
||||
inputMode="numeric"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
loading={smsSending}
|
||||
disabled={smsCountdown > 0}
|
||||
onClick={() => void handleSendWeiboSms()}
|
||||
>
|
||||
{smsCountdown > 0 ? `重新发送(${smsCountdown}s)` : '发送验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
|
||||
<Input
|
||||
value={smsCode}
|
||||
onChange={(event) => setSmsCode(event.target.value)}
|
||||
placeholder="请输入短信验证码"
|
||||
maxLength={8}
|
||||
onPressEnter={() => void handleLoginWeiboSms()}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={smsLogging}
|
||||
onClick={() => void handleLoginWeiboSms()}
|
||||
>
|
||||
登录微博
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Text type="secondary">
|
||||
如安全验证弹窗未出现,请检查浏览器是否拦截脚本。
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'qr',
|
||||
label: '扫码登录',
|
||||
children: (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<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={isWeibo ? '使用微博 App 扫码登录' : '使用虎牙 App 扫码登录'}>
|
||||
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
|
||||
{loginQrImage ? (
|
||||
<Image
|
||||
src={loginQrImage}
|
||||
alt={isWeibo ? '微博登录二维码' : '虎牙登录二维码'}
|
||||
width={260}
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<Spin tip="生成二维码..." />
|
||||
)}
|
||||
<Text type="secondary">{loginInfo?.message || '等待扫码'}</Text>
|
||||
{selectedTask.loginStatus === 'scanned' ? (
|
||||
<Tag color="cyan">
|
||||
{isWeibo ? '已扫码,请在微博客户端确认登录' : '已扫码,请在 App 确认'}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Button onClick={() => void handleStartLogin()}>重新获取二维码</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{bindingQrVisible ? (
|
||||
<Card
|
||||
title={
|
||||
selectedTask.bindingStatus === 'role_ready'
|
||||
? '第二步:确认游戏角色'
|
||||
: '第二步:扫码绑定游戏角色'
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
|
||||
{bindingQrPending && bindingInfo?.qrImage ? (
|
||||
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={260} />
|
||||
) : bindingQrPending ? (
|
||||
<Spin tip="生成绑定二维码..." />
|
||||
) : null}
|
||||
<Text type="secondary">
|
||||
{bindingInfo?.message ||
|
||||
config.bindingInstructions ||
|
||||
'请使用虎牙 App 扫码完成游戏角色绑定。'}
|
||||
</Text>
|
||||
{bindingRole ? (
|
||||
<Alert
|
||||
type={roleChoiceRequired ? 'warning' : 'success'}
|
||||
showIcon
|
||||
message={
|
||||
roleChoiceRequired
|
||||
? `当前已绑定角色:${bindingRole}`
|
||||
: `已识别角色:${bindingRole}`
|
||||
}
|
||||
description={[bindingAccount, bindingChannel].filter(Boolean).join(' · ')}
|
||||
/>
|
||||
) : null}
|
||||
{selectedTask.bindingStatus === 'role_ready' ? (
|
||||
<Space direction="vertical" align="center" size={10}>
|
||||
{roleChoiceRequired ? (
|
||||
<Text type="secondary">
|
||||
请确认这是本次需要的游戏角色;角色不正确时请重新扫码换绑。
|
||||
</Text>
|
||||
) : null}
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={bindingActionLoading}
|
||||
onClick={() => void handleConfirmBinding()}
|
||||
>
|
||||
{roleChoiceRequired
|
||||
? '使用当前角色'
|
||||
: bindingInfo?.rebindRequested
|
||||
? '确认换绑角色'
|
||||
: '确认绑定角色'}
|
||||
</Button>
|
||||
{roleChoiceRequired ? (
|
||||
<Button
|
||||
danger
|
||||
loading={bindingActionLoading}
|
||||
onClick={() => void handleStartRebind()}
|
||||
>
|
||||
角色不对,重新扫码换绑
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{selectedTask?.bindingStatus === 'confirmed' &&
|
||||
selectedTask.executionStatus !== 'completed' ? (
|
||||
<Card>
|
||||
<Result
|
||||
status="info"
|
||||
title={isWeibo ? '微博账号已授权,客服正在处理' : '账号已绑定,客服正在处理'}
|
||||
subTitle={
|
||||
isWeibo
|
||||
? '客服会使用本次授权完成微博发帖,请稍后刷新页面查看结果。'
|
||||
: '客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isValidWeiboMobile(countryCode: string, value: string) {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (!digits) return false
|
||||
if (countryCode === '86') return /^1\d{10}$/.test(digits)
|
||||
return digits.length >= 5 && digits.length <= 15
|
||||
}
|
||||
|
||||
function resolveStep(task: CustomerSemiAutoTask | null, binding: CustomerBindingInfo | null) {
|
||||
if (!task) return 0
|
||||
if (task.platform === 'weibo') {
|
||||
if (task.executionStatus === 'completed') return 2
|
||||
if (task.bindingStatus === 'confirmed') return 1
|
||||
return 0
|
||||
}
|
||||
if (task.executionStatus === 'completed') return 3
|
||||
if (task.bindingStatus === 'confirmed') return 2
|
||||
if (task.loginStatus === 'authenticated' || binding) return 1
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user