Files
playX/src/views/record/index.tsx
JiaJun 3eb03da0da feat(auth): 添加iframe轮播消息支持并优化地址簿和订单状态处理
- 添加HostWheelMessage类型定义和IFRAME_WHEEL消息常量
- 实现iframe轮播事件监听和消息发送功能
- 移除address.type.ts中的region和region_text字段
- 更新英文本地化文件中的completed状态为Approved
- 添加订单状态通知相关的国际化文案
- 更新常量文件中的订单状态描述注释
- 在index.ts中添加HostWheelMessage和OrderStatusCode类型
- 修改record页面的订单状态处理逻辑
- 实现订单状态变更通知弹窗功能
- 优化地址簿获取地址文本的方法
- 修复地址列表API响应数据访问问题
- 添加playx.html测试页面和相关配置文件
2026-07-27 14:23:45 +08:00

691 lines
25 KiB
TypeScript

import {useCallback, useEffect, useRef, useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useTranslation} from 'react-i18next'
import PageLayout from '@/components/layout'
import {ORDER_STATUS} from '@/constant'
import i18n from '@/lib/i18n'
import { cn } from '@/lib'
import {MotionButton, MotionDiv, tapMotionPropsIcon, tapMotionPropsSoft} from '@/lib/motion'
import Modal from '@/components/modal'
import Button from '@/components/button'
import type { OrderCardProps, OrderRecord, OrderStatusCode, PointsCardProps, RecordButtonType, TabButtonProps } from '@/types'
import {Link} from 'react-router-dom'
import { ArrowLeft, ChevronRight, Coins, PackageSearch } from 'lucide-react'
import {orders, pointsLogs} from '@/api/business.ts'
import {queryKeys} from '@/lib/queryKeys.ts'
import { useUserStore } from '@/store/user.ts'
import type {OrderItem, PointsLogItem} from '@/types/business.type.ts'
const pointsRecordToneClassName = {
positive: 'bg-[#9BFFC0] text-[#176640]',
negative: 'bg-[#FF9BA4] text-[#7B2634]',
} as const
const ORDER_POLL_INTERVAL_MS = 10_000
const EMPTY_ORDER_RECORDS: OrderRecord[] = []
type OrderStatusNotice = {
id: string
title: string
orderNumber?: string
statusCode: Extract<OrderStatusCode, 'COMPLETED' | 'REJECTED'>
}
function formatDatePart(value: number) {
return String(value).padStart(2, '0')
}
function getDateTimeParts(value?: number | string) {
if (value == null) {
return { date: '--', time: '--:--' }
}
const numericValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
const date = new Date(
typeof numericValue === 'number'
? String(numericValue).length === 13
? numericValue
: numericValue * 1000
: numericValue,
)
if (Number.isNaN(date.getTime())) {
return { date: '--', time: '--:--' }
}
return {
date: `${date.getFullYear()}-${formatDatePart(date.getMonth() + 1)}-${formatDatePart(date.getDate())}`,
time: `${formatDatePart(date.getHours())}:${formatDatePart(date.getMinutes())}`,
}
}
function toTitleCase(value: string) {
return value
.toLowerCase()
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(' ')
}
function normalizeOrderStatus(status?: string | number): OrderStatusCode {
if (typeof status === 'string') {
const normalizedStatus = status.trim().toUpperCase()
if (ORDER_STATUS.includes(normalizedStatus)) {
return normalizedStatus as OrderStatusCode
}
}
const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status.trim())
? Number(status.trim())
: status
if (typeof normalizedStatus === 'number') {
switch (normalizedStatus) {
case 0:
return 'PENDING'
case 1:
return 'COMPLETED'
case 2:
return 'SHIPPED'
case 3:
return 'REJECTED'
default:
return 'UNKNOWN'
}
}
return 'UNKNOWN'
}
function getOrderStatusLabel(statusCode: OrderStatusCode, fallbackStatus?: string | number) {
switch (statusCode) {
case 'PENDING':
return i18n.t('record.statusLabel.pending')
case 'COMPLETED':
return i18n.t('record.statusLabel.completed')
case 'SHIPPED':
return i18n.t('record.statusLabel.shipped')
case 'REJECTED':
return i18n.t('record.statusLabel.rejected')
case 'UNKNOWN':
if (!fallbackStatus) {
return i18n.t('record.statusLabel.pending')
}
return typeof fallbackStatus === 'number' ? String(fallbackStatus) : toTitleCase(fallbackStatus)
}
}
function getOrderCategory(item: OrderItem) {
if (item.type_title) {
return item.type_title
}
if (item.category_title) {
return item.category_title
}
if (item.type) {
const normalizedType = item.type.trim().toUpperCase()
switch (normalizedType) {
case 'BONUS':
return i18n.t('record.categories.bonus')
case 'PHYSICAL':
return i18n.t('record.categories.physical')
case 'WITHDRAW':
return i18n.t('record.categories.withdraw')
case '1':
return i18n.t('record.categories.withdraw')
case '2':
return i18n.t('record.categories.physical')
case '3':
return i18n.t('record.categories.bonus')
default:
return toTitleCase(normalizedType)
}
}
if (item.category) {
return toTitleCase(item.category)
}
return i18n.t('record.categories.order')
}
function getOrderPoints(item: OrderItem) {
const rawValue = item.points_cost
if (rawValue == null || rawValue === '') {
return {display: '--'}
}
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
if (Number.isNaN(numericValue)) {
const textValue = String(rawValue)
return {display: `${textValue} ${i18n.t('common.points')}`}
}
return {
display: `${numericValue} ${i18n.t('common.points')}`,
}
}
function getTrackingNumber(item: OrderItem) {
const shippingNumber = item.shipping_no?.trim()
const logisticsNumber = item.logistics_no?.trim()
const trackingNumber = item.tracking_no?.trim()
const shippingCompany = item.shipping_company?.trim()
const resolvedNumber = shippingNumber || logisticsNumber || trackingNumber
if (!resolvedNumber) {
return undefined
}
return shippingCompany ? `${shippingCompany} ${resolvedNumber}` : resolvedNumber
}
function mapOrderItemToRecord(item: OrderItem): OrderRecord {
const { date, time } = getDateTimeParts(item.create_time ?? item.created_at)
const orderNumber = item.external_transaction_id?.trim() || item.order_no ? String(item.external_transaction_id?.trim() || item.order_no) : undefined
const points = getOrderPoints(item)
const statusCode = normalizeOrderStatus(item.status)
return {
id: String(item.id),
orderNumber,
date,
time,
category: getOrderCategory(item),
title: item.item_title ?? item.title ?? item.mallItem?.title ?? i18n.t('record.untitledOrder'),
trackingNumber: getTrackingNumber(item),
status: getOrderStatusLabel(statusCode, item.status),
statusCode,
points: points.display,
}
}
function getPointsRecordAmount(item: PointsLogItem) {
const rawValue = item.points
if (rawValue == null || rawValue === '') {
return {
amount: '--',
tone: 'negative' as const,
}
}
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
if (Number.isNaN(numericValue)) {
const textValue = String(rawValue).trim()
return {
amount: item.direction === 'IN'
? textValue.startsWith('+') ? textValue : `+${textValue.replace(/^[+-]/, '')}`
: item.direction === 'OUT'
? textValue.startsWith('-') ? textValue : `-${textValue.replace(/^[+-]/, '')}`
: textValue,
tone: item.direction === 'IN' ? 'positive' as const : 'negative' as const,
}
}
return {
amount: `${item.direction === 'IN' ? '+' : item.direction === 'OUT' ? '-' : ''}${Math.abs(numericValue)}`,
tone: item.direction === 'IN' ? 'positive' as const : 'negative' as const,
}
}
function getPointsRecordTitle(item: PointsLogItem) {
return [
item.item_title,
item.title,
item.mallItem?.title,
item.biz_type,
item.type,
item.description,
item.remark?.split('\n')[0],
].find((value) => typeof value === 'string' && value.trim())?.trim() ?? i18n.t('record.pointsRecordFallback')
}
function mapPointsLogItemToRecord(item: PointsLogItem) {
const {date, time} = getDateTimeParts(item.ts ?? item.create_time ?? item.created_at ?? item.update_time)
const amount = getPointsRecordAmount(item)
return {
id: String(item.id),
title: getPointsRecordTitle(item),
date,
time,
amount: amount.amount,
tone: amount.tone,
}
}
function getOrderStatusClassName(statusCode: OrderStatusCode) {
switch (statusCode) {
case 'COMPLETED':
return 'bg-[#9BFFC0] text-[#176640]'
case 'SHIPPED':
return 'bg-[#95F0FF] text-[#116A79]'
case 'PENDING':
return 'bg-[#FFF18C] text-[#7F6A0D]'
case 'REJECTED':
return 'bg-[#FFB1C0] text-[#7C2941]'
case 'UNKNOWN':
return 'bg-white/15 text-white/80'
}
}
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
return (
<MotionButton
type="button"
className={cn(
'inline-flex min-w-[140px] cursor-pointer items-center justify-center gap-[8px] rounded-[10px] border px-[14px] py-[9px] text-[13px] transition-colors focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]',
active
? 'border-[#F99A0B] bg-linear-to-r from-[#F96C02] to-[#FE9F00] text-white shadow-[0_0_16px_rgba(249,108,2,0.22)]'
: 'border-white/35 bg-white/3 text-[#B8B1AA] hover:bg-white/6',
)}
onClick={onClick}
{...tapMotionPropsSoft}
>
<Icon className="h-[15px] w-[15px]" aria-hidden="true" />
{label}
</MotionButton>
)
}
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
const {t} = useTranslation()
return (
<div className="shrink-0 overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[14px] text-white">
{record.date} {record.time} {record.category}
</div>
<div className="liquid-glass-bg !rounded-t-none flex items-center justify-between gap-[14px] px-[12px] py-[14px]">
<div className="min-w-0 flex-1 pr-[16px]">
<div className="text-[16px] font-medium text-white">{record.title}</div>
{record.trackingNumber ? (
<div className="mt-[4px] text-[13px] text-white/45">
{t('record.trackingNumber')}&nbsp;&nbsp;{record.trackingNumber}
</div>
) : null}
<MotionButton
type="button"
className="mt-[10px] inline-flex items-center gap-[5px] text-[13px] text-[#FA6A00] focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => onOpenDetails(record)}
{...tapMotionPropsSoft}
>
{t('record.checkDetails')}
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true" />
</MotionButton>
</div>
<div className="flex shrink-0 flex-col items-end gap-[10px]">
<div
className={cn(
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
getOrderStatusClassName(record.statusCode),
)}
>
{record.status}
</div>
<div className="text-[13px] text-white/85">{record.points.replace(/^-/, '')}</div>
</div>
</div>
</div>
)
}
function PointsCard({ record }: PointsCardProps) {
return (
<div className="shrink-0 overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[15px] text-white">
{record.title}
</div>
<div className="liquid-glass-bg !rounded-t-none flex items-center justify-between px-[12px] py-[22px]">
<div className="text-[13px] text-white/40">
{record.date} &nbsp; {record.time}
</div>
<div
className={cn(
'inline-flex w-fit rounded-[6px] px-[10px] py-[3px] text-[12px] leading-none',
pointsRecordToneClassName[record.tone],
)}
>
{record.amount}
</div>
</div>
</div>
)
}
function OrdersTabContent({
sessionId,
onOpenDetails,
onOrdersChange,
onStatusNotices,
}: {
sessionId: string
onOpenDetails: (record: OrderRecord) => void
onOrdersChange: (records: OrderRecord[]) => void
onStatusNotices: (notices: OrderStatusNotice[]) => void
}) {
const {t} = useTranslation()
const initializedRef = useRef(false)
const pendingOrderIdsRef = useRef<Set<string>>(new Set())
const ordersQuery = useQuery({
queryKey: queryKeys.orders(sessionId),
enabled: Boolean(sessionId),
gcTime: 0,
refetchInterval: sessionId ? ORDER_POLL_INTERVAL_MS : false,
refetchIntervalInBackground: true,
queryFn: async () => {
const response = await orders({
session_id: sessionId,
})
return (response.data?.list ?? []).map(mapOrderItemToRecord)
},
})
const orderRecords = ordersQuery.data ?? EMPTY_ORDER_RECORDS
useEffect(() => {
initializedRef.current = false
pendingOrderIdsRef.current = new Set()
}, [sessionId])
useEffect(() => {
if (!ordersQuery.isSuccess) {
return
}
onOrdersChange(orderRecords)
if (!initializedRef.current) {
pendingOrderIdsRef.current = new Set(
orderRecords
.filter((record) => record.statusCode === 'PENDING')
.map((record) => record.id),
)
initializedRef.current = true
return
}
const pendingOrderIds = pendingOrderIdsRef.current
const nextPendingOrderIds = new Set(pendingOrderIds)
const notices: OrderStatusNotice[] = []
orderRecords.forEach((record) => {
if (record.statusCode === 'PENDING') {
nextPendingOrderIds.add(record.id)
return
}
if (!pendingOrderIds.has(record.id)) {
return
}
nextPendingOrderIds.delete(record.id)
if (record.statusCode === 'COMPLETED' || record.statusCode === 'REJECTED') {
notices.push({
id: record.id,
title: record.title,
orderNumber: record.orderNumber,
statusCode: record.statusCode,
})
}
})
pendingOrderIdsRef.current = nextPendingOrderIds
if (notices.length) {
onStatusNotices(notices)
}
}, [orderRecords, ordersQuery.isSuccess, onOrdersChange, onStatusNotices])
if (!sessionId) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('validation.verificationRequired')}
</div>
)
}
if (ordersQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('record.loading')}
</div>
)
}
if (!orderRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('record.noData')}
</div>
)
}
return (
<div className="space-y-[12px] pb-[4px]">
{orderRecords.map((record) => (
<OrderCard key={record.id} record={record} onOpenDetails={onOpenDetails} />
))}
</div>
)
}
function PointsTabContent({sessionId}: {sessionId: string}) {
const {t} = useTranslation()
const pointsLogsQuery = useQuery({
queryKey: queryKeys.pointsLogs(sessionId),
enabled: Boolean(sessionId),
gcTime: 0,
queryFn: async () => {
const response = await pointsLogs({
session_id: sessionId,
})
return (response.data.list ?? []).map(mapPointsLogItemToRecord)
},
})
const pointsRecords = pointsLogsQuery.data ?? []
if (!sessionId) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('validation.verificationRequired')}
</div>
)
}
if (pointsLogsQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('record.loading')}
</div>
)
}
if (!pointsRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{t('record.noData')}
</div>
)
}
return (
<div className="space-y-[12px] pb-[4px]">
{pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
</div>
)
}
function RecordPage() {
const {t} = useTranslation()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [tab, setTab] = useState<RecordButtonType>('order')
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
const [orderStatusNoticeQueue, setOrderStatusNoticeQueue] = useState<OrderStatusNotice[]>([])
const currentOrderStatusNotice = orderStatusNoticeQueue[0]
const handleCloseDetails = () => {
setSelectedOrder(null)
}
const handleOrdersChange = useCallback((records: OrderRecord[]) => {
setSelectedOrder((currentOrder) => {
if (!currentOrder) {
return null
}
return records.find((record) => record.id === currentOrder.id) ?? currentOrder
})
}, [])
const handleStatusNotices = useCallback((notices: OrderStatusNotice[]) => {
setOrderStatusNoticeQueue((currentQueue) => [...currentQueue, ...notices])
}, [])
const handleCloseOrderStatusNotice = () => {
setOrderStatusNoticeQueue((currentQueue) => currentQueue.slice(1))
}
return (
<PageLayout contentClassName="flex h-[100svh] w-full flex-col overflow-hidden px-4 pb-8 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-[980px]">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<MotionDiv {...tapMotionPropsIcon}>
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
</MotionDiv>
<span className="text-[14px] font-medium text-white/92">{t('common.back')}</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">{t('record.title')}</div>
<div className="w-[52px]"></div>
</Link>
</div>
<div className="flex min-h-0 flex-1 flex-col pt-[18px] pb-[24px]">
<div className="mx-auto w-full max-w-[860px]">
<div className="mb-[12px] flex justify-end">
<div className="flex gap-[8px]">
<TabButton
active={tab === 'order'}
icon={PackageSearch}
label={t('record.myOrders')}
onClick={() => {
setSelectedOrder(null)
setTab('order')
}}
/>
<TabButton
active={tab === 'record'}
icon={Coins}
label={t('record.pointsRecord')}
onClick={() => {
setSelectedOrder(null)
setTab('record')
}}
/>
</div>
</div>
<div className="h-px bg-white/16"></div>
</div>
<div className="mt-[14px] min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[860px]">
{tab === 'order' ? (
<OrdersTabContent
key="order"
sessionId={sessionId}
onOpenDetails={setSelectedOrder}
onOrdersChange={handleOrdersChange}
onStatusNotices={handleStatusNotices}
/>
) : (
<PointsTabContent key="record" sessionId={sessionId} />
)}
</div>
</div>
</div>
<Modal
open={Boolean(selectedOrder)}
title={t('record.orderDetails')}
onClose={handleCloseDetails}
className="max-w-[420px]"
bodyClassName="pt-[0px]"
footer={
<Button type="button" className="h-[36px] w-full sm:min-w-[94px] sm:w-auto" onClick={handleCloseDetails}>
{t('common.close')}
</Button>
}
>
{selectedOrder ? (
<div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
{[
{ label: t('record.orderNumber'), value: selectedOrder.orderNumber ?? '--' },
{ label: t('record.orderTime'), value: `${selectedOrder.date} ${selectedOrder.time}` },
{ label: t('record.orderType'), value: selectedOrder.category },
{ label: t('record.itemName'), value: selectedOrder.title },
...(selectedOrder.trackingNumber
? [{ label: t('record.trackingNumber'), value: selectedOrder.trackingNumber }]
: []),
{ label: t('record.points'), value: selectedOrder.points.replace(/^-/, '') },
{ label: t('record.status'), value: selectedOrder.status },
].map((item) => (
<div key={item.label} className="border-b border-white/8 py-[10px] last:border-b-0">
<div className="text-[13px] text-white/48">{item.label}</div>
<div className="mt-[4px] text-[14px] text-white">{item.value}</div>
</div>
))}
</div>
) : null}
</Modal>
<Modal
open={Boolean(currentOrderStatusNotice)}
title={currentOrderStatusNotice?.statusCode === 'COMPLETED'
? t('record.statusNotice.approvedTitle')
: t('record.statusNotice.rejectedTitle')}
onClose={handleCloseOrderStatusNotice}
className="max-w-[420px]"
bodyClassName="pt-[0px]"
footer={
<Button
type="button"
className="h-[36px] w-full sm:min-w-[94px] sm:w-auto"
onClick={handleCloseOrderStatusNotice}
>
{t('record.statusNotice.acknowledge')}
</Button>
}
>
{currentOrderStatusNotice ? (
<div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[16px] text-[14px] leading-[1.65] text-white">
<div>
{currentOrderStatusNotice.statusCode === 'COMPLETED'
? t('record.statusNotice.approvedDescription')
: t('record.statusNotice.rejectedDescription')}
</div>
<div className="mt-[10px] text-white/58">
{currentOrderStatusNotice.orderNumber ?? currentOrderStatusNotice.title}
</div>
</div>
) : null}
</Modal>
</PageLayout>
)
}
export default RecordPage