feat(crash): 新增移动端摸红商城三页

This commit is contained in:
yml2213
2026-10-30 17:30:56 +08:00
parent a6027c0c4d
commit d10419d325
4 changed files with 298 additions and 0 deletions
+18
View File
@@ -149,6 +149,21 @@ const MobileAddHomeGuidePage = lazy(() =>
default: module.MobileAddHomeGuidePage,
}))
)
const MobileCrashListPage = lazy(() =>
import('@/features/mobile/MobileCrashListPage').then(module => ({
default: module.MobileCrashListPage,
}))
)
const MobileCrashDetailPage = lazy(() =>
import('@/features/mobile/MobileCrashDetailPage').then(module => ({
default: module.MobileCrashDetailPage,
}))
)
const MobileCrashOrdersPage = lazy(() =>
import('@/features/mobile/MobileCrashOrdersPage').then(module => ({
default: module.MobileCrashOrdersPage,
}))
)
const MobileOrdersPage = lazy(() =>
import('@/features/mobile/MobileOrdersPage').then(module => ({
default: module.MobileOrdersPage,
@@ -503,6 +518,9 @@ export function AppRouter() {
<Route path="invitations" element={<MobileInvitationsPage />} />
<Route path="register" element={<MobileRegisterPage />} />
<Route path="add-home-guide" element={<MobileAddHomeGuidePage />} />
<Route path="crash" element={<MobileCrashListPage />} />
<Route path="crash/orders" element={<MobileCrashOrdersPage />} />
<Route path="crash/:id" element={<MobileCrashDetailPage />} />
</Route>
<Route path={`${adminBase}/login`} element={<AdminLoginPage />} />
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { Button, Card, Dialog, Image, NavBar, Stepper, Toast } from 'antd-mobile'
import { useNavigate, useParams } from 'react-router-dom'
import { createCrashOrder, startCrashPayment, useCrashProduct } from '@/features/crash/api'
import { formatMoney } from '@/shared/utils/format'
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
return response?.data?.message || (error instanceof Error ? error.message : '下单失败')
}
export function MobileCrashDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const [quantity, setQuantity] = useState(1)
const [submitting, setSubmitting] = useState(false)
const productQuery = useCrashProduct(id)
const product = productQuery.data
const submit = async () => {
if (!product) return
setSubmitting(true)
try {
const order = await createCrashOrder({ product_id: product.id, quantity })
try {
const payment = await startCrashPayment(order.id)
if (payment?.pay_url) {
window.location.href = payment.pay_url
return
}
} catch {
// 支付未配置时稍后在订单页继续
}
Toast.show({ icon: 'success', content: '下单成功' })
navigate('/m/crash/orders')
} catch (error) {
await Dialog.alert({ content: errorText(error), confirmText: '知道了' })
} finally {
setSubmitting(false)
}
}
return (
<div className="mobile-wallet">
<NavBar
onBack={() => navigate('/m/crash')}
right={
<Button size="mini" fill="none" onClick={() => navigate('/m/crash/orders')}>
我的订单
</Button>
}
>
商品详情
</NavBar>
{product ? (
<>
{product.cover_url ? (
<Image src={product.cover_url} width="100%" height={200} fit="cover" style={{ borderRadius: 10 }} />
) : null}
<Card>
<div className="admin-stack" style={{ gap: 8 }}>
<strong style={{ fontSize: 18 }}>{product.title}</strong>
<div className="mobile-wallet__sub">
{formatMoney(product.price_cent)} · {product.category_name || '未分类'}
</div>
<div className="mobile-wallet__sub">{product.description}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span>数量</span>
<Stepper min={1} max={99} value={quantity} onChange={value => setQuantity(value)} />
<strong>合计 {formatMoney(product.price_cent * quantity)}</strong>
</div>
</div>
</Card>
<Button block color="primary" loading={submitting} onClick={() => void submit()}>
立即下单
</Button>
<div className="mobile-wallet__hint">下单需登录并完成实名;支付成功后由客服接单处理。</div>
</>
) : null}
</div>
)
}
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { Button, Card, ErrorBlock, Image, NavBar, Skeleton, Tag } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { useCrashCategories, useCrashProducts } from '@/features/crash/api'
import { formatMoney } from '@/shared/utils/format'
export function MobileCrashListPage() {
const navigate = useNavigate()
const [categoryId, setCategoryId] = useState<number | undefined>()
const categoriesQuery = useCrashCategories()
const productsQuery = useCrashProducts({ category_id: categoryId, page: 1, page_size: 30 })
const items = productsQuery.data?.items ?? []
return (
<div className="mobile-wallet">
<NavBar
onBack={() => navigate('/m')}
right={
<Button size="mini" fill="none" onClick={() => navigate('/m/crash/orders')}>
我的订单
</Button>
}
>
摸红商城
</NavBar>
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', padding: '4px 0 8px' }}>
<Tag
color={categoryId ? 'default' : 'primary'}
onClick={() => setCategoryId(undefined)}
>
全部
</Tag>
{(categoriesQuery.data ?? []).map(item => (
<Tag
key={item.id}
color={categoryId === item.id ? 'primary' : 'default'}
onClick={() => setCategoryId(item.id)}
>
{item.name}
</Tag>
))}
</div>
{productsQuery.isPending ? (
<Card>
<Skeleton.Paragraph lineCount={4} animated />
</Card>
) : items.length === 0 ? (
<ErrorBlock status="empty" title="暂无商品" />
) : (
<div style={{ display: 'grid', gap: 10 }}>
{items.map(item => (
<Card key={item.id} onClick={() => navigate(`/m/crash/${item.id}`)}>
<div style={{ display: 'flex', gap: 12 }}>
{item.cover_url ? (
<Image src={item.cover_url} width={78} height={78} fit="cover" style={{ borderRadius: 8 }} />
) : null}
<div className="admin-stack" style={{ flex: 1, gap: 4 }}>
<strong>{item.title}</strong>
<div className="mobile-wallet__sub">
{formatMoney(item.price_cent)} · {item.category_name || '未分类'}
</div>
<Tag color={item.stock < 0 ? 'success' : 'primary'}>
{item.stock < 0 ? '不限量' : `库存 ${item.stock}`}
</Tag>
</div>
</div>
</Card>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,120 @@
import { Button, Card, Dialog, ErrorBlock, Image, NavBar, Skeleton, Tag, Toast } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { cancelCrashOrder, startCrashPayment, useMyCrashOrders } from '@/features/crash/api'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney } from '@/shared/utils/format'
const statusMeta: Record<string, { label: string; color: 'primary' | 'success' | 'warning' | 'danger' | 'default' }> = {
pending_payment: { label: '待支付', color: 'warning' },
paid: { label: '已支付', color: 'primary' },
received: { label: '已接单', color: 'primary' },
completed: { label: '已完成', color: 'success' },
cancelled: { label: '已取消', color: 'default' },
refunded: { label: '已退款', color: 'danger' },
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
return response?.data?.message || (error instanceof Error ? error.message : '操作失败')
}
export function MobileCrashOrdersPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const user = useSessionStore(state => state.user)
const ordersQuery = useMyCrashOrders(1, 30)
const items = ordersQuery.data?.items ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['crash-orders'] })
return (
<div className="mobile-wallet">
<NavBar onBack={() => navigate('/m/crash')}>我的撞车订单</NavBar>
{ordersQuery.isPending ? (
<Card>
<Skeleton.Paragraph lineCount={4} animated />
</Card>
) : items.length === 0 ? (
<ErrorBlock status="empty" title="还没有撞车订单" />
) : (
<div style={{ display: 'grid', gap: 10 }}>
{items.map(order => {
const meta = statusMeta[order.status] ?? { label: order.status, color: 'default' as const }
return (
<Card key={order.id}>
<div style={{ display: 'flex', gap: 12 }}>
{order.product_cover_url ? (
<Image
src={order.product_cover_url}
width={72}
height={72}
fit="cover"
style={{ borderRadius: 8 }}
/>
) : null}
<div className="admin-stack" style={{ flex: 1, gap: 4 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<strong>{order.product_title || order.order_no}</strong>
<Tag color={meta.color}>{meta.label}</Tag>
</div>
<div className="mobile-wallet__sub">{order.order_no}</div>
<div className="mobile-wallet__sub">
{formatMoney(order.amount_cent)} · 数量 {order.quantity}
</div>
<div className="mobile-wallet__sub">{formatDateTime(order.created_at)}</div>
{order.status === 'pending_payment' ? (
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Button
size="mini"
color="primary"
onClick={async () => {
try {
const payment = await startCrashPayment(order.id)
if (payment?.pay_url) {
window.location.href = payment.pay_url
} else {
Toast.show({ content: '支付已发起,请在支付渠道完成付款' })
}
await invalidate()
} catch (error) {
Toast.show({ content: errorText(error) })
}
}}
>
去支付
</Button>
<Button
size="mini"
onClick={async () => {
const confirmed = await Dialog.confirm({
content: '确认取消该订单?',
confirmText: '取消订单',
})
if (!confirmed) return
try {
await cancelCrashOrder(order.id)
Toast.show({ icon: 'success', content: '订单已取消' })
await invalidate()
} catch (error) {
Toast.show({ content: errorText(error) })
}
}}
>
取消
</Button>
</div>
) : null}
</div>
</div>
</Card>
)
})}
</div>
)}
{!user ? <div className="mobile-wallet__hint">登录后可查看自己的撞车订单。</div> : null}
</div>
)
}