完善半自动履约凭证与客户重新授权流程

统一虎牙和微博账号级 CK 保存与导出;CK 失效后回到客户重新登录,后台支持重发授权;客户授权页改为统一深色布局并收敛开发迁移配置。
This commit is contained in:
yml
2026-11-14 00:13:00 +08:00
parent e1d16f3a53
commit 8a54c41476
29 changed files with 1788 additions and 827 deletions
@@ -2,16 +2,22 @@ import {
CheckCircleOutlined,
ChromeOutlined,
CopyOutlined,
DownOutlined,
KeyOutlined,
LinkOutlined,
MoreOutlined,
ReloadOutlined,
SendOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Descriptions,
Drawer,
Dropdown,
Form,
Input,
InputNumber,
@@ -29,7 +35,6 @@ 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,
@@ -38,9 +43,10 @@ import {
exportAdminSemiAutoCredential,
fetchAdminSemiAutoTask,
fetchAdminSemiAutoTasks,
requestAdminSemiAutoCustomerReauth,
syncAdminSemiAutoTask,
} from '@/services/admin/semi-auto'
import type { SemiAutoCredentialExport, SemiAutoTask } from '@/types/admin/semi-auto'
import type { 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'
@@ -54,6 +60,7 @@ const STATUS_COLORS: Record<string, string> = {
waiting_binding: 'gold',
binding_confirm: 'orange',
waiting_operator: 'volcano',
credential_expired: 'red',
processing: 'processing',
completed: 'success',
failed: 'error',
@@ -82,7 +89,6 @@ export default function AdminSemiAutoTasksPage() {
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(
@@ -169,7 +175,7 @@ export default function AdminSemiAutoTasksPage() {
return
}
window.postMessage({ type: 'ORDER_SITE_SEMI_AUTO_INJECT', ticket }, window.location.origin)
message.info('正在通过虎牙履约助手注入登录态,请稍候')
message.info('正在注入浏览器登录态,请稍候')
void refreshAll()
},
onError: (error) =>
@@ -179,13 +185,32 @@ export default function AdminSemiAutoTasksPage() {
const credentialExportMutation = useMutation({
mutationFn: (taskId: number) => exportAdminSemiAutoCredential(taskId),
onSuccess: (response) => {
if (!response.data) {
message.error('账号 CK 导出失败')
const text =
String(response.data?.cookieHeader || '').trim() ||
(response.data?.cookies || []).map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
if (!text) {
message.error('当前任务没有可复制的 CK')
return
}
setCredentialExport(response.data)
void navigator.clipboard
.writeText(text)
.then(() => message.success('最终 CK 已复制'))
.catch(() => message.error('复制失败,请检查浏览器剪贴板权限'))
},
onError: (error) => message.error(error instanceof Error ? error.message : '导出账号 CK 失败'),
onError: (error) => message.error(error instanceof Error ? error.message : '复制账号 CK 失败'),
})
const reauthMutation = useMutation({
mutationFn: (task: SemiAutoTask) => requestAdminSemiAutoCustomerReauth(task.id),
onSuccess: async (response, task) => {
const copied = await copyCustomerLink(response.data?.task || task, '')
message.success(
copied ? '已重新发起授权,客户链接已复制' : '已重新发起授权,请手动复制客户链接',
)
await refreshAll()
},
onError: (error) =>
message.error(error instanceof Error ? error.message : '重新发起客户授权失败'),
})
const completeMutation = useMutation({
@@ -231,33 +256,114 @@ export default function AdminSemiAutoTasksPage() {
])
}
function copyCustomerLink(task: SemiAutoTask) {
async function copyCustomerLink(task: SemiAutoTask, successMessage = '客户授权链接已复制') {
const url = `${window.location.origin}/c/${encodeURIComponent(task.orderNo)}`
void navigator.clipboard
.writeText(url)
.then(() => message.success('客户授权链接已复制'))
.catch(() => message.error('复制失败,请手动复制'))
try {
await navigator.clipboard.writeText(url)
if (successMessage) message.success(successMessage)
return true
} catch {
message.error('复制失败,请手动复制客户链接')
return false
}
}
function copyCredentialText(text: string, label: string) {
void navigator.clipboard
.writeText(text)
.then(() => message.success(`${label}已复制`))
.catch(() => message.error('复制失败,请手动选择文本'))
}
function confirmExportCredential(task: SemiAutoTask) {
function confirmReauth(task: SemiAutoTask) {
modal.confirm({
title: '导出账号 CK',
content:
'CK 等同于平台登录态。导出后会显示在当前页面,请勿发给无关人员;关闭弹窗后不会保留在页面状态中。',
okText: '确认导出',
title: '重新发送客户授权',
content: '当前登录状态会失效,客户需要打开新的授权链接重新登录。原授权记录会保留。',
okText: '重新发送并复制链接',
cancelText: '取消',
onOk: () => reauthMutation.mutateAsync(task),
})
}
function confirmCopyCredential(task: SemiAutoTask) {
modal.confirm({
title: '复制最终 CK',
content: 'CK 等同于平台登录态。复制后请勿发给无关人员,也不要保存到聊天记录或工单备注。',
okText: '确认复制',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => credentialExportMutation.mutateAsync(task.id),
})
}
function credentialMenu(task: SemiAutoTask) {
const canExport = hasAdminPermission('semi_auto.credential.export')
return {
items: [
...(canExport
? [
{
key: 'copy',
icon: <KeyOutlined />,
label: '复制最终 CK',
disabled: !task.credentialReady || credentialExportMutation.isPending,
},
]
: []),
{
key: 'inject',
icon: <ChromeOutlined />,
label: '注入浏览器',
disabled: task.bindingStatus !== 'confirmed' || injectionMutation.isPending,
},
],
onClick: ({ key }: { key: string }) => {
if (key === 'copy') {
confirmCopyCredential(task)
return
}
if (key === 'inject') injectionMutation.mutate(task)
},
}
}
function taskMenu(task: SemiAutoTask) {
const active = task.executionStatus !== 'completed' && task.executionStatus !== 'cancelled'
return {
items: [
{
key: 'copy',
icon: <LinkOutlined />,
label: '复制客户链接',
},
{
key: 'sync',
icon: <ReloadOutlined />,
label: '刷新客户状态',
disabled: syncMutation.isPending,
},
...(!task.assignedAdminUserId
? [
{
key: 'claim',
icon: <UserSwitchOutlined />,
label: '领取任务',
disabled: claimMutation.isPending,
},
]
: []),
...(active
? [
{
key: 'cancel',
danger: true,
label: '取消任务',
},
]
: []),
],
onClick: ({ key }: { key: string }) => {
if (key === 'copy') void copyCustomerLink(task)
if (key === 'sync') syncMutation.mutate(task.id)
if (key === 'claim') claimMutation.mutate(task.id)
if (key === 'cancel') confirmCancel(task)
},
}
}
function openComplete(task: SemiAutoTask) {
setCompleteTask(task)
setCompleteFiles([])
@@ -300,17 +406,14 @@ export default function AdminSemiAutoTasksPage() {
{
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>
width: 120,
render: (value: string, task) => (
<Space direction="vertical" size={4}>
<Tag color="purple">{PLATFORM_LABELS[value] || value}</Tag>
<Tag color={task.connectorMode === 'mock' ? 'orange' : 'green'}>
{task.connectorMode === 'mock' ? 'Mock' : '真实'}
</Tag>
</Space>
),
},
{
@@ -365,58 +468,46 @@ export default function AdminSemiAutoTasksPage() {
{
title: '操作',
key: 'actions',
width: 360,
width: 340,
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 ? (
{taskNeedsCustomerReauth(task) ? (
<Button
size="small"
icon={<UserSwitchOutlined />}
loading={claimMutation.isPending}
onClick={() => claimMutation.mutate(task.id)}
type="primary"
icon={<SendOutlined />}
loading={reauthMutation.isPending}
onClick={() => confirmReauth(task)}
>
领取
重发授权
</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>
</>
<Dropdown menu={credentialMenu(task)} trigger={['click']}>
<Button size="small" icon={<KeyOutlined />}>
CK 操作 <DownOutlined />
</Button>
</Dropdown>
{task.executionStatus !== 'completed' &&
task.executionStatus !== 'cancelled' &&
!taskNeedsCustomerReauth(task) ? (
<Button
size="small"
type="primary"
icon={<CheckCircleOutlined />}
disabled={task.bindingStatus !== 'confirmed'}
onClick={() => openComplete(task)}
>
完成
</Button>
) : null}
<Dropdown menu={taskMenu(task)} trigger={['click']}>
<Button size="small" icon={<MoreOutlined />}>
更多
</Button>
</Dropdown>
</Space>
),
},
@@ -582,6 +673,14 @@ export default function AdminSemiAutoTasksPage() {
{formatAdminDateTime(detail.task.updatedAt)}
</Descriptions.Item>
</Descriptions>
{taskNeedsCustomerReauth(detail.task) ? (
<Alert
type="warning"
showIcon
message="客户登录状态需要更新"
description="平台 CK 已失效或任务登录失败。重新发送后,客户需要重新打开链接完成登录。"
/>
) : null}
{detail.task.lastError ? (
<Card size="small" title="最近错误">
<Text type="danger">{detail.task.lastError}</Text>
@@ -589,7 +688,17 @@ export default function AdminSemiAutoTasksPage() {
) : null}
<Card size="small" title="操作">
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => copyCustomerLink(detail.task)}>
{taskNeedsCustomerReauth(detail.task) ? (
<Button
type="primary"
icon={<SendOutlined />}
loading={reauthMutation.isPending}
onClick={() => confirmReauth(detail.task)}
>
重新发送授权
</Button>
) : null}
<Button icon={<CopyOutlined />} onClick={() => void copyCustomerLink(detail.task)}>
复制客户链接
</Button>
<Button
@@ -599,25 +708,11 @@ export default function AdminSemiAutoTasksPage() {
>
刷新客户状态
</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
<Dropdown menu={credentialMenu(detail.task)} trigger={['click']}>
<Button icon={<KeyOutlined />}>
CK 操作 <DownOutlined />
</Button>
) : null}
</Dropdown>
</Space>
</Card>
<Card size="small" title="时间线">
@@ -642,65 +737,6 @@ export default function AdminSemiAutoTasksPage() {
)}
</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="确认完成半自动履约"
@@ -753,3 +789,14 @@ function firstTargetUrl(task: SemiAutoTask) {
: []
return String(targets.find((item) => item?.url)?.url || '')
}
function taskNeedsCustomerReauth(task: SemiAutoTask) {
if (task.executionStatus === 'completed' || task.executionStatus === 'cancelled') return false
return (
task.credentialStatus === 'invalid' ||
task.credentialStatus === 'error' ||
task.executionStatus === 'failed' ||
task.loginStatus === 'failed' ||
task.loginStatus === 'expired'
)
}
@@ -2,28 +2,12 @@ import {
CheckCircleFilled,
LoadingOutlined,
QrcodeOutlined,
ReloadOutlined,
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 { App, Button, Image, Input, Select, Space, Spin, Tabs } from 'antd'
import QRCode from 'qrcode'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useParams } from 'react-router'
import {
@@ -49,8 +33,6 @@ import type {
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[] = [
@@ -131,6 +113,30 @@ export default function CustomerFulfillmentPage() {
void markCustomerFulfillmentOpened(orderNo, selectedTask.id).catch(() => undefined)
}, [orderNo, selectedTask?.id])
useEffect(() => {
if (!selectedTask || !orderNo) return
if (
selectedTask.executionStatus === 'completed' ||
selectedTask.executionStatus === 'cancelled'
) {
return
}
const timer = window.setInterval(() => {
void load(true)
}, POLL_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [load, orderNo, selectedTask?.executionStatus, selectedTask?.id])
useEffect(() => {
if (!selectedTask) return
if (!['waiting_login', 'failed', 'expired'].includes(selectedTask.loginStatus)) return
setLoginInfo(null)
setBindingInfo(null)
setSmsCode('')
setSmsMfaRequired(false)
setSmsCountdown(0)
}, [selectedTask?.id, selectedTask?.loginStatus])
useEffect(() => {
if (!selectedTask || selectedTask.platform !== 'weibo' || !orderNo) return
let cancelled = false
@@ -402,58 +408,8 @@ export default function CustomerFulfillmentPage() {
}
}
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 =
@@ -469,21 +425,69 @@ export default function CustomerFulfillmentPage() {
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>
if (loading) {
return (
<CustomerAuthFrame eyebrow="账号授权" title="正在读取订单信息">
<div className="customer-auth-loading">
<Spin indicator={<LoadingOutlined spin />} />
<span>正在读取订单履约信息...</span>
</div>
</CustomerAuthFrame>
)
}
if (loadError || tasks.length === 0) {
return (
<CustomerAuthFrame eyebrow="账号授权" title="暂时无法打开订单">
<div className="customer-auth-state is-error">
<h2>订单信息读取失败</h2>
<p>{loadError || '该订单没有需要客户授权或绑定的任务。'}</p>
</div>
</CustomerAuthFrame>
)
}
if (selectedTask?.executionStatus === 'completed') {
return (
<CustomerAuthFrame
platform={selectedTask.platform}
eyebrow={isWeibo ? '微博账号授权' : '虎牙账号授权'}
title={title}
>
<CustomerTaskFacts task={selectedTask} />
<div className="customer-auth-state is-success">
<div className="customer-auth-state-icon">
<CheckCircleFilled />
</div>
<h2>履约已完成</h2>
<p>
{selectedTask.completionNote ||
(isWeibo
? '客服已完成本订单的微博发帖操作,请到微博账号中确认。'
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。')}
</p>
{selectedTask.completedAt ? (
<span className="customer-auth-state-time">
完成时间:{formatAdminDateTime(selectedTask.completedAt)}
</span>
) : null}
</div>
<CustomerPrivacyNote />
</CustomerAuthFrame>
)
}
return (
<CustomerAuthFrame
platform={selectedTask?.platform}
eyebrow={isWeibo ? '微博账号授权' : '虎牙账号授权'}
title={title}
>
<div className="customer-auth-content-stack">
{tasks.length > 1 ? (
<Card size="small" title="请选择需要处理的产品">
<Space wrap>
<div className="customer-auth-task-switcher">
<span>本订单需要处理</span>
<div>
{tasks.map((task) => (
<Button
key={task.id}
@@ -497,61 +501,33 @@ export default function CustomerFulfillmentPage() {
{task.productName || task.skuName} × {task.quantity}
</Button>
))}
</Space>
</Card>
</div>
</div>
) : 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}
<CustomerTaskFacts task={selectedTask} />
{selectedTask?.loginStatus === 'waiting_login' ||
selectedTask?.loginStatus === 'failed' ||
selectedTask?.loginStatus === 'expired' ? (
<Card title={isWeibo ? '第一步:登录微博账号' : '第一步:登录平台账号'}>
<section className="customer-auth-section">
<div className="customer-auth-section-heading">
<span className="customer-auth-section-icon">
<QrcodeOutlined />
</span>
<div>
<h2>{isWeibo ? '登录微博账号' : '登录平台账号'}</h2>
<p>
{config.loginInstructions ||
(isWeibo
? '使用微博绑定手机号接收验证码,或使用微博 App 扫码登录。'
: '使用虎牙 App 扫码登录,请确认扫码账号属于本人。')}
</p>
</div>
</div>
{isWeibo ? (
<Tabs
className="customer-auth-tabs"
activeKey={loginMode}
onChange={(key) => setLoginMode(key as 'sms' | 'qr')}
items={[
@@ -559,29 +535,12 @@ export default function CustomerFulfillmentPage() {
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 }}>
<div className="customer-auth-form">
<div className="customer-auth-inline-input">
<Select
value={countryCode}
onChange={(value) => setCountryCode(String(value))}
style={{ width: 110 }}
className="customer-auth-country-code"
options={countryCodes.map((item) => ({
value: item.code,
label: `+${item.code}`,
@@ -590,7 +549,9 @@ export default function CustomerFulfillmentPage() {
optionRender={(option) => (
<Space size={6}>
<span>{(option.data as { name?: string }).name}</span>
<Text type="secondary">+{option.data.value}</Text>
<span className="customer-auth-option-hint">
+{option.data.value}
</span>
</Space>
)}
filterOption={(input, option) =>
@@ -604,20 +565,25 @@ export default function CustomerFulfillmentPage() {
<Input
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder="手机号"
placeholder="请输入微博绑定手机号"
maxLength={countryCode === '86' ? 11 : 15}
inputMode="numeric"
style={{ flex: 1 }}
/>
<Button
type="primary"
loading={smsSending}
disabled={smsCountdown > 0}
onClick={() => void handleSendWeiboSms()}
>
{smsCountdown > 0 ? `重新发送(${smsCountdown}s)` : '发送验证码'}
{smsCountdown > 0 ? `${smsCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
</div>
{smsMfaRequired ? (
<div className="customer-auth-note">
请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
</div>
) : null}
<div className="customer-auth-inline-input is-code">
<Input
value={smsCode}
onChange={(event) => setSmsCode(event.target.value)}
@@ -632,159 +598,279 @@ export default function CustomerFulfillmentPage() {
>
登录微博
</Button>
</Space.Compact>
<Text type="secondary">
</div>
<span className="customer-auth-form-hint">
如安全验证弹窗未出现,请检查浏览器是否拦截脚本。
</Text>
</Space>
</span>
</div>
),
},
{
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()}>
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
点击下方按钮后,使用微博 App 扫码并确认登录。
</p>
<Button
type="primary"
size="large"
block
onClick={() => void handleStartLogin()}
>
获取登录二维码
</Button>
</Space>
</div>
),
},
]}
/>
) : (
<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()}>
<div className="customer-auth-form">
<Button type="primary" size="large" block onClick={() => void handleStartLogin()}>
获取登录二维码
</Button>
</Space>
</div>
)}
</Card>
</section>
) : null}
{loginQrVisible ? (
<Card title={isWeibo ? '使用微博 App 扫码登录' : '使用虎牙 App 扫码登录'}>
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
<section className="customer-auth-section">
<div className="customer-auth-section-heading">
<span className="customer-auth-section-icon">
<QrcodeOutlined />
</span>
<div>
<h2>{isWeibo ? '使用微博 App 扫码' : '使用虎牙 App 扫码'}</h2>
<p>二维码仅用于本次订单授权,请勿转发给他人。</p>
</div>
</div>
<div className="customer-auth-qr">
{loginQrImage ? (
<Image
src={loginQrImage}
alt={isWeibo ? '微博登录二维码' : '虎牙登录二维码'}
width={260}
preview={false}
/>
<div className="customer-auth-qr-image">
<Image
src={loginQrImage}
alt={isWeibo ? '微博登录二维码' : '虎牙登录二维码'}
width={232}
preview={false}
/>
</div>
) : (
<Spin tip="生成二维码..." />
<div className="customer-auth-qr-placeholder">
<Spin />
<span>正在生成二维码...</span>
</div>
)}
<Text type="secondary">{loginInfo?.message || '等待扫码'}</Text>
<p>{loginInfo?.message || '等待扫码'}</p>
{selectedTask.loginStatus === 'scanned' ? (
<Tag color="cyan">
<span className="customer-auth-status-pill is-scanned">
{isWeibo ? '已扫码,请在微博客户端确认登录' : '已扫码,请在 App 确认'}
</Tag>
</span>
) : null}
<Button onClick={() => void handleStartLogin()}>重新获取二维码</Button>
</Space>
</Card>
<Button
className="customer-auth-secondary-button"
onClick={() => void handleStartLogin()}
>
重新获取二维码
</Button>
</div>
</section>
) : null}
{bindingQrVisible ? (
<Card
title={
selectedTask.bindingStatus === 'role_ready'
? '第二步:确认游戏角色'
: '第二步:扫码绑定游戏角色'
}
>
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
<section className="customer-auth-section">
<div className="customer-auth-section-heading">
<span className="customer-auth-section-icon">
<SafetyCertificateOutlined />
</span>
<div>
<h2>
{selectedTask.bindingStatus === 'role_ready' ? '确认绑定角色' : '扫码绑定角色'}
</h2>
<p>
{selectedTask.bindingStatus === 'role_ready'
? '当前账号已有绑定角色,请确认是否继续使用;如需更换,请重新扫码换绑。'
: '请使用虎牙 App 扫码完成游戏角色绑定。'}
</p>
</div>
</div>
<div className="customer-auth-qr">
{bindingQrPending && bindingInfo?.qrImage ? (
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={260} />
<div className="customer-auth-qr-image">
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={232} />
</div>
) : bindingQrPending ? (
<Spin tip="生成绑定二维码..." />
<div className="customer-auth-qr-placeholder">
<Spin />
<span>正在生成绑定二维码...</span>
</div>
) : null}
<Text type="secondary">
<p>
{bindingInfo?.message ||
config.bindingInstructions ||
'请使用虎牙 App 扫码完成游戏角色绑定。'}
</Text>
'扫码完成后,请回到本页面确认角色。'}
</p>
{bindingRole ? (
<Alert
type={roleChoiceRequired ? 'warning' : 'success'}
showIcon
message={
roleChoiceRequired
? `当前已绑定角色:${bindingRole}`
: `已识别角色:${bindingRole}`
}
description={[bindingAccount, bindingChannel].filter(Boolean).join(' · ')}
/>
<div className="customer-auth-role-card">
<span>{roleChoiceRequired ? '当前已绑定角色' : '已识别角色'}</span>
<strong>{bindingRole}</strong>
<small>{[bindingAccount, bindingChannel].filter(Boolean).join(' · ')}</small>
</div>
) : null}
{selectedTask.bindingStatus === 'role_ready' ? (
<Space direction="vertical" align="center" size={10}>
<div className="customer-auth-button-stack">
<Button
type="primary"
size="large"
block
loading={bindingActionLoading}
onClick={() => void handleConfirmBinding()}
>
{roleChoiceRequired
? '使用当前角色'
: bindingInfo?.rebindRequested
? '确认换绑角色'
: '确认绑定角色'}
</Button>
{roleChoiceRequired ? (
<Text type="secondary">
请确认这是本次需要的游戏角色;角色不正确时请重新扫码换绑。
</Text>
) : null}
<Space wrap>
<Button
type="primary"
className="customer-auth-secondary-button"
size="large"
block
loading={bindingActionLoading}
onClick={() => void handleConfirmBinding()}
onClick={() => void handleStartRebind()}
>
{roleChoiceRequired
? '使用当前角色'
: bindingInfo?.rebindRequested
? '确认换绑角色'
: '确认绑定角色'}
换绑其他角色
</Button>
{roleChoiceRequired ? (
<Button
danger
loading={bindingActionLoading}
onClick={() => void handleStartRebind()}
>
角色不对,重新扫码换绑
</Button>
) : null}
</Space>
</Space>
) : null}
</div>
) : null}
</Space>
</Card>
</div>
</section>
) : null}
{selectedTask?.bindingStatus === 'confirmed' &&
selectedTask.executionStatus !== 'completed' ? (
<Card>
<Result
status="info"
title={isWeibo ? '微博账号已授权,客服正在处理' : '账号已绑定,客服正在处理'}
subTitle={
isWeibo
? '客服会使用本次授权完成微博发帖,请稍后刷新页面查看结果。'
: '客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。'
}
/>
</Card>
<div className="customer-auth-state is-waiting">
<div className="customer-auth-state-icon">
<LoadingOutlined spin />
</div>
<h2>{isWeibo ? '微博账号已授权' : '账号角色已绑定'}</h2>
<p>
{isWeibo
? '客服正在使用本次授权完成微博发帖,页面会自动更新。'
: '客服正在处理本订单,页面会自动更新。'}
</p>
<Button
className="customer-auth-secondary-button"
icon={<ReloadOutlined />}
onClick={() => void load()}
>
刷新状态
</Button>
</div>
) : null}
</Space>
<CustomerPrivacyNote />
</div>
</CustomerAuthFrame>
)
}
function CustomerAuthFrame(input: {
platform?: CustomerSemiAutoTask['platform']
eyebrow: string
title: string
children: ReactNode
}) {
const platformClass =
input.platform === 'weibo' ? 'is-weibo' : input.platform === 'huya' ? 'is-huya' : ''
return (
<div className={`customer-auth-page ${platformClass}`}>
<div className="customer-auth-shell">
<header className="customer-auth-hero">
<div className="customer-auth-brand">
<span className="customer-auth-logo">
<QrcodeOutlined />
</span>
<div>
<span className="customer-auth-eyebrow">{input.eyebrow}</span>
<h1>{input.title}</h1>
</div>
</div>
</header>
<div className="customer-auth-warning">温馨提示:请使用本人账号完成授权</div>
<main className="customer-auth-main">{input.children}</main>
</div>
</div>
)
}
function CustomerTaskFacts(input: { task: CustomerSemiAutoTask | null }) {
const task = input.task
if (!task) return null
const statusTone =
task.displayStatus.key === 'credential_expired' || task.displayStatus.key === 'failed'
? 'is-danger'
: task.bindingStatus === 'confirmed'
? 'is-success'
: 'is-pending'
return (
<div className="customer-auth-facts">
<div>
<span>订单号</span>
<strong>{task.orderNo}</strong>
</div>
<div>
<span>商品</span>
<strong>
{task.productName || task.skuName} × {task.quantity}
</strong>
</div>
<div>
<span>平台</span>
<strong>{task.platformLabel || task.platform}</strong>
</div>
{task.huyaUid ? (
<div>
<span>平台账号 ID</span>
<strong>{task.huyaUid}</strong>
</div>
) : null}
{task.platform === 'weibo' && task.platformNickname ? (
<div>
<span>账号昵称</span>
<strong>{task.platformNickname}</strong>
</div>
) : null}
{task.platform === 'huya' && task.bindGameRole ? (
<div>
<span>绑定角色</span>
<strong>{task.bindGameRole}</strong>
</div>
) : null}
{task.platform === 'huya' && task.bindGameChannel ? (
<div>
<span>区服 / 渠道</span>
<strong>{task.bindGameChannel}</strong>
</div>
) : null}
<div>
<span>状态</span>
<strong className={`customer-auth-status-pill ${statusTone}`}>
{task.displayStatus.label}
</strong>
</div>
</div>
)
}
function CustomerPrivacyNote() {
return (
<div className="customer-auth-privacy">
资料仅用于本次订单履约,请勿向他人泄露验证码或账号信息。
</div>
)
}
@@ -795,16 +881,3 @@ function isValidWeiboMobile(countryCode: string, value: string) {
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
}