收敛半自动平台为登录获取 CK 流程

移除角色绑定对客户页、状态展示和完成门禁的阻塞;虎牙与微博统一保存并直接导出 CK;同步精简客服工作台、配置项和权威文档。
This commit is contained in:
yml
2026-11-14 01:13:00 +08:00
parent 8a54c41476
commit d178556f03
22 changed files with 526 additions and 732 deletions
@@ -48,7 +48,6 @@ import {
} from '@/services/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'
const { Text } = Typography
@@ -290,24 +289,19 @@ export default function AdminSemiAutoTasksPage() {
}
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: 'copy',
icon: <KeyOutlined />,
label: '复制最终 CK',
disabled: !task.credentialReady || credentialExportMutation.isPending,
},
{
key: 'inject',
icon: <ChromeOutlined />,
label: '注入浏览器',
disabled: task.bindingStatus !== 'confirmed' || injectionMutation.isPending,
disabled: !taskCanUseCredential(task) || injectionMutation.isPending,
},
],
onClick: ({ key }: { key: string }) => {
@@ -438,20 +432,19 @@ export default function AdminSemiAutoTasksPage() {
),
},
{
title: '授权 / 绑定角色',
key: 'binding',
title: '账号凭证',
key: 'credential',
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>
),
render: (_, task) => (
<Space direction="vertical" size={2}>
<Text strong>{task.platformNickname || task.huyaUid || '未获取账号'}</Text>
<Text type="secondary">
{task.credentialSource
? `${task.credentialSource} · ${formatAdminDateTime(task.credentialUpdatedAt)}`
: '尚无 CK'}
</Text>
</Space>
),
},
{
title: '客服',
@@ -497,7 +490,7 @@ export default function AdminSemiAutoTasksPage() {
size="small"
type="primary"
icon={<CheckCircleOutlined />}
disabled={task.bindingStatus !== 'confirmed'}
disabled={!taskCanUseCredential(task)}
onClick={() => openComplete(task)}
>
完成
@@ -649,23 +642,12 @@ export default function AdminSemiAutoTasksPage() {
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
{detail.task.bindGameAccount || '-'}
<Descriptions.Item label="平台昵称">
{detail.task.platformNickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="登录状态">
{detail.task.loginStatus === 'authenticated' ? 'CK 已获取' : '等待客户登录'}
</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>
@@ -800,3 +782,12 @@ function taskNeedsCustomerReauth(task: SemiAutoTask) {
task.loginStatus === 'expired'
)
}
function taskCanUseCredential(task: SemiAutoTask) {
return (
task.loginStatus === 'authenticated' &&
task.credentialReady &&
task.credentialStatus !== 'invalid' &&
task.credentialStatus !== 'error'
)
}
@@ -820,7 +820,7 @@ function resolveTaskListBlocker(item: AdminTaskListItem): TaskListBlocker {
level: 'normal',
detail: item.roleConfirmedAt
? '客户已提交角色信息,等待平台履约收口。'
: '等待客户打开链接、绑定或提交信息。',
: '等待客户授权、绑定或提交信息。',
showDetail: true,
}
}
@@ -72,7 +72,6 @@ type ConfigFormValues = {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
}
}
@@ -694,9 +693,6 @@ export function ManualSkuConfigPanel() {
<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
}
@@ -5,44 +5,27 @@ import {
ReloadOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons'
import { App, Button, Image, Input, Select, Space, Spin, Tabs } from 'antd'
import { App, Button, Image, Input, Spin, Tabs } from 'antd'
import QRCode from 'qrcode'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } 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 type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment'
import { formatAdminDateTime } from '@/utils/admin-time'
import { runWeiboYidunChallenge } from '@/utils/weibo-yidun'
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 }>()
@@ -52,22 +35,14 @@ export default function CustomerFulfillmentPage() {
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,
@@ -131,27 +106,11 @@ export default function CustomerFulfillmentPage() {
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
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)
@@ -199,86 +158,6 @@ export default function CustomerFulfillmentPage() {
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)
@@ -291,7 +170,6 @@ export default function CustomerFulfillmentPage() {
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 {
@@ -302,13 +180,13 @@ export default function CustomerFulfillmentPage() {
async function handleSendWeiboSms() {
if (!selectedTask) return
const phone = mobile.trim()
if (!isValidWeiboMobile(countryCode, phone)) {
if (!isValidWeiboMobile(phone)) {
message.warning('请输入正确的手机号')
return
}
setSmsSending(true)
try {
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, '86')
if (response.data.mfaRequired) {
setSmsMfaRequired(true)
if (!response.data.mfaId || !response.data.captchaId) {
@@ -319,7 +197,7 @@ export default function CustomerFulfillmentPage() {
orderNo,
selectedTask.id,
phone,
countryCode,
'86',
response.data.mfaId,
)
if (response.data.mfaRequired) {
@@ -339,7 +217,7 @@ export default function CustomerFulfillmentPage() {
async function handleLoginWeiboSms() {
if (!selectedTask) return
const phone = mobile.trim()
if (!isValidWeiboMobile(countryCode, phone)) {
if (!isValidWeiboMobile(phone)) {
message.warning('请输入正确的手机号')
return
}
@@ -352,13 +230,12 @@ export default function CustomerFulfillmentPage() {
const response = await loginCustomerWeiboSms(orderNo, selectedTask.id, {
mobile: phone,
code: smsCode.trim(),
countryCode,
countryCode: '86',
})
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 || ''
@@ -373,57 +250,11 @@ export default function CustomerFulfillmentPage() {
}
}
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)
}
}
const config = selectedTask?.semiAutoConfig || {}
const title = config.pageTitle || (isWeibo ? '微博账号授权' : '账号授权与角色绑定')
const displayTitle = isWeibo ? '微博账号登录授权' : title
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
if (loading) {
return (
@@ -452,7 +283,7 @@ export default function CustomerFulfillmentPage() {
<CustomerAuthFrame
platform={selectedTask.platform}
eyebrow={isWeibo ? '微博账号授权' : '虎牙账号授权'}
title={title}
title={displayTitle}
>
<CustomerTaskFacts task={selectedTask} />
<div className="customer-auth-state is-success">
@@ -481,7 +312,7 @@ export default function CustomerFulfillmentPage() {
<CustomerAuthFrame
platform={selectedTask?.platform}
eyebrow={isWeibo ? '微博账号授权' : '虎牙账号授权'}
title={title}
title={displayTitle}
>
<div className="customer-auth-content-stack">
{tasks.length > 1 ? (
@@ -495,7 +326,6 @@ export default function CustomerFulfillmentPage() {
onClick={() => {
setSelectedTaskId(task.id)
setLoginInfo(null)
setBindingInfo(null)
}}
>
{task.productName || task.skuName} × {task.quantity}
@@ -511,20 +341,6 @@ export default function CustomerFulfillmentPage() {
selectedTask?.loginStatus === 'failed' ||
selectedTask?.loginStatus === 'expired' ? (
<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"
@@ -536,46 +352,23 @@ export default function CustomerFulfillmentPage() {
label: '手机号登录',
children: (
<div className="customer-auth-form">
<div className="customer-auth-inline-input">
<Select
value={countryCode}
onChange={(value) => setCountryCode(String(value))}
className="customer-auth-country-code"
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>
<span className="customer-auth-option-hint">
+{option.data.value}
</span>
</Space>
)}
filterOption={(input, option) =>
String(
(option as { name?: string } | undefined)?.name || '',
).includes(input) || String(option?.value || '').includes(input)
}
showSearch
popupMatchSelectWidth={240}
/>
<div className="customer-auth-field customer-auth-phone-field">
<span className="customer-auth-country-prefix">+86</span>
<Input
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder="请输入微博绑定手机号"
maxLength={countryCode === '86' ? 11 : 15}
maxLength={11}
inputMode="numeric"
/>
<Button
className="customer-auth-send-code"
type="primary"
loading={smsSending}
disabled={smsCountdown > 0}
onClick={() => void handleSendWeiboSms()}
>
{smsCountdown > 0 ? `${smsCountdown}s 后重发` : '发送验证码'}
{smsCountdown > 0 ? `${smsCountdown}s` : '发送验证码'}
</Button>
</div>
{smsMfaRequired ? (
@@ -583,16 +376,18 @@ export default function CustomerFulfillmentPage() {
请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
</div>
) : null}
<div className="customer-auth-inline-input is-code">
<div className="customer-auth-field customer-auth-code-field">
<Input
value={smsCode}
onChange={(event) => setSmsCode(event.target.value)}
placeholder="请输入短信验证码"
maxLength={8}
prefix={<SafetyCertificateOutlined />}
onPressEnter={() => void handleLoginWeiboSms()}
/>
<Button
type="primary"
className="customer-auth-code-submit"
loading={smsLogging}
onClick={() => void handleLoginWeiboSms()}
>
@@ -628,6 +423,9 @@ export default function CustomerFulfillmentPage() {
/>
) : (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
{config.loginInstructions || '使用虎牙 App 扫码登录,请确认扫码账号属于本人。'}
</p>
<Button type="primary" size="large" block onClick={() => void handleStartLogin()}>
获取登录二维码
</Button>
@@ -679,90 +477,14 @@ export default function CustomerFulfillmentPage() {
</section>
) : null}
{bindingQrVisible ? (
<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 ? (
<div className="customer-auth-qr-image">
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={232} />
</div>
) : bindingQrPending ? (
<div className="customer-auth-qr-placeholder">
<Spin />
<span>正在生成绑定二维码...</span>
</div>
) : null}
<p>
{bindingInfo?.message ||
config.bindingInstructions ||
'扫码完成后,请回到本页面确认角色。'}
</p>
{bindingRole ? (
<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' ? (
<div className="customer-auth-button-stack">
<Button
type="primary"
size="large"
block
loading={bindingActionLoading}
onClick={() => void handleConfirmBinding()}
>
{roleChoiceRequired
? '使用当前角色'
: bindingInfo?.rebindRequested
? '确认换绑角色'
: '确认绑定角色'}
</Button>
{roleChoiceRequired ? (
<Button
className="customer-auth-secondary-button"
size="large"
block
loading={bindingActionLoading}
onClick={() => void handleStartRebind()}
>
换绑其他角色
</Button>
) : null}
</div>
) : null}
</div>
</section>
) : null}
{selectedTask?.bindingStatus === 'confirmed' &&
{selectedTask?.loginStatus === 'authenticated' &&
selectedTask.executionStatus !== 'completed' ? (
<div className="customer-auth-state is-waiting">
<div className="customer-auth-state-icon">
<LoadingOutlined spin />
</div>
<h2>{isWeibo ? '微博账号已授权' : '账号角色已绑定'}</h2>
<p>
{isWeibo
? '客服正在使用本次授权完成微博发帖,页面会自动更新。'
: '客服正在处理本订单,页面会自动更新。'}
</p>
<h2>客服正在安排</h2>
<p>请耐心等待</p>
<Button
className="customer-auth-secondary-button"
icon={<ReloadOutlined />}
@@ -792,16 +514,19 @@ function CustomerAuthFrame(input: {
<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>
{input.platform !== 'weibo' ? (
<span className="customer-auth-eyebrow">{input.eyebrow}</span>
) : null}
<h1>{input.title}</h1>
</div>
</div>
</header>
<div className="customer-auth-warning">温馨提示:请使用本人账号完成授权</div>
<div className="customer-auth-warning">
{input.platform === 'weibo'
? '温馨提示:未成年人禁止下单'
: '温馨提示:请使用本人账号完成授权'}
</div>
<main className="customer-auth-main">{input.children}</main>
</div>
</div>
@@ -814,9 +539,10 @@ function CustomerTaskFacts(input: { task: CustomerSemiAutoTask | null }) {
const statusTone =
task.displayStatus.key === 'credential_expired' || task.displayStatus.key === 'failed'
? 'is-danger'
: task.bindingStatus === 'confirmed'
: task.loginStatus === 'authenticated'
? 'is-success'
: 'is-pending'
const showAccountDetails = ['authenticated', 'scanned'].includes(task.loginStatus)
return (
<div className="customer-auth-facts">
<div>
@@ -833,30 +559,18 @@ function CustomerTaskFacts(input: { task: CustomerSemiAutoTask | null }) {
<span>平台</span>
<strong>{task.platformLabel || task.platform}</strong>
</div>
{task.huyaUid ? (
{showAccountDetails && task.huyaUid ? (
<div>
<span>平台账号 ID</span>
<strong>{task.huyaUid}</strong>
</div>
) : null}
{task.platform === 'weibo' && task.platformNickname ? (
{showAccountDetails && 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}`}>
@@ -875,9 +589,8 @@ function CustomerPrivacyNote() {
)
}
function isValidWeiboMobile(countryCode: string, value: string) {
function isValidWeiboMobile(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
return /^1\d{10}$/.test(digits)
}
@@ -1,9 +1,5 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
CustomerBindingInfo,
CustomerLoginInfo,
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
import type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment'
export function fetchCustomerFulfillment(orderNo: string) {
return apiGet<CustomerSemiAutoTask[]>(
@@ -31,30 +27,6 @@ export function pollCustomerLogin(orderNo: string, taskId: number) {
)
}
export function startCustomerBinding(
orderNo: string,
taskId: number,
options: { forceRebind?: boolean } = {},
) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/start`,
options.forceRebind ? { forceRebind: true } : {},
)
}
export function pollCustomerBinding(orderNo: string, taskId: number) {
return apiGet<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo | null }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/status`,
)
}
export function confirmCustomerBinding(orderNo: string, taskId: number) {
return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/confirm`,
{},
)
}
export type CustomerWeiboCountryCode = {
code: string
name: string
+228 -89
View File
@@ -1453,11 +1453,6 @@
background: #202124;
}
.customer-auth-page.is-weibo {
--auth-accent: #ff5c9a;
--auth-accent-hover: #ff78aa;
}
.customer-auth-shell {
width: min(760px, 100%);
min-width: 0;
@@ -1658,6 +1653,21 @@
line-height: 1.75;
}
.customer-auth-section-copy h2 {
margin: 0 0 8px;
color: #fff;
font-size: 23px;
line-height: 1.35;
font-weight: 700;
}
.customer-auth-section-copy p {
margin: 0;
color: var(--auth-muted);
font-size: 15px;
line-height: 1.75;
}
.customer-auth-tabs {
margin-top: 22px;
}
@@ -1667,24 +1677,32 @@
}
.customer-auth-tabs .ant-tabs-nav::before {
border-bottom-color: var(--auth-line);
display: none;
}
.customer-auth-tabs .ant-tabs-nav-list {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
padding: 5px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 11px;
background: rgba(4, 15, 37, 0.72);
}
.customer-auth-tabs .ant-tabs-tab {
padding: 12px 30px;
justify-content: center;
margin: 0 !important;
padding: 11px 18px;
color: var(--auth-muted);
background: rgba(255, 255, 255, 0.025);
border: 1px solid var(--auth-line) !important;
border-radius: 4px 4px 0 0;
}
.customer-auth-tabs .ant-tabs-tab + .ant-tabs-tab {
margin-left: 4px;
background: transparent;
border: 0 !important;
border-radius: 7px;
}
.customer-auth-tabs .ant-tabs-tab.ant-tabs-tab-active {
border-color: rgba(255, 255, 255, 0.18) !important;
background: rgba(255, 255, 255, 0.09);
background: var(--auth-accent);
}
.customer-auth-tabs .ant-tabs-tab-btn {
@@ -1692,8 +1710,13 @@
font-size: 16px;
}
.customer-auth-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: #111827 !important;
font-weight: 700;
}
.customer-auth-tabs .ant-tabs-ink-bar {
background: var(--auth-accent);
display: none;
}
.customer-auth-form {
@@ -1703,73 +1726,129 @@
padding-top: 18px;
}
.customer-auth-inline-input {
.customer-auth-field {
display: grid;
grid-template-columns: 104px minmax(0, 1fr) 126px;
align-items: center;
min-height: 50px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 5px;
background: rgba(6, 18, 43, 0.7);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 10px;
background: rgba(13, 29, 61, 0.72);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.customer-auth-inline-input.is-code {
grid-template-columns: minmax(0, 1fr) 126px;
.customer-auth-field:focus-within {
border-color: var(--auth-accent);
box-shadow: 0 0 0 3px rgba(255, 138, 0, 0.12);
}
.customer-auth-inline-input > * {
.customer-auth-field > * {
min-width: 0;
}
.customer-auth-inline-input .ant-select-selector,
.customer-auth-inline-input .ant-input,
.customer-auth-inline-input .ant-input-affix-wrapper {
.customer-auth-phone-field {
grid-template-columns: 58px minmax(0, 1fr) 112px;
padding: 4px;
}
.customer-auth-code-field {
grid-template-columns: minmax(0, 1fr) 122px;
gap: 5px;
padding: 5px;
}
.customer-auth-field .ant-select-selector,
.customer-auth-field .ant-input,
.customer-auth-field .ant-input-affix-wrapper {
color: #f8fafc !important;
border: 0 !important;
border-radius: 0 !important;
background: rgba(6, 18, 43, 0.7) !important;
background: transparent !important;
box-shadow: none !important;
}
.customer-auth-inline-input .ant-select-selector {
.customer-auth-field .ant-select-selector {
height: 48px !important;
padding-inline: 14px !important;
align-items: center;
}
.customer-auth-inline-input .ant-select-selection-item {
color: #f8fafc !important;
.customer-auth-field .ant-select {
height: 48px;
background: transparent !important;
}
.customer-auth-inline-input .ant-input {
.customer-auth-field .ant-select-selection-item {
color: #f8fafc !important;
line-height: 46px !important;
}
.customer-auth-field .ant-select-arrow {
color: #c4cede !important;
}
.customer-auth-country-prefix {
display: grid;
place-items: center;
align-self: stretch;
color: #f8fafc;
font-size: 14px;
font-weight: 600;
border-right: 1px solid var(--auth-line);
}
.customer-auth-field .ant-input-affix-wrapper {
height: 48px;
padding-inline: 16px;
padding-inline: 15px;
}
.customer-auth-field .ant-input {
height: 48px;
padding-inline: 10px;
font-size: 15px;
}
.customer-auth-inline-input .ant-input::placeholder {
.customer-auth-field .ant-input-affix-wrapper .ant-input {
padding-inline: 0;
}
.customer-auth-field .ant-input::placeholder {
color: #7f8ca5;
}
.customer-auth-inline-input .ant-select,
.customer-auth-inline-input .ant-input {
border-right: 1px solid var(--auth-line) !important;
.customer-auth-field .ant-input-prefix {
margin-inline-end: 9px;
color: var(--auth-accent);
}
.customer-auth-inline-input .ant-btn {
height: 48px;
color: #fff;
border: 0;
border-radius: 0;
background: rgba(48, 75, 124, 0.55);
}
.customer-auth-inline-input .ant-btn-primary {
.customer-auth-send-code {
height: 40px !important;
padding-inline: 10px !important;
color: #111827;
background: var(--auth-accent);
border: 0 !important;
border-radius: 7px !important;
background: var(--auth-accent) !important;
font-size: 14px !important;
font-weight: 700;
}
.customer-auth-option-hint {
color: var(--auth-muted);
.customer-auth-send-code:hover {
background: var(--auth-accent-hover) !important;
}
.customer-auth-code-submit {
height: 40px !important;
color: #111827;
border: 0 !important;
border-radius: 7px !important;
background: var(--auth-accent) !important;
font-weight: 700;
}
.customer-auth-code-submit:hover {
background: var(--auth-accent-hover) !important;
}
.customer-auth-note {
@@ -1983,34 +2062,31 @@
@media (max-width: 640px) {
.customer-auth-page {
height: 100dvh;
min-height: 0;
overflow: hidden;
padding: 0;
background: var(--auth-panel);
}
.customer-auth-shell {
width: 100%;
min-height: 100vh;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
border: 0;
border-radius: 0;
box-shadow: none;
}
.customer-auth-hero {
min-height: 150px;
padding: 26px 22px;
min-height: 116px;
flex: none;
padding: 20px 22px;
background-position: 58% center;
}
.customer-auth-logo {
width: 54px;
height: 54px;
font-size: 29px;
}
.customer-auth-brand {
gap: 14px;
}
.customer-auth-eyebrow {
font-size: 13px;
}
@@ -2020,8 +2096,20 @@
}
.customer-auth-warning {
padding: 16px 14px;
font-size: 17px;
flex: none;
padding: 12px 14px;
font-size: 16px;
}
.customer-auth-main {
flex: 1;
min-height: 0;
overflow: hidden;
}
.customer-auth-content-stack {
height: 100%;
min-height: 0;
}
.customer-auth-facts {
@@ -2031,55 +2119,104 @@
.customer-auth-facts > div {
grid-template-columns: 92px minmax(0, 1fr);
gap: 12px;
min-height: 68px;
padding-block: 14px;
min-height: 52px;
padding-block: 8px;
}
.customer-auth-facts > div > span {
font-size: 14px;
font-size: 13px;
}
.customer-auth-facts > div > strong {
font-size: 16px;
font-size: 15px;
line-height: 1.35;
}
.customer-auth-section {
padding: 24px 18px 27px;
padding: 14px 16px 10px;
}
.customer-auth-section-heading h2,
.customer-auth-section-copy h2,
.customer-auth-state h2 {
font-size: 20px;
}
.customer-auth-section-heading p,
.customer-auth-section-copy p,
.customer-auth-state p,
.customer-auth-form-copy {
font-size: 14px;
font-size: 13px;
line-height: 1.5;
}
.customer-auth-section-icon {
width: 38px;
height: 38px;
font-size: 20px;
.customer-auth-tabs {
margin-top: 0;
}
.customer-auth-inline-input,
.customer-auth-inline-input.is-code {
grid-template-columns: 94px minmax(0, 1fr);
.customer-auth-tabs .ant-tabs-nav {
margin-bottom: 10px;
}
.customer-auth-inline-input > .ant-btn {
grid-column: 1 / -1;
border-top: 1px solid var(--auth-line);
.customer-auth-tabs .ant-tabs-nav-list {
gap: 4px;
padding: 4px;
}
.customer-auth-inline-input.is-code {
grid-template-columns: minmax(0, 1fr);
.customer-auth-tabs .ant-tabs-tab {
padding: 9px 14px;
}
.customer-auth-inline-input.is-code > .ant-btn {
grid-column: auto;
.customer-auth-tabs .ant-tabs-tab-btn {
font-size: 15px;
}
.customer-auth-form {
gap: 10px;
padding-top: 0;
}
.customer-auth-field {
min-height: 44px;
border-radius: 9px;
}
.customer-auth-phone-field {
grid-template-columns: 50px minmax(0, 1fr) 104px;
}
.customer-auth-code-field {
grid-template-columns: minmax(0, 1fr) 104px;
}
.customer-auth-field .ant-select,
.customer-auth-field .ant-select-selector,
.customer-auth-field .ant-input,
.customer-auth-field .ant-input-affix-wrapper {
height: 44px;
}
.customer-auth-field .ant-select-selection-item {
line-height: 42px !important;
}
.customer-auth-field .ant-input-affix-wrapper {
padding-inline: 12px;
}
.customer-auth-code-field {
padding: 4px;
}
.customer-auth-code-submit {
height: 36px !important;
}
.customer-auth-send-code {
height: 36px !important;
}
.customer-auth-form-hint {
font-size: 10px;
line-height: 1.35;
}
.customer-auth-qr-image,
@@ -2089,7 +2226,9 @@
}
.customer-auth-privacy {
margin: 20px 18px 24px;
font-size: 13px;
margin: 8px 16px 12px;
padding: 8px 12px;
font-size: 12px;
line-height: 1.45;
}
}
@@ -436,7 +436,6 @@ export interface ManualFulfillmentConfig {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
browserTargets?: Array<{ key: string; label: string; url: string }>
}
note?: string
@@ -13,7 +13,6 @@ export type SemiAutoTask = {
platform: SemiAutoPlatform
platformLabel: string
loginStatus: string
bindingStatus: string
executionStatus: string
connectorMode: 'embedded' | 'external' | 'mock'
displayStatus: SemiAutoDisplayStatus
@@ -24,9 +23,6 @@ export type SemiAutoTask = {
credentialUpdatedAt: string | null
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
targetQuantity: number
completedQuantity: number
assignedAdminUserId: number | null
@@ -54,7 +50,6 @@ export type SemiAutoTask = {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
browserTargets?: Array<{ key: string; label: string; url: string }>
}
}
@@ -8,21 +8,16 @@ export type CustomerSemiAutoTask = {
skuCode: string
quantity: number
loginStatus: string
bindingStatus: string
executionStatus: string
displayStatus: { key: string; label: string }
huyaUid: string
platformNickname: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
completedAt: string | null
completionNote: string
semiAutoConfig: {
pageTitle?: string
pageDescription?: string
loginInstructions?: string
bindingInstructions?: string
}
}
@@ -33,15 +28,3 @@ export type CustomerLoginInfo = {
expiresAt: string
message: string
}
export type CustomerBindingInfo = {
status: string
qrImage: string
qrExpiresAt: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
hasExistingBinding?: boolean
rebindRequested?: boolean
message: string
}