feat(auth): 添加iframe轮播消息支持并优化地址簿和订单状态处理

- 添加HostWheelMessage类型定义和IFRAME_WHEEL消息常量
- 实现iframe轮播事件监听和消息发送功能
- 移除address.type.ts中的region和region_text字段
- 更新英文本地化文件中的completed状态为Approved
- 添加订单状态通知相关的国际化文案
- 更新常量文件中的订单状态描述注释
- 在index.ts中添加HostWheelMessage和OrderStatusCode类型
- 修改record页面的订单状态处理逻辑
- 实现订单状态变更通知弹窗功能
- 优化地址簿获取地址文本的方法
- 修复地址列表API响应数据访问问题
- 添加playx.html测试页面和相关配置文件
This commit is contained in:
JiaJun
2026-07-27 14:23:45 +08:00
parent 8002650b95
commit 3eb03da0da
12 changed files with 442 additions and 61 deletions

View File

@@ -0,0 +1,12 @@
{
"pid": 62123,
"port": 17175,
"host": "0.0.0.0",
"base": "/",
"framework": "vite-react",
"command": "/Users/jiaunun/.nvm/versions/node/v24.15.0/bin/node /Users/jiaunun/Desktop/JiaJun/palyX/node_modules/vite/bin/vite.js --host 0.0.0.0 --port 17175 --strictPort --base /",
"internalUrl": "http://127.0.0.1:17175/",
"logPath": "/Users/jiaunun/Desktop/JiaJun/palyX/.cloudbase-sites/logs/preview-1785130890070.log",
"cwd": "/Users/jiaunun/Desktop/JiaJun/palyX",
"startedAt": "2026-07-27T05:41:31.101Z"
}

105
public/playx.html Normal file
View File

@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
>
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>PlayX</title>
<style>
* {
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
}
main {
display: flex;
height: calc(100% - 80px);
width: 100%;
}
header {
background-color: red;
height: 80px;
width: 100%;
}
aside {
background-color: blue;
height: 100%;
width: 200px;
}
section {
background-color: yellow;
height: 100%;
width: calc(100% - 200px);
overflow-y: auto;
overflow-x: hidden;
}
</style>
</head>
<body>
<header>header</header>
<main>
<aside>aside</aside>
<section id="host-scroll-region">
<div id="iframe-container" style="height: 100%">
<iframe
id="playx-frame"
src="https://playx-mall.cjdhr.top"
width="100%"
height="100%"
></iframe>
</div>
<footer style="height: 80px; background-color: green;">footer</footer>
</section>
</main>
<script>
const frame = document.getElementById('playx-frame')
const iframeContainer = document.getElementById('iframe-container')
const hostScrollRegion = document.getElementById('host-scroll-region')
frame.addEventListener('load', () => {
iframeContainer.style.height = '100%'
hostScrollRegion.scrollTop = 0
})
window.addEventListener('message', (event) => {
if (event.source !== frame.contentWindow) {
return
}
if (event.data?.type === 'IFRAME_HEIGHT') {
const height = Number(event.data?.payload?.height)
if (!Number.isFinite(height) || height <= 0) {
return
}
iframeContainer.style.height = `${height}px`
return
}
if (event.data?.type === 'IFRAME_WHEEL') {
const downTotalY = Number(event.data?.payload?.downTotalY)
if (!Number.isFinite(downTotalY)) {
return
}
const maxScrollTop = Math.max(hostScrollRegion.scrollHeight - hostScrollRegion.clientHeight, 0)
hostScrollRegion.scrollTop = Math.min(Math.max(downTotalY, 0), maxScrollTop)
}
})
</script>
</body>
</html>

View File

