diff --git a/apps/frontend-react/src/App.tsx b/apps/frontend-react/src/App.tsx index 61111624..721e29d4 100644 --- a/apps/frontend-react/src/App.tsx +++ b/apps/frontend-react/src/App.tsx @@ -6,11 +6,14 @@ import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' import AdminLayout from '@/layouts/AdminLayout' const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')) +const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage')) const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage')) const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage')) const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage')) const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage')) +const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage')) +const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) function RequireAdmin() { @@ -55,14 +58,14 @@ export default function App() { } /> } /> }> - } /> + } /> } /> } /> - } /> + } /> - } /> + } /> } /> diff --git a/apps/frontend-react/src/pages/admin/AdminAuditLogsPage.tsx b/apps/frontend-react/src/pages/admin/AdminAuditLogsPage.tsx new file mode 100644 index 00000000..8cea755d --- /dev/null +++ b/apps/frontend-react/src/pages/admin/AdminAuditLogsPage.tsx @@ -0,0 +1,231 @@ +import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { Alert, Button, Card, DatePicker, Empty, Form, Input, Select, Space, Table, Typography } from 'antd' +import type { TableColumnsType } from 'antd' +import { useQuery } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { useMemo } from 'react' +import { useSearchParams } from 'react-router' + +import PageHeader from '@/components/admin/PageHeader' +import StatusTag from '@/components/admin/StatusTag' +import { fetchAdminAuditLogs } from '@/services/admin' +import type { AdminAuditLogItem } from '@/types/admin' +import { hasAdminRole } from '@/utils/admin-auth' +import { formatAuditAction, formatAuditTargetType } from '@/utils/admin-display' +import { adminAuditActionOptions, adminAuditTargetTypeOptions } from '@/utils/admin-options' +import { formatAdminDateTime } from '@/utils/admin-time' +import { stringifyDisplayJson } from '@/utils/date-time' + +type AuditFilterForm = { + actorUsername?: string + action?: string + targetType?: string + dateRange?: unknown +} + +export default function AdminAuditLogsPage() { + const isAdmin = hasAdminRole('admin') + const [searchParams, setSearchParams] = useSearchParams() + const page = Number(searchParams.get('page') || 1) || 1 + const pageSize = Number(searchParams.get('pageSize') || 20) || 20 + const filters = { + actorUsername: searchParams.get('actorUsername') || '', + action: searchParams.get('action') || '', + targetType: searchParams.get('targetType') || '', + dateFrom: searchParams.get('dateFrom') || '', + dateTo: searchParams.get('dateTo') || '', + } + const queryParams = useMemo( + () => ({ page, pageSize, ...filters }), + [ + filters.action, + filters.actorUsername, + filters.dateFrom, + filters.dateTo, + filters.targetType, + page, + pageSize, + ], + ) + const query = useQuery({ + queryKey: ['admin-audit-logs', queryParams], + enabled: isAdmin, + queryFn: () => fetchAdminAuditLogs(queryParams), + }) + const data = query.data?.data + + const columns: TableColumnsType = [ + { + title: '时间', + dataIndex: 'createdAt', + width: 180, + render: (value) => formatAdminDateTime(value), + }, + { + title: '操作人', + minWidth: 140, + render: (_, row) => ( + + {row.actorUsername || '-'} + + + ), + }, + { + title: '动作', + dataIndex: 'action', + minWidth: 180, + render: (value) => {formatAuditAction(value)}, + }, + { + title: '目标', + minWidth: 180, + render: (_, row) => ( + + {formatAuditTargetType(row.targetType)} #{row.targetId || '-'} + + ), + }, + { + title: '摘要', + minWidth: 260, + render: (_, row) => {formatPayload(row.payload)}, + }, + ] + + function applyFilters(values: AuditFilterForm) { + const [dateFrom, dateTo] = Array.isArray(values.dateRange) + ? values.dateRange.map((item: { format?: (pattern: string) => string }) => + item?.format ? item.format('YYYY-MM-DD') : '', + ) + : ['', ''] + + setSearchParams( + cleanParams({ + actorUsername: values.actorUsername, + action: values.action, + targetType: values.targetType, + dateFrom, + dateTo, + page: 1, + pageSize, + }), + ) + } + + function resetFilters() { + setSearchParams(cleanParams({ page: 1, pageSize })) + } + + return ( + + 共 {data?.pagination.total || 0} 条审计记录} + /> + + {!isAdmin ? ( + + ) : ( + <> + + + layout="inline" + initialValues={{ + actorUsername: filters.actorUsername, + action: filters.action, + targetType: filters.targetType, + dateRange: + filters.dateFrom && filters.dateTo + ? [dayjs(filters.dateFrom), dayjs(filters.dateTo)] + : undefined, + }} + onFinish={applyFilters} + className="filter-form" + > + + + + + + + + + + + + + + + } htmlType="submit"> + 查询 + + } onClick={resetFilters}> + 重置 + + + + + + + {query.error ? ( + + ) : null} + + + + rowKey="logId" + loading={query.isLoading || query.isFetching} + columns={columns} + dataSource={data?.items || []} + locale={{ emptyText: }} + scroll={{ x: 980 }} + pagination={{ + current: data?.pagination.page || page, + pageSize: data?.pagination.pageSize || pageSize, + total: data?.pagination.total || 0, + showTotal: (total) => `共 ${total} 条`, + onChange: (nextPage, nextPageSize) => { + setSearchParams( + cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }), + ) + }, + }} + /> + + > + )} + + ) +} + +function formatPayload(payload: Record) { + const text = stringifyDisplayJson(payload, 0) + return text.length > 140 ? `${text.slice(0, 140)}...` : text +} + +function cleanParams(params: Record) { + const next = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + const normalized = String(value ?? '').trim() + if (normalized) { + next.set(key, normalized) + } + }) + return next +} diff --git a/apps/frontend-react/src/pages/admin/AdminUsersPage.tsx b/apps/frontend-react/src/pages/admin/AdminUsersPage.tsx new file mode 100644 index 00000000..f2a440ca --- /dev/null +++ b/apps/frontend-react/src/pages/admin/AdminUsersPage.tsx @@ -0,0 +1,454 @@ +import { + DownOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + Dropdown, + Empty, + Form, + Input, + Modal, + Result, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd' +import type { MenuProps, TableColumnsType } from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' + +import PageHeader from '@/components/admin/PageHeader' +import StatusTag from '@/components/admin/StatusTag' +import { + createAdminUser, + fetchAdminUsers, + resetAdminUserPassword, + updateAdminUserRole, + updateAdminUserStatus, +} from '@/services/admin' +import type { AdminRole, AdminUserListItem } from '@/types/admin' +import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth' +import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options' +import { formatAdminDateTime } from '@/utils/admin-time' + +type UserFilterForm = { + username?: string + role?: string + status?: string +} + +type CreateUserForm = { + username: string + password: string + role: AdminRole +} + +const USER_ROLE_OPTIONS = [ + { label: '普通运营', value: 'operator' }, + { label: '客服', value: 'support' }, + { label: '管理员', value: 'admin' }, +] + +export default function AdminUsersPage() { + const isAdmin = hasAdminRole('admin') + const currentUserId = getAdminUserId() + const queryClient = useQueryClient() + const { message, modal } = App.useApp() + const [searchParams, setSearchParams] = useSearchParams() + const [createForm] = Form.useForm() + const [creating, setCreating] = useState(false) + const [actionLoadingId, setActionLoadingId] = useState(null) + const page = Number(searchParams.get('page') || 1) || 1 + const pageSize = Number(searchParams.get('pageSize') || 20) || 20 + const filters = { + username: searchParams.get('username') || '', + role: searchParams.get('role') || '', + status: searchParams.get('status') || '', + } + const queryParams = useMemo( + () => ({ page, pageSize, ...filters }), + [filters.role, filters.status, filters.username, page, pageSize], + ) + const query = useQuery({ + queryKey: ['admin-users', queryParams], + enabled: isAdmin, + queryFn: () => fetchAdminUsers(queryParams), + }) + const data = query.data?.data + + async function reloadUsers() { + await queryClient.invalidateQueries({ queryKey: ['admin-users'] }) + } + + async function submitCreate(values: CreateUserForm) { + setCreating(true) + + try { + await createAdminUser({ + username: values.username.trim(), + password: values.password.trim(), + role: values.role, + status: 'active', + }) + message.success('后台用户已创建') + createForm.resetFields() + setSearchParams(cleanParams({ ...filters, page: 1, pageSize })) + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '创建后台用户失败') + } finally { + setCreating(false) + } + } + + function confirmUpdateRole(item: AdminUserListItem, nextRole: AdminRole) { + if (item.role === nextRole) return + + modal.confirm({ + title: '确认操作', + content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`, + okText: '继续执行', + cancelText: '取消', + centered: true, + onOk: async () => { + setActionLoadingId(item.userId) + try { + await updateAdminUserRole(item.userId, { role: nextRole }) + message.success('用户角色已更新') + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '更新用户角色失败') + } finally { + setActionLoadingId(null) + } + }, + }) + } + + function confirmToggleStatus(item: AdminUserListItem) { + const nextStatus = item.status === 'active' ? 'disabled' : 'active' + + modal.confirm({ + title: '确认操作', + content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`, + okText: '继续执行', + cancelText: '取消', + centered: true, + onOk: async () => { + setActionLoadingId(item.userId) + try { + await updateAdminUserStatus(item.userId, { status: nextStatus }) + message.success('用户状态已更新') + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '更新用户状态失败') + } finally { + setActionLoadingId(null) + } + }, + }) + } + + function openResetPasswordModal(item: AdminUserListItem) { + const form = ModalFormStore.create<{ password: string }>() + + modal.confirm({ + title: '重置密码', + content: ( + + + + + + ), + okText: '提交', + cancelText: '取消', + centered: true, + onOk: async () => { + const values = await form.validate() + setActionLoadingId(item.userId) + try { + await resetAdminUserPassword(item.userId, { password: values.password.trim() }) + message.success('用户密码已重置') + await reloadUsers() + } catch (error) { + message.error(error instanceof Error ? error.message : '重置密码失败') + } finally { + setActionLoadingId(null) + } + }, + }) + } + + function applyFilters(values: UserFilterForm) { + setSearchParams(cleanParams({ ...values, page: 1, pageSize })) + } + + function resetFilters() { + setSearchParams(cleanParams({ page: 1, pageSize })) + } + + if (!isAdmin) { + return ( + + + + + ) + } + + const columns: TableColumnsType = [ + { + title: 'ID', + dataIndex: 'userId', + width: 72, + align: 'center', + }, + { + title: '账号', + dataIndex: 'username', + minWidth: 150, + render: (value, row) => ( + + {value} + {row.userId === currentUserId ? 当前账号 : null} + + ), + }, + { + title: '角色', + dataIndex: 'role', + width: 120, + render: (value: AdminRole) => formatRoleLabel(value), + }, + { + title: '状态', + dataIndex: 'status', + width: 140, + render: (value) => , + }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 170, + render: (value) => formatAdminDateTime(value), + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + width: 170, + render: (value) => formatAdminDateTime(value), + }, + { + title: '操作', + width: 310, + render: (_, row) => { + const isCurrent = row.userId === currentUserId + const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({ + key: item.value, + label: `设为${item.label}`, + disabled: row.role === item.value, + })) + + return ( + + confirmUpdateRole(row, key as AdminRole), + }} + > + + 改角色 + + + confirmToggleStatus(row)} + > + {row.status === 'active' ? '停用' : '启用'} + + openResetPasswordModal(row)} + > + 重置密码 + + + ) + }, + }, + ] + + return ( + + + + + + layout="inline" + initialValues={filters} + onFinish={applyFilters} + className="filter-form" + > + + + + + + + + + + + + } htmlType="submit"> + 查询 + + } onClick={resetFilters}> + 重置 + + + + + + + + + form={createForm} + layout="inline" + initialValues={{ role: 'operator' }} + onFinish={submitCreate} + className="filter-form" + > + + + + + + + + + + + } loading={creating}> + 新增用户 + + + + + + {query.error ? ( + + ) : null} + + 共 {data?.pagination.total || 0} 个账号} + > + + rowKey="userId" + loading={query.isLoading || query.isFetching} + columns={columns} + dataSource={data?.items || []} + locale={{ emptyText: }} + scroll={{ x: 1060 }} + pagination={{ + current: data?.pagination.page || page, + pageSize: data?.pagination.pageSize || pageSize, + total: data?.pagination.total || 0, + showTotal: (total) => `共 ${total} 条`, + onChange: (nextPage, nextPageSize) => { + setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize })) + }, + }} + /> + + + ) +} + +function formatRoleLabel(role: AdminRole) { + if (role === 'admin') return '管理员' + if (role === 'support') return '客服' + return '普通运营' +} + +function cleanParams(params: Record) { + const next = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + const normalized = String(value ?? '').trim() + if (normalized) { + next.set(key, normalized) + } + }) + return next +} + +class ModalFormStore { + private form: ReturnType>[0] | null = null + + static create() { + return new ModalFormStore() + } + + bind = (instance: unknown) => { + this.form = instance as ReturnType>[0] | null + } + + async validate(): Promise { + if (!this.form) { + return {} as T + } + + return this.form.validateFields() + } +} diff --git a/apps/frontend-react/src/pages/claim/ClaimPage.tsx b/apps/frontend-react/src/pages/claim/ClaimPage.tsx new file mode 100644 index 00000000..3090f489 --- /dev/null +++ b/apps/frontend-react/src/pages/claim/ClaimPage.tsx @@ -0,0 +1,1162 @@ +import { + CheckCircleOutlined, + ClockCircleOutlined, + ExclamationCircleOutlined, + LinkOutlined, + LoadingOutlined, + ReloadOutlined, + SendOutlined, + SwapOutlined, +} from '@ant-design/icons' +import { + Alert, + Button, + Card, + Collapse, + Empty, + Image, + Input, + Space, + Spin, + Tooltip, + Typography, +} from 'antd' +import QRCode from 'qrcode' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams } from 'react-router' + +import { + TASK_STATUS, + hasKuaishouCloudRedeemResultStatus, + isClaimInactiveTaskStatus, + isKuaishouCloudCompletedStatus, + isKuaishouCloudRoleConfirmedStatus, + normalizeTaskStatus, +} from '@/domain/task-status' +import { isFeedbackDismissed, showConfirm, showError, showSuccess } from '@/lib/feedback' +import { + confirmKuaishouCloudClaimRole, + fetchClaimDetail, + rebindKuaishouCloudClaimRole, + redeemKuaishouCloudClaim, + verifyKuaishouCloudClaimTicket, +} from '@/services/claim' +import type { + ClaimDetailData, + ClaimKuaishouCloudFlowInfo, + ClaimKuaishouFeifeiFlowInfo, + ClaimOrderInfo, + ClaimProductInfo, +} from '@/types/claim' +import { formatAdminDateTime } from '@/utils/admin-time' + +const BINDING_PREPARE_POLL_MS = 5_000 +const ROLE_FAST_POLL_MS = 10_000 +const ROLE_SLOW_POLL_MS = 30_000 +const ROLE_IDLE_POLL_MS = 60_000 +const ROLE_FAST_WINDOW_MS = 3 * 60_000 +const ROLE_IDLE_WINDOW_MS = 10 * 60_000 + +type ResultVariant = 'success' | 'warning' | 'info' + +type ClaimSnapshot = ReturnType + +export default function ClaimPage() { + const { token: routeToken } = useParams<{ token: string }>() + const token = String(routeToken || '').trim() + + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [refreshingRole, setRefreshingRole] = useState(false) + const [confirmingRole, setConfirmingRole] = useState(false) + const [rebindingRole, setRebindingRole] = useState(false) + const [redeeming, setRedeeming] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [detail, setDetail] = useState(null) + const [ticketCode, setTicketCode] = useState('') + const [qrCodeDataUrl, setQrCodeDataUrl] = useState('') + + const pollTimerRef = useRef(0) + const rolePollBaselineAtRef = useRef(0) + const rolePollBaselineKeyRef = useRef('') + + const snapshot = useMemo( + () => createClaimSnapshot(detail, { rebindingRole }), + [detail, rebindingRole], + ) + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + window.clearTimeout(pollTimerRef.current) + pollTimerRef.current = 0 + } + }, []) + + const generateQRCode = useCallback(async (url: string) => { + if (!url) { + setQrCodeDataUrl('') + return + } + + try { + const dataUrl = await QRCode.toDataURL(url, { + width: 280, + margin: 2, + color: { + dark: '#0f172a', + light: '#ffffff', + }, + }) + setQrCodeDataUrl(dataUrl) + } catch (error) { + setQrCodeDataUrl('') + console.error('生成二维码失败:', error) + } + }, []) + + const applyDetail = useCallback( + async (nextDetail: ClaimDetailData) => { + setDetail(nextDetail) + setTicketCode((current) => current || nextDetail.kuaishouCloudFulfillment?.ticket.code || '') + await generateQRCode( + String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim(), + ) + }, + [generateQRCode], + ) + + const loadDetail = useCallback( + async (options: { silent?: boolean } = {}) => { + if (!token) { + setLoading(false) + setErrorMessage('领取链接无效,请检查链接是否完整') + stopPolling() + return null + } + + if (!options.silent) { + setLoading(true) + } + setErrorMessage('') + + try { + const response = await fetchClaimDetail(token) + await applyDetail(response.data) + return response.data + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '读取领取信息失败') + stopPolling() + return null + } finally { + if (!options.silent) { + setLoading(false) + } + } + }, + [applyDetail, stopPolling, token], + ) + + const resolveRolePollDelay = useCallback((nextSnapshot: ClaimSnapshot) => { + const nextRoleKey = [nextSnapshot.roleName, nextSnapshot.roleId].join('::') + const now = Date.now() + + if (nextRoleKey !== rolePollBaselineKeyRef.current) { + rolePollBaselineKeyRef.current = nextRoleKey + rolePollBaselineAtRef.current = now + } + + if (!rolePollBaselineAtRef.current) { + rolePollBaselineAtRef.current = now + } + + const elapsedMs = now - rolePollBaselineAtRef.current + if (elapsedMs < ROLE_FAST_WINDOW_MS) { + return ROLE_FAST_POLL_MS + } + + if (elapsedMs < ROLE_IDLE_WINDOW_MS) { + return ROLE_SLOW_POLL_MS + } + + return ROLE_IDLE_POLL_MS + }, []) + + const resolveNextPollDelay = useCallback( + (nextSnapshot: ClaimSnapshot) => { + if ( + !nextSnapshot.flow || + nextSnapshot.hasRedeemResult || + isClaimInactiveTaskStatus(nextSnapshot.task?.status) + ) { + return 0 + } + + if (nextSnapshot.currentStep === 2) { + if (!nextSnapshot.isBindingPrepared) { + return BINDING_PREPARE_POLL_MS + } + + return resolveRolePollDelay(nextSnapshot) + } + + return 0 + }, + [resolveRolePollDelay], + ) + + useEffect(() => { + rolePollBaselineAtRef.current = 0 + rolePollBaselineKeyRef.current = '' + setDetail(null) + setTicketCode('') + setQrCodeDataUrl('') + void loadDetail() + + return () => { + stopPolling() + } + }, [loadDetail, stopPolling]) + + useEffect(() => { + stopPolling() + const delay = resolveNextPollDelay(snapshot) + if (!delay) { + return undefined + } + + pollTimerRef.current = window.setTimeout(() => { + pollTimerRef.current = 0 + void loadDetail({ silent: true }) + }, delay) + + return () => { + stopPolling() + } + }, [loadDetail, resolveNextPollDelay, snapshot, stopPolling]) + + async function submitTicket() { + const normalizedTicketCode = ticketCode.trim() + if (!normalizedTicketCode) { + showError('请输入核销码') + return + } + + setSubmitting(true) + try { + const response = await verifyKuaishouCloudClaimTicket(token, { + ticketCode: normalizedTicketCode, + }) + await applyDetail(response.data) + showSuccess('核销已完成,系统已开始准备绑定资源') + } catch (error) { + showError(error instanceof Error ? error.message : '核销码验证失败') + } finally { + setSubmitting(false) + } + } + + async function refreshRole() { + setRefreshingRole(true) + try { + const nextDetail = await loadDetail({ silent: true }) + const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole }) + + if (nextSnapshot.isCustomerRoleReady) { + showSuccess('角色信息已刷新') + } else if (nextSnapshot.isDefaultRole) { + showError('当前仍是虚拟机默认角色,请重新绑定自己的角色信息') + } else if (nextDetail) { + showError( + nextSnapshot.flow?.role.errorMessage || + nextSnapshot.flow?.role.defaultErrorMessage || + '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试', + ) + } + } finally { + setRefreshingRole(false) + } + } + + async function confirmRole() { + setConfirmingRole(true) + try { + const response = await confirmKuaishouCloudClaimRole(token) + await applyDetail(response.data) + showSuccess('角色已确认,进入下一步') + } catch (error) { + showError(error instanceof Error ? error.message : '确认角色失败') + } finally { + setConfirmingRole(false) + } + } + + async function confirmRedeem() { + try { + await showConfirm('兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。', '确认兑换', { + okText: '确认兑换', + okButtonProps: { danger: true }, + }) + } catch (error) { + if (!isFeedbackDismissed(error)) { + showError('确认弹窗已关闭') + } + return + } + + setRedeeming(true) + try { + const response = await redeemKuaishouCloudClaim(token) + await applyDetail(response.data) + showSuccess('兑换请求已提交') + } catch (error) { + showError(error instanceof Error ? error.message : '兑换失败') + } finally { + setRedeeming(false) + } + } + + async function rebindRole() { + try { + await showConfirm( + '换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。核销码和领取商品不会改变,但需要重新扫码绑定角色。', + '确认换绑角色', + { + okText: '确认换绑', + okButtonProps: { danger: true }, + }, + ) + } catch (error) { + if (!isFeedbackDismissed(error)) { + showError('确认弹窗已关闭') + } + return + } + + setRebindingRole(true) + try { + const response = await rebindKuaishouCloudClaimRole(token) + await applyDetail(response.data) + showSuccess('新的绑定二维码已生成,请重新绑定角色') + } catch (error) { + showError(error instanceof Error ? error.message : '换绑角色失败') + } finally { + setRebindingRole(false) + } + } + + function openBindUrl(useCurrentPage = false) { + const bindUrl = String(snapshot.flow?.binding.bindUrl || '').trim() + if (!bindUrl) { + showError('绑定链接还没准备好,请稍后刷新') + return + } + + if (useCurrentPage) { + window.location.assign(bindUrl) + return + } + + window.open(bindUrl, '_blank', 'noopener,noreferrer') + } + + function openFeifeiUrl(useCurrentPage = false) { + const url = String( + snapshot.feifei?.h5.rechargeUrl || snapshot.feifei?.h5.entryUrl || '', + ).trim() + + if (!url) { + showError('领取链接还没准备好,请稍后刷新') + return + } + + if (useCurrentPage) { + window.location.assign(url) + return + } + + window.open(url, '_blank', 'noopener,noreferrer') + } + + return ( + + + + {loading ? ( + + } size="large" /> + 正在读取当前领取进度... + + ) : errorMessage ? ( + + + {errorMessage} + + ) : snapshot.isFeifeiFlow && snapshot.feifei ? ( + openFeifeiUrl(false)} + /> + ) : detail && snapshot.flow ? ( + + ) : ( + + + + )} + + ) +} + +function createClaimSnapshot( + detail: ClaimDetailData | null, + options: { rebindingRole?: boolean } = {}, +) { + const flow = detail?.kuaishouCloudFulfillment || null + const feifei = detail?.kuaishouFeifei || null + const order = detail?.order || null + const orderItem = detail?.orderItem || null + const product = detail?.product || null + const task = detail?.task || null + + const roleName = flow?.role.name || flow?.binding.roleName || '' + const roleId = flow?.role.rid || flow?.binding.roleId || '' + const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei' + const isTicketVerified = flow?.ticket.status === 'verified' + const isBindUrlExpired = isDateExpired(flow?.binding.bindExpiresAt || '') + const isBindingPrepared = + flow?.binding.prepareStatus === 'ready' && + Boolean(String(flow?.binding.bindUrl || '').trim()) && + !isBindUrlExpired + const isBindingPreparing = flow?.binding.prepareStatus === 'pending' + const canEnterBindingStep = isTicketVerified + const isRoleReady = Boolean(roleName || roleId) + const hasDefaultRoleSnapshot = Boolean(flow?.role.defaultName || flow?.role.defaultRid) + const isDefaultRole = flow?.role.isDefaultRole === true + const isCustomerRoleReady = isRoleReady && hasDefaultRoleSnapshot && !isDefaultRole + const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({ + flow, + isBindingPrepared, + hasDefaultRoleSnapshot, + isRoleReady, + isDefaultRole, + rebindingRole: Boolean(options.rebindingRole), + }) + const isRoleConfirmed = isKuaishouCloudRoleConfirmedStatus(task?.status) + const isDispatched = String(flow?.dispatch.status || '').trim() === 'success' + const normalizedStatus = normalizeTaskStatus(task?.status) + const isRedeemFailed = + normalizedStatus === TASK_STATUS.MANUAL_REVIEW || + normalizedStatus === TASK_STATUS.FAILED || + String(flow?.dispatch.status || '').trim() === 'failed' + const isCompleted = isKuaishouCloudCompletedStatus(task?.status) + const hasRedeemResult = isDispatched || hasKuaishouCloudRedeemResultStatus(task?.status) + const canSubmitTicket = !isClaimInactiveTaskStatus(task?.status) + + const currentStep = resolveCurrentStep({ + isFeifeiFlow, + isCompleted, + hasRedeemResult, + isRoleConfirmed, + canEnterBindingStep, + }) + const progressText = resolveProgressText({ + feifei, + isFeifeiFlow, + hasRedeemResult, + isRoleConfirmed, + isTicketVerified, + isBindingPrepared, + }) + const resultTitle = resolveResultTitle({ isRedeemFailed, isCompleted, isDispatched }) + const resultDescription = resolveResultDescription({ detail, flow, task, isRedeemFailed, isCompleted }) + const resultVariant: ResultVariant = isRedeemFailed + ? 'warning' + : isCompleted || isDispatched + ? 'success' + : 'info' + + return { + flow, + feifei, + order, + orderItem, + product, + task, + roleName, + roleId, + isFeifeiFlow, + isTicketVerified, + isBindUrlExpired, + isBindingPrepared, + isBindingPreparing, + canEnterBindingStep, + isRoleReady, + hasDefaultRoleSnapshot, + isDefaultRole, + isCustomerRoleReady, + confirmRoleDisabledReason, + isRoleConfirmed, + isDispatched, + isRedeemFailed, + isCompleted, + hasRedeemResult, + canSubmitTicket, + currentStep, + progressText, + resultTitle, + resultDescription, + resultVariant, + } +} + +function resolveConfirmRoleDisabledReason(options: { + flow: ClaimKuaishouCloudFlowInfo | null + isBindingPrepared: boolean + hasDefaultRoleSnapshot: boolean + isRoleReady: boolean + isDefaultRole: boolean + rebindingRole: boolean +}) { + if (!options.isBindingPrepared) { + return '绑定二维码还在准备中,请稍后自动刷新' + } + if (!options.hasDefaultRoleSnapshot) { + return options.flow?.role.defaultErrorMessage || '系统还未获取到虚拟机默认角色信息,请稍后刷新' + } + if (!options.isRoleReady) { + return '请先扫码绑定自己的角色,并刷新角色信息' + } + if (options.isDefaultRole) { + return '当前仍是虚拟机默认角色,请重新绑定自己的角色信息' + } + if (options.rebindingRole) { + return '正在换绑角色,请稍候' + } + return '' +} + +function resolveCurrentStep(options: { + isFeifeiFlow: boolean + isCompleted: boolean + hasRedeemResult: boolean + isRoleConfirmed: boolean + canEnterBindingStep: boolean +}) { + if (options.isFeifeiFlow) { + return options.isCompleted ? 4 : 2 + } + if (options.hasRedeemResult) { + return 4 + } + if (options.isRoleConfirmed) { + return 3 + } + if (options.canEnterBindingStep) { + return 2 + } + return 1 +} + +function resolveProgressText(options: { + feifei: ClaimKuaishouFeifeiFlowInfo | null + isFeifeiFlow: boolean + hasRedeemResult: boolean + isRoleConfirmed: boolean + isTicketVerified: boolean + isBindingPrepared: boolean +}) { + if (options.isFeifeiFlow) { + return options.feifei?.rechargeStatusLabel || '请打开领取链接' + } + if (options.hasRedeemResult) { + return '兑换结果已生成' + } + if (options.isRoleConfirmed) { + return '角色已确认,等待兑换' + } + if (options.isTicketVerified) { + return options.isBindingPrepared ? '请完成扫码绑定' : '绑定链接刷新中,请稍候' + } + return '等待提交并核销' +} + +function resolveResultTitle(options: { + isRedeemFailed: boolean + isCompleted: boolean + isDispatched: boolean +}) { + if (options.isRedeemFailed) { + return '兑换遇到问题' + } + if (options.isCompleted) { + return '兑换成功' + } + if (options.isDispatched) { + return '兑换请求已提交' + } + return '结果已记录' +} + +function resolveResultDescription(options: { + detail: ClaimDetailData | null + flow: ClaimKuaishouCloudFlowInfo | null + task: ClaimDetailData['task'] | null + isRedeemFailed: boolean + isCompleted: boolean +}) { + if (options.isRedeemFailed) { + const message = String( + options.task?.lastError || + options.flow?.dispatch.errorMessage || + options.detail?.result?.resultMessage || + '', + ).trim() + return message || '当前兑换流程需要客服处理,后续结果请以后台任务界面为准。' + } + + if (options.isCompleted) { + return '当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。' + } + + return '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。' +} + +function isDateExpired(value: string | null) { + const normalized = String(value || '').trim() + if (!normalized) { + return false + } + + const expiresTime = Date.parse(normalized) + return Number.isFinite(expiresTime) && expiresTime <= Date.now() +} + +function ClaimHeaderCard({ + order, + product, + currentStep, + progressText, +}: { + order: ClaimOrderInfo | null + product: ClaimProductInfo | null + currentStep: number + progressText: string +}) { + return ( + + + 快手 Cloud 客户领取 + {product?.title || '商品领取'} + 订单号:{order?.platformOrderId || '-'} + + + + + + {[1, 2, 3, 4].map((step) => ( + = step ? 'claim-step-dot active' : 'claim-step-dot'}> + {step} + + ))} + + {progressText} + + + ) +} + +function KuaishouCloudClaimSteps({ + snapshot, + ticketCode, + qrCodeDataUrl, + submitting, + refreshingRole, + confirmingRole, + rebindingRole, + redeeming, + onTicketCodeChange, + onSubmitTicket, + onOpenBindUrl, + onRefreshRole, + onRebindRole, + onConfirmRole, + onConfirmRedeem, +}: { + snapshot: ClaimSnapshot + ticketCode: string + qrCodeDataUrl: string + submitting: boolean + refreshingRole: boolean + confirmingRole: boolean + rebindingRole: boolean + redeeming: boolean + onTicketCodeChange: (value: string) => void + onSubmitTicket: () => void + onOpenBindUrl: (useCurrentPage?: boolean) => void + onRefreshRole: () => void + onRebindRole: () => void + onConfirmRole: () => void + onConfirmRedeem: () => void +}) { + if (!snapshot.flow) { + return null + } + + if (snapshot.currentStep === 1) { + return ( + + ) + } + + if (snapshot.currentStep === 2) { + return ( + + ) + } + + if (snapshot.currentStep === 3) { + return ( + + ) + } + + return +} + +function ClaimTicketStep({ + ticketCode, + canSubmitTicket, + submitting, + isBindingPreparing, + flow, + onTicketCodeChange, + onSubmitTicket, +}: { + ticketCode: string + canSubmitTicket: boolean + submitting: boolean + isBindingPreparing: boolean + flow: ClaimKuaishouCloudFlowInfo + onTicketCodeChange: (value: string) => void + onSubmitTicket: () => void +}) { + return ( + + 第 1 步:提交核销码 + + 请您先从快手小店复制核销码。提交后会立即核销,核销成功后系统会自动准备绑定资源。 + + + onTicketCodeChange(event.target.value)} + onPressEnter={onSubmitTicket} + /> + + } + loading={submitting} + disabled={!canSubmitTicket} + onClick={onSubmitTicket} + > + 提交核销码并继续 + + + } + message={ + isBindingPreparing + ? '核销完成后会自动准备绑定资源。' + : '核销完成后会自动进入扫码绑定步骤。' + } + /> + + {flow.guideImages.length > 0 ? ( + + + {flow.guideImages.map((imageUrl, index) => ( + + + 步骤 {index + 1} + + ))} + + + ), + }, + ]} + /> + ) : null} + + ) +} + +function ClaimBindingStep({ + snapshot, + qrCodeDataUrl, + refreshingRole, + confirmingRole, + rebindingRole, + onOpenBindUrl, + onRefreshRole, + onRebindRole, + onConfirmRole, +}: { + snapshot: ClaimSnapshot + qrCodeDataUrl: string + refreshingRole: boolean + confirmingRole: boolean + rebindingRole: boolean + onOpenBindUrl: (useCurrentPage?: boolean) => void + onRefreshRole: () => void + onRebindRole: () => void + onConfirmRole: () => void +}) { + const flow = snapshot.flow + if (!flow) { + return null + } + + return ( + + {snapshot.isBindingPrepared && qrCodeDataUrl ? ( + + + Q区用Q扫 + + V区用V扫 + 长按保存相册进行扫码绑定 + + + } onClick={() => onOpenBindUrl(false)}> + 打开绑定链接 + + + ) : ( + } + message="系统正在准备绑定资源,请稍后自动刷新。" + /> + )} + + + + + + + {flow.role.isDefaultRole ? ( + + ) : null} + + + } loading={refreshingRole} onClick={onRefreshRole}> + 刷新角色信息 + + } + loading={rebindingRole} + disabled={confirmingRole} + onClick={onRebindRole} + > + 换绑角色 + + + + } + loading={confirmingRole} + disabled={Boolean(snapshot.confirmRoleDisabledReason)} + onClick={onConfirmRole} + > + 我已完成绑定,下一步 + + + + + + + 最近刷新:{formatAdminDateTime(flow.role.refreshedAt)} + 链接有效期:{formatAdminDateTime(flow.binding.bindExpiresAt)} + {flow.binding.bindProbeMessage ? ( + + 链接检测:{flow.binding.bindProbeStatus || '-'} / {flow.binding.bindProbeMessage} + + ) : null} + + + ) +} + +function ClaimConfirmStep({ + roleName, + roleId, + product, + redeeming, + rebindingRole, + onConfirmRedeem, + onRebindRole, +}: { + roleName: string + roleId: string + product: ClaimProductInfo | null + redeeming: boolean + rebindingRole: boolean + onConfirmRedeem: () => void + onRebindRole: () => void +}) { + return ( + + 第 3 步:确认兑换信息 + 请再次确认角色和商品信息,确认无误后再继续兑换。 + + + + + + + + + + + + + + } + loading={rebindingRole} + disabled={redeeming} + onClick={onRebindRole} + > + 换绑角色 + + } + loading={redeeming} + disabled={rebindingRole} + onClick={onConfirmRedeem} + > + 确认兑换 + + + + ) +} + +function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { + const flow = snapshot.flow + if (!flow) { + return null + } + + return ( + + + {snapshot.resultVariant === 'warning' ? ( + + ) : ( + + )} + + {snapshot.resultTitle} + {snapshot.resultDescription} + + + + + + + + + + + + + + + + + ) +} + +function FeifeiClaimPanel({ + feifei, + order, + product, + taskLastError, + onOpen, +}: { + feifei: ClaimKuaishouFeifeiFlowInfo + order: ClaimOrderInfo | null + product: ClaimProductInfo | null + taskLastError?: string + onOpen: () => void +}) { + return ( + + + kuaishou-feifei + {product?.title || '商品领取'} + {feifei.rechargeStatusLabel || '待领取'} + + + + + + + + } + disabled={!feifei.h5.rechargeUrl && !feifei.h5.entryUrl} + onClick={onOpen} + > + 打开领取链接 + + + {taskLastError ? : null} + + ) +} + +function ClaimProductItems({ + product, + compact = false, +}: { + product: ClaimProductInfo | null + compact?: boolean +}) { + const items = getProductItems(product) + const hasMultipleItems = Boolean(product?.isBundle || items.length > 1) + + if (!items.length) { + return null + } + + return ( + + + {hasMultipleItems ? '包含商品' : '商品明细'} + {hasMultipleItems ? {items.length} 项 : null} + + + + {items.map((item) => ( + + {item.name} + x{item.quantity} + + ))} + + + ) +} + +function getProductItems(product: ClaimProductInfo | null) { + const items = Array.isArray(product?.items) ? product.items : [] + return items + .map((item) => ({ + key: item.cloudSkuId || item.name, + name: String(item.name || '').trim(), + quantity: Math.max(1, Number(item.quantity || 1) || 1), + })) + .filter((item) => item.name) +} + +function InfoTile({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ) +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ) +} diff --git a/apps/frontend-react/src/styles/main.css b/apps/frontend-react/src/styles/main.css index d02360f0..05127afd 100644 --- a/apps/frontend-react/src/styles/main.css +++ b/apps/frontend-react/src/styles/main.css @@ -172,6 +172,403 @@ select { margin-top: 0; } +.modal-form { + margin-top: 14px; +} + +.payload-text { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 12px; + color: #4b5563; + word-break: break-all; +} + +.claim-page { + min-height: 100vh; + padding: 24px; + background: linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%); + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; +} + +.claim-header-card, +.claim-content-card { + width: min(680px, 100%); + border-color: rgba(148, 163, 184, 0.18); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08); +} + +.claim-header-card { + position: relative; +} + +.claim-header-card .ant-card-body, +.claim-content-card .ant-card-body { + display: flex; + flex-direction: column; + gap: 18px; +} + +.claim-header-copy { + min-width: 0; + padding-right: 220px; +} + +.claim-header-copy h1 { + margin: 0; + color: #0f172a; + font-size: 30px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.claim-header-copy p, +.claim-muted { + margin: 0; + color: #64748b; + line-height: 1.6; +} + +.claim-step-indicator { + position: absolute; + top: 24px; + right: 24px; + max-width: 210px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 7px; +} + +.claim-step-dots { + display: flex; + gap: 8px; +} + +.claim-step-dot { + width: 32px; + height: 32px; + border-radius: 50%; + background: #e5e7eb; + color: #94a3b8; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.claim-step-dot.active { + background: #1677ff; + color: #ffffff; +} + +.claim-step-progress-text { + color: #64748b; + font-size: 13px; + font-weight: 700; + line-height: 1.4; + text-align: right; + overflow-wrap: anywhere; +} + +.claim-content-card .ant-typography { + margin: 0; +} + +.claim-state-card .ant-card-body { + min-height: 240px; + align-items: center; + justify-content: center; + text-align: center; +} + +.claim-large-icon { + font-size: 44px; +} + +.claim-icon-success { + color: #16a34a; +} + +.claim-icon-warning { + color: #d97706; +} + +.claim-icon-info { + color: #1677ff; +} + +.claim-icon-error { + color: #dc2626; +} + +.claim-guide-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.claim-guide-figure { + margin: 0; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 6px; + overflow: hidden; +} + +.claim-guide-figure .ant-image, +.claim-guide-figure img { + width: 100%; + display: block; +} + +.claim-guide-figure figcaption { + padding: 10px 12px 14px; + color: #475569; + font-size: 13px; +} + +.claim-qr-block { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 14px; + border-radius: 8px; + background: #f8fbff; + border: 1px solid #dbeafe; +} + +.claim-qr-guide-panel { + display: grid; + grid-template-columns: 42px minmax(0, 280px) 42px; + grid-template-areas: + 'left code right' + '. hint .'; + gap: 10px; + align-items: center; + justify-content: center; + width: 100%; + max-width: 410px; + min-width: 0; +} + +.claim-scan-label { + align-self: stretch; + min-height: 150px; + padding: 10px 6px; + color: #e11d48; + font-weight: 900; + font-size: 17px; + line-height: 1.2; + writing-mode: vertical-rl; + text-orientation: upright; + display: flex; + align-items: center; + justify-content: center; +} + +.claim-scan-label-left { + grid-area: left; +} + +.claim-scan-label-right { + grid-area: right; +} + +.claim-qr-code { + grid-area: code; + width: 100%; + max-width: 280px; + aspect-ratio: 1 / 1; + padding: 10px; + background: #ffffff; + border-radius: 8px; + border: 1px solid #dbeafe; +} + +.claim-save-hint { + grid-area: hint; + margin: 0; + justify-self: center; + color: #64748b; + font-size: 14px; + font-weight: 700; + line-height: 1.4; + text-align: center; +} + +.claim-role-panel { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.claim-role-panel.pending { + opacity: 0.82; +} + +.claim-role-panel-item { + min-width: 0; + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.claim-role-panel-item span { + color: #64748b; + font-size: 13px; + line-height: 1.4; + white-space: nowrap; +} + +.claim-role-panel-item strong { + min-width: 0; + color: #0f172a; + font-size: 17px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.claim-action-row { + width: 100%; +} + +.claim-action-tooltip-wrap { + display: inline-flex; +} + +.claim-meta-footer { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + justify-content: center; + color: #94a3b8; + font-size: 11px; + line-height: 1.5; +} + +.claim-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.claim-info-item { + min-width: 0; + padding: 16px; + border-radius: 6px; + background: #f8fafc; + border: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + gap: 6px; +} + +.claim-info-item span { + color: #64748b; + font-size: 13px; +} + +.claim-info-item strong { + color: #0f172a; + font-size: 17px; + overflow-wrap: anywhere; +} + +.claim-result-header { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.claim-feifei-main { + display: grid; + gap: 8px; +} + +.claim-feifei-main p { + margin: 0; + color: #1677ff; + font-weight: 700; +} + +.claim-feifei-label { + color: #64748b; + font-size: 13px; +} + +.claim-product-items { + display: flex; + flex-direction: column; + gap: 10px; +} + +.claim-product-items-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #64748b; + font-size: 13px; +} + +.claim-product-items-header strong { + color: #1677ff; + font-weight: 700; +} + +.claim-product-items-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.claim-product-item-row { + min-height: 44px; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #f8fafc; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.claim-product-item-row span { + min-width: 0; + color: #0f172a; + font-weight: 700; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.claim-product-item-row strong { + flex: 0 0 auto; + color: #0f172a; + font-size: 15px; +} + +.claim-product-items.compact { + margin-top: 10px; + gap: 8px; +} + +.claim-product-items.compact .claim-product-items-list { + gap: 6px; +} + +.claim-product-items.compact .claim-product-item-row { + min-height: 36px; + padding: 8px 10px; + background: rgba(248, 250, 252, 0.72); +} + @media (max-width: 900px) { .admin-sider { position: fixed !important; @@ -193,3 +590,129 @@ select { display: grid; } } + +@media (max-width: 720px) { + .claim-page { + padding: 14px; + } + + .claim-header-card .ant-card-body, + .claim-content-card .ant-card-body { + gap: 14px; + } + + .claim-header-copy { + padding-right: 150px; + } + + .claim-header-copy h1 { + font-size: 24px; + line-height: 1.22; + } + + .claim-header-copy p { + font-size: 14px; + line-height: 1.4; + } + + .claim-step-indicator { + top: 16px; + right: 16px; + max-width: 140px; + } + + .claim-step-dots { + gap: 5px; + } + + .claim-step-dot { + width: 26px; + height: 26px; + font-size: 13px; + } + + .claim-step-progress-text { + font-size: 12px; + } + + .claim-guide-grid, + .claim-info-grid { + grid-template-columns: 1fr; + } + + .claim-qr-block { + padding: 12px; + } + + .claim-qr-guide-panel { + grid-template-columns: 34px minmax(0, 1fr) 34px; + gap: 8px; + max-width: 100%; + } + + .claim-scan-label { + min-height: 128px; + padding: 8px 4px; + font-size: 14px; + } + + .claim-qr-code { + max-width: 232px; + padding: 8px; + } + + .claim-save-hint { + font-size: 13px; + } + + .claim-role-panel-item { + grid-template-columns: 76px minmax(0, 1fr); + gap: 10px; + } + + .claim-role-panel-item span { + font-size: 12px; + } + + .claim-role-panel-item strong { + font-size: 16px; + } + + .claim-action-row { + display: flex; + flex-direction: column; + align-items: stretch; + } + + .claim-action-row .ant-space-item, + .claim-action-row .ant-btn, + .claim-action-tooltip-wrap { + width: 100%; + } + + .claim-meta-footer { + justify-content: flex-start; + } + + .claim-result-header { + display: grid; + } +} + +@media (max-width: 420px) { + .claim-header-copy { + padding-right: 124px; + } + + .claim-header-copy h1 { + font-size: 22px; + } + + .claim-header-copy p { + padding-right: 0; + } + + .claim-step-indicator { + max-width: 116px; + } +}
正在读取当前领取进度...
{errorMessage}
订单号:{order?.platformOrderId || '-'}
+ 请您先从快手小店复制核销码。提交后会立即核销,核销成功后系统会自动准备绑定资源。 +
长按保存相册进行扫码绑定
请再次确认角色和商品信息,确认无误后再继续兑换。
{snapshot.resultDescription}
{feifei.rechargeStatusLabel || '待领取'}