feat(semi-auto): 支持微博半自动履约与手机号国家码登录

新增微博内置 Connector:扫码/短信登录、Cookie 获取与一次性浏览器票据;客户页支持国家码选择、短信/扫码登录及登录即授权;dev-mock 与后台任务页适配微博 Mock 单。
This commit is contained in:
2026-11-10 19:05:05 +08:00
parent 08c3834a1c
commit 90173e3bd7
11 changed files with 1980 additions and 138 deletions
@@ -304,6 +304,7 @@ export default function AdminDevMockPage() {
<Col xs={24} md={12}>
<Typography.Text type="secondary">内部 SKU / 任务</Typography.Text>
<div>
{lastSemiAuto.semiAutoPlatform === 'weibo' ? '微博' : '虎牙'} ·{' '}
{lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId}
</div>
</Col>
@@ -525,6 +526,7 @@ function SemiAutoMockForm({
externalConnectorConfigured?: boolean
mockConnectorAvailable?: boolean
onSubmit: (values: {
platform: 'huya' | 'weibo'
orderNo?: string
skuName?: string
quantity?: number
@@ -534,6 +536,8 @@ function SemiAutoMockForm({
}) => void
}) {
const [form] = Form.useForm()
const selectedPlatform =
(Form.useWatch('platform', form) as 'huya' | 'weibo' | undefined) || 'huya'
const defaultMode =
defaultConnectorMode === 'mock' && mockConnectorAvailable
? 'mock'
@@ -549,6 +553,7 @@ function SemiAutoMockForm({
form={form}
layout="vertical"
initialValues={{
platform: 'huya',
quantity: 1,
pageTitle: '虎牙账号授权测试',
browserTargetUrl: 'https://www.huya.com/',
@@ -557,37 +562,73 @@ function SemiAutoMockForm({
onFinish={onSubmit}
>
<Form.Item
label="Mock 范围"
name="connectorMode"
extra="只 Mock 订单时,登录、角色绑定和浏览器票据注入可走内置真实 Connector,也可显式使用远程 Connector。"
label="履约平台"
name="platform"
extra="微博 Mock 单登录成功即视为授权完成,不再有角色绑定步骤。"
>
<Select
options={[
{
value: 'embedded',
label: '仅 Mock 订单(内置真实登录 / 绑定 / 注入)',
},
{
value: 'external',
label: '仅 Mock 订单(远程真实 Connector)',
},
{
value: 'mock',
label: '订单 + 登录绑定都 Mock(内置 Connector)',
disabled: mockConnectorAvailable === false,
},
{ 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="留空自动生成“虎牙半自动测试-时间戳”;必须包含中文。"
extra={`留空自动生成“${
selectedPlatform === 'weibo' ? '微博半自动' : '虎牙半自动'
}测试-时间戳”;必须包含中文。`}
>
<Input allowClear placeholder="虎牙半自动测试" />
<Input
allowClear
placeholder={selectedPlatform === 'weibo' ? '微博半自动测试' : '虎牙半自动测试'}
/>
</Form.Item>
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
<Input allowClear placeholder="留空自动 MOCKSA…" />
<Input
allowClear
placeholder={selectedPlatform === 'weibo' ? '留空自动 MOCKWB…' : '留空自动 MOCKSA…'}
/>
</Form.Item>
<Space size={12} style={{ display: 'flex' }}>
<Form.Item label="数量" name="quantity" style={{ flex: 1 }}>
@@ -602,26 +643,34 @@ function SemiAutoMockForm({
name="browserTargetUrl"
extra="客服点击“打开浏览器登录态”时,扩展会注入到此页面。"
>
<Input placeholder="https://www.huya.com/" />
<Input
placeholder={
selectedPlatform === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/'
}
/>
</Form.Item>
<Alert
type={
selectedMode === 'mock'
? 'warning'
: selectedMode === 'external' && !externalConnectorConfigured
? 'error'
: 'success'
selectedPlatform === 'weibo'
? 'info'
: selectedMode === 'mock'
? 'warning'
: selectedMode === 'external' && !externalConnectorConfigured
? 'error'
: 'success'
}
showIcon
style={{ marginBottom: 12 }}
message={
selectedMode === 'mock'
? '登录和绑定会使用内置 Mock Connector,仅用于联调页面。'
: selectedMode === 'external'
? externalConnectorConfigured
? '远程真实 Connector 已配置;只有订单由 Mock 生成。'
: '远程真实 Connector 未配置:请设置 SEMI_AUTO_CONNECTOR_BASE_URL 和 API_KEY。'
: '使用 order_site 内置真实 Connector;无需启动 live-hub-py,只有订单由 Mock 生成。'
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>
@@ -290,7 +290,7 @@ export default function AdminSemiAutoTasksPage() {
),
},
{
title: '绑定角色',
title: '授权 / 绑定角色',
key: 'binding',
width: 190,
render: (_, task) =>
@@ -302,7 +302,7 @@ export default function AdminSemiAutoTasksPage() {
</Text>
</Space>
) : (
<Text type="secondary">未绑定</Text>
<Text type="secondary">{task.platform === 'weibo' ? '未授权' : '未绑定'}</Text>
),
},
{
@@ -481,19 +481,29 @@ export default function AdminSemiAutoTasksPage() {
{detail.task.displayStatus.label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="虎牙 UID">{detail.task.huyaUid || '-'}</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="游戏账号">
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
{detail.task.bindGameAccount || '-'}
</Descriptions.Item>
<Descriptions.Item label="游戏角色">
{detail.task.bindGameRole || '-'}
</Descriptions.Item>
<Descriptions.Item label="区服" span={2}>
{detail.task.bindGameChannel || '-'}
</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>
@@ -11,11 +11,14 @@ import {
Card,
Col,
Image,
Input,
Result,
Row,
Select,
Space,
Spin,
Steps,
Tabs,
Tag,
Typography,
} from 'antd'
@@ -26,12 +29,18 @@ 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,
@@ -43,6 +52,14 @@ 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 }>()
@@ -55,6 +72,17 @@ export default function CustomerFulfillmentPage() {
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)
@@ -62,6 +90,7 @@ export default function CustomerFulfillmentPage() {
() => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null,
[selectedTaskId, tasks],
)
const isWeibo = selectedTask?.platform === 'weibo'
const load = useCallback(
async (silent = false) => {
@@ -101,6 +130,21 @@ export default function CustomerFulfillmentPage() {
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)
@@ -117,12 +161,21 @@ export default function CustomerFulfillmentPage() {
}).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 = await pollCustomerLogin(orderNo, selectedTask.id)
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)),
@@ -137,10 +190,10 @@ export default function CustomerFulfillmentPage() {
void tick()
}, POLL_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [orderNo, selectedTask?.id, selectedTask?.loginStatus])
}, [orderNo, selectedTask?.id, selectedTask?.loginStatus, selectedTask?.platform])
useEffect(() => {
if (!selectedTask || !orderNo) return
if (!selectedTask || !orderNo || selectedTask.platform === 'weibo') return
if (
selectedTask.loginStatus !== 'authenticated' ||
selectedTask.bindingStatus === 'confirmed'
@@ -185,7 +238,14 @@ export default function CustomerFulfillmentPage() {
}, [message, orderNo, selectedTask?.bindingStatus, selectedTask?.id, selectedTask?.loginStatus])
useEffect(() => {
if (!selectedTask || !orderNo || bindingInfo || hydratingBindingRef.current) return
if (
!selectedTask ||
!orderNo ||
selectedTask.platform === 'weibo' ||
bindingInfo ||
hydratingBindingRef.current
)
return
if (
selectedTask.loginStatus !== 'authenticated' ||
selectedTask.bindingStatus !== 'role_ready'
@@ -216,7 +276,10 @@ export default function CustomerFulfillmentPage() {
if (!selectedTask) return
setLoading(true)
try {
const response = await startCustomerLogin(orderNo, selectedTask.id)
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)),
)
@@ -229,6 +292,69 @@ export default function CustomerFulfillmentPage() {
}
}
async function handleSendWeiboSms() {
if (!selectedTask) return
const phone = mobile.trim()
if (!isValidWeiboMobile(countryCode, phone)) {
message.warning('请输入正确的手机号')
return
}
setSmsSending(true)
try {
const response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
message.success(response.data.message || '验证码已发送,请注意查收')
setSmsMfaRequired(false)
setSmsCountdown(60)
} 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 {
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)
@@ -294,7 +420,9 @@ export default function CustomerFulfillmentPage() {
subTitle={
selectedTask.completionNote
? selectedTask.completionNote
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。'
: isWeibo
? '客服已完成本订单的微博发帖操作,请到微博账号中确认。'
: '客服已完成本订单的充值或赠送操作,请到平台账号中确认。'
}
extra={
selectedTask.completedAt
@@ -307,13 +435,17 @@ export default function CustomerFulfillmentPage() {
}
const config = selectedTask?.semiAutoConfig || {}
const title = config.pageTitle || '账号授权与角色绑定'
const title = config.pageTitle || (isWeibo ? '微博账号授权' : '账号授权与角色绑定')
const description =
config.pageDescription || '请按页面指引完成平台登录和游戏角色绑定,客服随后会处理您的订单。'
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 || '')
@@ -374,12 +506,20 @@ export default function CustomerFulfillmentPage() {
<Col xs={24} lg={16}>
<Steps
current={currentStep}
items={[
{ title: '登录平台', icon: <QrcodeOutlined /> },
{ title: '扫码绑定', icon: <SafetyCertificateOutlined /> },
{ title: '客服处理', icon: <LoadingOutlined /> },
{ title: '完成' },
]}
items={
isWeibo
? [
{ title: '登录微博', icon: <QrcodeOutlined /> },
{ title: '客服处理', icon: <LoadingOutlined /> },
{ title: '完成' },
]
: [
{ title: '登录平台', icon: <QrcodeOutlined /> },
{ title: '扫码绑定', icon: <SafetyCertificateOutlined /> },
{ title: '客服处理', icon: <LoadingOutlined /> },
{ title: '完成' },
]
}
/>
</Col>
</Row>
@@ -397,35 +537,160 @@ export default function CustomerFulfillmentPage() {
{selectedTask?.loginStatus === 'waiting_login' ||
selectedTask?.loginStatus === 'failed' ||
selectedTask?.loginStatus === 'expired' ? (
<Card title="第一步:登录平台账号">
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="请使用本人账号登录"
description={
config.loginInstructions ||
'点击下方按钮后使用虎牙 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
}
<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="warning"
showIcon
message="微博要求二次验证"
description="当前网络环境触发了微博人机验证,短信登录无法自动完成,请改用扫码登录。"
action={
<Button size="small" onClick={() => setLoginMode('qr')}>
去扫码
</Button>
}
/>
) : 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>
),
},
]}
/>
<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="使用虎牙 App 扫码登录">
<Card title={isWeibo ? '使用微博 App 扫码登录' : '使用虎牙 App 扫码登录'}>
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
{loginQrImage ? (
<Image src={loginQrImage} alt="虎牙登录二维码" width={260} preview={false} />
<Image
src={loginQrImage}
alt={isWeibo ? '微博登录二维码' : '虎牙登录二维码'}
width={260}
preview={false}
/>
) : (
<Spin tip="生成二维码..." />
)}
<Text type="secondary">{loginInfo?.message || '等待扫码'}</Text>
{selectedTask.loginStatus === 'scanned' ? (
<Tag color="cyan">已扫码,请在 App 确认</Tag>
<Tag color="cyan">
{isWeibo ? '已扫码,请在微博客户端确认登录' : '已扫码,请在 App 确认'}
</Tag>
) : null}
<Button onClick={() => void handleStartLogin()}>重新获取二维码</Button>
</Space>
@@ -503,8 +768,12 @@ export default function CustomerFulfillmentPage() {
<Card>
<Result
status="info"
title="账号已绑定,客服正在处理"
subTitle="客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。"
title={isWeibo ? '微博账号已授权,客服正在处理' : '账号已绑定,客服正在处理'}
subTitle={
isWeibo
? '客服会使用本次授权完成微博发帖,请稍后刷新页面查看结果。'
: '客服会使用本次授权完成充值或赠送,请稍后刷新页面查看结果。'
}
/>
</Card>
) : null}
@@ -513,8 +782,20 @@ export default function CustomerFulfillmentPage() {
)
}
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
@@ -53,6 +53,7 @@ export type DevMockKuaishouIndustryVoucherResult = {
export type DevMockSemiAutoResult = {
platform: 'semi_auto'
semiAutoPlatform: 'huya' | 'weibo'
orderId: number
orderNo: string
orderItemId: number
@@ -89,6 +90,7 @@ export function createKuaishouIndustryVoucherDevMock(payload: { sellerId?: strin
}
export function createSemiAutoDevMock(payload: {
platform?: 'huya' | 'weibo'
orderNo?: string
skuName?: string
quantity?: number
@@ -54,3 +54,49 @@ export function confirmCustomerBinding(orderNo: string, taskId: number) {
{},
)
}
export type CustomerWeiboCountryCode = {
code: string
name: string
}
export function fetchCustomerWeiboCountryCodes(orderNo: string, taskId: number) {
return apiGet<{ countryCodes: CustomerWeiboCountryCode[] }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/config`,
)
}
export function sendCustomerWeiboSmsCode(
orderNo: string,
taskId: number,
mobile: string,
countryCode: string,
) {
return apiPost<{ ok: boolean; message: string }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`,
{ mobile, countryCode },
)
}
export function loginCustomerWeiboSms(
orderNo: string,
taskId: number,
input: { mobile: string; code: string; countryCode: string },
) {
return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/login`,
input,
)
}
export function startCustomerWeiboQrLogin(orderNo: string, taskId: number) {
return apiGet<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/qrcode`,
)
}
export function pollCustomerWeiboQrLogin(orderNo: string, taskId: number) {
return apiGet<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo | null }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/qrcode/status`,
)
}