@@ -330,6 +330,20 @@
scheduleHeightLog(event.data)
return
}
if (event.source === frame.contentWindow && event.data?.type === 'IFRAME_WHEEL') {
const downTotalY = Number(event.data?.payload?.downTotalY)
const upTotalY = Number(event.data?.payload?.upTotalY)
if (!Number.isFinite(downTotalY) || !Number.isFinite(upTotalY)) {
return
}
appendLog('Received IFRAME_WHEEL from iframe', {
downTotalY,
upTotalY,
})
}
})
</script>
</body>

View File

@@ -35,5 +35,5 @@ export const HOME_GOOD_TYPE_ORDER = ['WITHDRAW', 'BONUS', 'PHYSICAL'] as const s
export const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ?? 'https://playx-api.cjdhr.top'
/**@description 待处理 / 已完成 / 已发货 / 已驳回 */
/**@description 待处理 / 已通过 / 已发货 / 已拒绝 */
export const ORDER_STATUS = ['PENDING','COMPLETED','SHIPPED','REJECTED']

View File

@@ -22,8 +22,7 @@ const emptyAddressForm: AddAddressForm = {
}
export function getAddressText(item: AddressListItem) {
const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ')
return [regionText, item.detail_address].filter(Boolean).join(', ')
return item.detail_address.trim()
}
export function mapAddressToOption(item: AddressListItem): AddressOption {
@@ -56,7 +55,7 @@ export function useAddressBook(options?: UseAddressBookOptions) {
enabled: Boolean(sessionId) && Boolean(options?.autoLoad),
queryFn: async () => {
const response = await addressList({session_id: sessionId!})
return response.data.list
return response.data?.list ?? []
},
})
@@ -108,7 +107,7 @@ export function useAddressBook(options?: UseAddressBookOptions) {
queryKey: queryKeys.addressList(sessionId),
queryFn: async () => {
const response = await addressList({session_id: sessionId})
return response.data.list
return response.data?.list ?? []
},
})

View File

@@ -6,10 +6,11 @@ import {userAssets} from '@/api/user.ts'
import {normalizeLanguage} from '@/lib/i18n'
import {queryKeys} from '@/lib/queryKeys.ts'
import {useUserStore} from '@/store/user.ts'
import type {HostContextMessage} from '@/types'
import type {HostContextMessage, HostWheelMessage} from '@/types'
const HOST_READY_MESSAGE = 'IFRAME_READY'
const HOST_HEIGHT_MESSAGE = 'IFRAME_HEIGHT'
const HOST_WHEEL_MESSAGE = 'IFRAME_WHEEL'
const HOST_READY_INTERVAL = 500
const HOST_READY_RETRY_LIMIT = 20
const HOST_HEIGHT_REPORT_INTERVAL = 250
@@ -240,5 +241,93 @@ export function AuthGuide({children}: PropsWithChildren) {
}
}, [])
useEffect(() => {
if (window.parent === window) {
return
}
let frameId = 0
let hasPendingWheelUpdate = false
let virtualScrollY = 0
const measurePageHeight = () =>
Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight,
document.body.offsetHeight,
document.documentElement.offsetHeight,
)
const getPageHeight = () =>
Math.max(window.innerHeight, document.documentElement.clientHeight, 1)
const normalizeWheelDeltaY = (event: WheelEvent) => {
switch (event.deltaMode) {
case WheelEvent.DOM_DELTA_LINE:
return event.deltaY * 16
case WheelEvent.DOM_DELTA_PAGE:
return event.deltaY * getPageHeight()
case WheelEvent.DOM_DELTA_PIXEL:
default:
return event.deltaY
}
}
const flushWheelDelta = () => {
frameId = 0
if (!hasPendingWheelUpdate) {
return
}
const message: HostWheelMessage = {
type: HOST_WHEEL_MESSAGE,
payload: {
downTotalY: virtualScrollY,
upTotalY: Math.max(measurePageHeight() - virtualScrollY, 0),
},
}
console.log('[playx] posting wheel position to host:', message)
hasPendingWheelUpdate = false
window.parent.postMessage(message, '*')
}
const scheduleWheelPost = () => {
if (frameId) {
return
}
frameId = window.requestAnimationFrame(flushWheelDelta)
}
const handleWheel = (event: WheelEvent) => {
const deltaY = normalizeWheelDeltaY(event)
if (!Number.isFinite(deltaY) || deltaY === 0) {
return
}
const pageHeight = measurePageHeight()
const previousVirtualScrollY = virtualScrollY
virtualScrollY = Math.min(Math.max(virtualScrollY + deltaY, 0), pageHeight)
if (virtualScrollY === previousVirtualScrollY) {
return
}
hasPendingWheelUpdate = true
scheduleWheelPost()
}
window.addEventListener('wheel', handleWheel, {passive: true, capture: true})
return () => {
window.removeEventListener('wheel', handleWheel, true)
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [])
return <>{children}</>
}

View File

@@ -130,10 +130,17 @@ const en = {
},
statusLabel: {
pending: 'Pending',
completed: 'Completed',
completed: 'Approved',
shipped: 'Shipped',
rejected: 'Rejected',
},
statusNotice: {
approvedTitle: 'Order Approved',
rejectedTitle: 'Order Rejected',
approvedDescription: 'Your order has been approved.',
rejectedDescription: 'Your order has been rejected.',
acknowledge: 'Got it',
},
},
validation: {
sessionExpired: 'Session expired. Please log in again.',

View File

@@ -130,10 +130,17 @@ const ms = {
},
statusLabel: {
pending: 'Menunggu',
completed: 'Selesai',
completed: 'Diluluskan',
shipped: 'Dihantar',
rejected: 'Ditolak',
},
statusNotice: {
approvedTitle: 'Pesanan Diluluskan',
rejectedTitle: 'Pesanan Ditolak',
approvedDescription: 'Pesanan anda telah diluluskan.',
rejectedDescription: 'Pesanan anda telah ditolak.',
acknowledge: 'Faham',
},
},
validation: {
sessionExpired: 'Sesi telah tamat. Sila log masuk semula.',

View File

@@ -130,9 +130,16 @@ const zh = {
},
statusLabel: {
pending: '待处理',
completed: '已完成',
completed: '已通过',
shipped: '已发货',
rejected: '已驳回',
rejected: '已拒绝',
},
statusNotice: {
approvedTitle: '订单已通过',
rejectedTitle: '订单已拒绝',
approvedDescription: '订单已经通过。',
rejectedDescription: '订单已拒绝。',
acknowledge: '知道了',
},
},
validation: {

View File

@@ -12,14 +12,12 @@ export interface AddressInfo {
export type AddressListItem = {
id: number
phone: string
region: string[]
detail_address: string
default_setting: 0 | 1
create_time: number
update_time: number
playx_user_asset_id: number
receiver_name: string
region_text: string
}
export type AddressListData = {

View File

@@ -12,6 +12,14 @@ export type HostContextMessage = {
}
}
export type HostWheelMessage = {
type: 'IFRAME_WHEEL'
payload: {
downTotalY: number
upTotalY: number
}
}
export type ButtonVariant = 'orange' | 'gray'
export type ButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
@@ -94,6 +102,8 @@ export type AccountTableRow = {
export type PointsRecordTone = 'positive' | 'negative'
export type OrderStatusCode = 'PENDING' | 'COMPLETED' | 'SHIPPED' | 'REJECTED' | 'UNKNOWN'
export type OrderRecord = {
id: string
orderNumber?: string
@@ -103,6 +113,7 @@ export type OrderRecord = {
title: string
trackingNumber?: string
status: string
statusCode: OrderStatusCode
points: string
}

View File

@@ -1,4 +1,4 @@
import {useState} from 'react'
import {useCallback, useEffect, useRef, useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useTranslation} from 'react-i18next'
@@ -9,7 +9,7 @@ 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, PointsCardProps, RecordButtonType, TabButtonProps } from '@/types'
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'
@@ -22,6 +22,16 @@ const pointsRecordToneClassName = {
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')
}
@@ -59,48 +69,53 @@ function toTitleCase(value: string) {
.join(' ')
}
function getOrderStatus(status?: string | number) {
function normalizeOrderStatus(status?: string | number): OrderStatusCode {
if (typeof status === 'string') {
const normalizedStatus = status.trim().toUpperCase()
const matchedStatus = ORDER_STATUS.find((item) => item === normalizedStatus)
if (matchedStatus) {
switch (matchedStatus) {
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')
}
if (ORDER_STATUS.includes(normalizedStatus)) {
return normalizedStatus as OrderStatusCode
}
}
const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status)
? Number(status)
const normalizedStatus = typeof status === 'string' && /^\d+$/.test(status.trim())
? Number(status.trim())
: status
if (typeof normalizedStatus === 'number') {
switch (normalizedStatus) {
case 0:
return i18n.t('record.statusLabel.pending')
return 'PENDING'
case 1:
return i18n.t('record.statusLabel.completed')
return 'COMPLETED'
case 2:
return i18n.t('record.statusLabel.shipped')
return 'SHIPPED'
case 3:
return i18n.t('record.statusLabel.rejected')
return 'REJECTED'
default:
return String(normalizedStatus)
return 'UNKNOWN'
}
}
if (!normalizedStatus) {
return i18n.t('record.statusLabel.pending')
}
return 'UNKNOWN'
}
return toTitleCase(normalizedStatus)
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) {
@@ -175,6 +190,7 @@ 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),
@@ -184,7 +200,8 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord {
category: getOrderCategory(item),
title: item.item_title ?? item.title ?? item.mallItem?.title ?? i18n.t('record.untitledOrder'),
trackingNumber: getTrackingNumber(item),
status: getOrderStatus(item.status),
status: getOrderStatusLabel(statusCode, item.status),
statusCode,
points: points.display,
}
}
@@ -244,25 +261,19 @@ function mapPointsLogItemToRecord(item: PointsLogItem) {
}
}
function getOrderStatusClassName(status: string) {
const normalizedStatus = status.toLowerCase()
if (normalizedStatus === 'completed' || status === i18n.t('record.statusLabel.completed')) {
return 'bg-[#9BFFC0] text-[#176640]'
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'
}
if (normalizedStatus === 'shipped' || status === i18n.t('record.statusLabel.shipped')) {
return 'bg-[#95F0FF] text-[#116A79]'
}
if (normalizedStatus === 'pending' || status === i18n.t('record.statusLabel.pending')) {
return 'bg-[#FFF18C] text-[#7F6A0D]'
}
if (normalizedStatus === 'rejected' || status === i18n.t('record.statusLabel.rejected')) {
return 'bg-[#FFB1C0] text-[#7C2941]'
}
return 'bg-white/15 text-white/80'
}
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
@@ -314,7 +325,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
<div
className={cn(
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
getOrderStatusClassName(record.status),
getOrderStatusClassName(record.statusCode),
)}
>
{record.status}
@@ -352,24 +363,87 @@ function PointsCard({ record }: PointsCardProps) {
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)
return (response.data?.list ?? []).map(mapOrderItemToRecord)
},
})
const orderRecords = ordersQuery.data ?? []
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 (
@@ -456,11 +530,31 @@ function RecordPage() {
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]">
@@ -510,7 +604,13 @@ function RecordPage() {
<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} />
<OrdersTabContent
key="order"
sessionId={sessionId}
onOpenDetails={setSelectedOrder}
onOrdersChange={handleOrdersChange}
onStatusNotices={handleStatusNotices}
/>
) : (
<PointsTabContent key="record" sessionId={sessionId} />
)}
@@ -551,6 +651,38 @@ function RecordPage() {
</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>
)
}