feat(crash): 新增摸红商城前台三页

This commit is contained in:
yml2213
2026-10-30 17:30:09 +08:00
parent 68b0a94b63
commit a6027c0c4d
7 changed files with 554 additions and 0 deletions
+12
View File
@@ -85,6 +85,15 @@ const InviteLandingPage = lazy(() =>
default: module.InviteLandingPage,
}))
)
const CrashListPage = lazy(() =>
import('@/features/crash/CrashListPage').then(module => ({ default: module.CrashListPage }))
)
const CrashDetailPage = lazy(() =>
import('@/features/crash/CrashDetailPage').then(module => ({ default: module.CrashDetailPage }))
)
const CrashOrdersPage = lazy(() =>
import('@/features/crash/CrashOrdersPage').then(module => ({ default: module.CrashOrdersPage }))
)
const MessagesPage = lazy(() =>
import('@/features/messages/MessagesPage').then(module => ({ default: module.MessagesPage }))
)
@@ -456,6 +465,9 @@ export function AppRouter() {
<Route path="announcements/:id" element={<AnnouncementDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="invitations" element={<InvitationsPage />} />
<Route path="crash" element={<CrashListPage />} />
<Route path="crash/orders" element={<CrashOrdersPage />} />
<Route path="crash/:id" element={<CrashDetailPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<ChatPage />} />
<Route path="seller/listings" element={<SellerListingsPage />} />
@@ -0,0 +1,108 @@
import { useState } from 'react'
import { Alert, App, Button, Card, Image, InputNumber, Space, Tag, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
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 CrashDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const { message } = App.useApp()
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 })
message.success('下单成功')
try {
const payment = await startCrashPayment(order.id)
if (payment?.pay_url) {
window.location.href = payment.pay_url
return
}
} catch {
// 支付未配置时允许稍后在订单页继续支付
}
navigate('/crash/orders')
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
return (
<div className="wallet-page">
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/crash')}>
返回商城
</Button>
<Button onClick={() => navigate('/crash/orders')}>我的撞车订单</Button>
</Space>
{productQuery.isError ? (
<Alert type="error" showIcon message="商品加载失败" description="商品可能已下架。" />
) : null}
{product ? (
<Card>
<div style={{ display: 'grid', gap: 20, gridTemplateColumns: 'minmax(0, 360px) minmax(0, 1fr)' }}>
{product.cover_url ? (
<Image src={product.cover_url} alt={product.title} width="100%" style={{ borderRadius: 10 }} />
) : null}
<div className="admin-stack" style={{ gap: 12 }}>
<Typography.Title level={2} className="brand-title" style={{ margin: 0 }}>
{product.title}
</Typography.Title>
<Space size={8} wrap>
<Tag bordered={false}>{product.category_name || '未分类'}</Tag>
<Tag bordered={false} color={product.stock < 0 ? 'green' : 'blue'}>
{product.stock < 0 ? '不限量' : `库存 ${product.stock}`}
</Tag>
<Tag bordered={false}>{product.unit || '份'}</Tag>
</Space>
<Space size={10}>
<Typography.Title level={3} style={{ margin: 0, color: 'var(--brand-seal)' }}>
{formatMoney(product.price_cent)}
</Typography.Title>
{product.original_price_cent > 0 ? (
<Typography.Text delete type="secondary">
{formatMoney(product.original_price_cent)}
</Typography.Text>
) : null}
</Space>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap' }}>
{product.description}
</Typography.Paragraph>
<Space size={12}>
<span>数量</span>
<InputNumber min={1} max={99} value={quantity} onChange={value => setQuantity(Number(value) || 1)} />
<Typography.Text strong>
合计 {formatMoney(product.price_cent * quantity)}
</Typography.Text>
</Space>
<Button type="primary" size="large" loading={submitting} onClick={() => void submit()}>
立即下单
</Button>
<Typography.Text type="secondary">
下单需登录并完成实名认证;支付成功后由客服接单处理。
</Typography.Text>
</div>
</div>
</Card>
) : null}
</div>
)
}
@@ -0,0 +1,109 @@
import { useState } from 'react'
import { Alert, Button, Card, Empty, Image, Pagination, Segmented, Space, Tag, Typography } from 'antd'
import { Link, useNavigate } from 'react-router-dom'
import { useCrashCategories, useCrashProducts } from '@/features/crash/api'
import { useSessionStore } from '@/stores/session'
import { formatMoney } from '@/shared/utils/format'
export function CrashListPage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const [categoryId, setCategoryId] = useState<number | undefined>()
const [page, setPage] = useState(1)
const categoriesQuery = useCrashCategories()
const productsQuery = useCrashProducts({ category_id: categoryId, page, page_size: 12 })
const items = productsQuery.data?.items ?? []
return (
<div className="wallet-page">
<section className="wallet-hero wallet-hero--compact">
<div>
<Typography.Text className="wallet-eyebrow">JIEYOU · 撞车商城</Typography.Text>
<Typography.Title level={1} className="brand-title wallet-title">
摸红商城
</Typography.Title>
<Typography.Paragraph className="wallet-desc">
明码标价、下单即锁库存;支付后由客服接单处理,订单可在「我的撞车订单」查看。
</Typography.Paragraph>
</div>
<div className="wallet-hero__action">
<span>共</span>
<strong>{productsQuery.data?.total ?? 0}</strong>
<Space size={8} wrap>
<Button onClick={() => navigate('/crash/orders')}>我的撞车订单</Button>
</Space>
</div>
</section>
<Segmented
value={categoryId ?? 0}
options={[
{ value: 0, label: '全部' },
...(categoriesQuery.data ?? []).map(item => ({ value: item.id, label: item.name })),
]}
onChange={value => {
setCategoryId(Number(value) || undefined)
setPage(1)
}}
/>
{productsQuery.isError ? <Alert type="error" showIcon message="商品加载失败" /> : null}
{items.length === 0 && !productsQuery.isPending ? (
<Card>
<Empty description="暂无商品" />
</Card>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 14,
}}
>
{items.map(item => (
<Card key={item.id} styles={{ body: { padding: 12 } }}>
<Link to={`/crash/${item.id}`}>
{item.cover_url ? (
<Image src={item.cover_url} alt={item.title} height={140} width="100%" style={{ objectFit: 'cover', borderRadius: 8 }} preview={false} />
) : null}
<div className="admin-stack" style={{ marginTop: 10 }}>
<strong>{item.title}</strong>
<Space size={6} wrap>
<Typography.Text strong>{formatMoney(item.price_cent)}</Typography.Text>
{item.original_price_cent > 0 ? (
<Typography.Text delete type="secondary">
{formatMoney(item.original_price_cent)}
</Typography.Text>
) : null}
<Tag bordered={false}>{item.category_name || '未分类'}</Tag>
<Tag bordered={false} color={item.stock < 0 ? 'green' : item.stock > 0 ? 'blue' : 'default'}>
{item.stock < 0 ? '不限量' : `库存 ${item.stock}`}
</Tag>
</Space>
</div>
</Link>
</Card>
))}
</div>
)}
{(productsQuery.data?.total ?? 0) > 12 ? (
<Pagination
current={page}
pageSize={12}
total={productsQuery.data?.total ?? 0}
showSizeChanger={false}
onChange={next => setPage(next)}
style={{ textAlign: 'center' }}
/>
) : null}
{!user ? (
<Typography.Text type="secondary">登录后可下单;下单需要完成实名认证。</Typography.Text>
) : null}
</div>
)
}
@@ -0,0 +1,156 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Alert, App, Button, Card, Empty, Image, Space, Tag, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
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: string }> = {
pending_payment: { label: '待支付', color: 'gold' },
paid: { label: '已支付', color: 'blue' },
received: { label: '已接单', color: 'cyan' },
completed: { label: '已完成', color: 'green' },
cancelled: { label: '已取消', color: 'default' },
refunded: { label: '已退款', color: 'red' },
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
return response?.data?.message || (error instanceof Error ? error.message : '操作失败')
}
export function CrashOrdersPage() {
const navigate = useNavigate()
const { message } = App.useApp()
const queryClient = useQueryClient()
const user = useSessionStore(state => state.user)
const [page, setPage] = useState(1)
const ordersQuery = useMyCrashOrders(page, 10)
const items = ordersQuery.data?.items ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['crash-orders'] })
if (!user) {
return (
<div className="wallet-page">
<Alert
type="info"
showIcon
message="登录后查看撞车订单"
action={<Button size="small" onClick={() => navigate('/login')}>去登录</Button>}
/>
</div>
)
}
return (
<div className="wallet-page">
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/crash')}>
返回商城
</Button>
</Space>
{ordersQuery.isError ? <Alert type="error" showIcon message="订单加载失败" /> : null}
{items.length === 0 && !ordersQuery.isPending ? (
<Card>
<Empty description="还没有撞车订单" />
</Card>
) : (
<div className="admin-stack" style={{ gap: 12 }}>
{items.map(order => {
const meta = statusMeta[order.status] ?? { label: order.status, color: 'default' }
return (
<Card key={order.id}>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-start' }}>
{order.product_cover_url ? (
<Image src={order.product_cover_url} width={84} height={84} style={{ objectFit: 'cover', borderRadius: 8 }} preview={false} />
) : null}
<div className="admin-stack" style={{ flex: 1 }}>
<Space size={8} wrap>
<strong>{order.product_title || `订单 ${order.order_no}`}</strong>
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
</Space>
<span className="admin-mono">{order.order_no}</span>
<span>
{formatMoney(order.amount_cent)} · 数量 {order.quantity}
</span>
<span className="wallet-muted">下单 {formatDateTime(order.created_at)}</span>
{order.cancel_reason ? <span className="wallet-muted">原因:{order.cancel_reason}</span> : null}
<Space size={8} wrap>
{order.status === 'pending_payment' ? (
<>
<Button
type="primary"
size="small"
onClick={async () => {
try {
const payment = await startCrashPayment(order.id)
if (payment?.pay_url) {
window.location.href = payment.pay_url
} else {
message.success('支付已发起,请在支付渠道完成付款')
}
await invalidate()
} catch (error) {
message.error(errorText(error))
}
}}
>
去支付
</Button>
<Button
size="small"
onClick={async () => {
try {
await cancelCrashOrder(order.id)
message.success('订单已取消')
await invalidate()
} catch (error) {
message.error(errorText(error))
}
}}
>
取消订单
</Button>
</>
) : null}
{order.qrcode_url_snapshot ? (
<Button size="small" onClick={() => window.open(order.qrcode_url_snapshot, '_blank')}>
查看二维码
</Button>
) : null}
</Space>
</div>
</div>
</Card>
)
})}
</div>
)}
{(ordersQuery.data?.total ?? 0) > 10 ? (
<Space style={{ justifyContent: 'center' }}>
<Button disabled={page <= 1} onClick={() => setPage(current => current - 1)}>
上一页
</Button>
<Typography.Text>
{page} / {Math.ceil((ordersQuery.data?.total ?? 0) / 10)}
</Typography.Text>
<Button
disabled={page >= Math.ceil((ordersQuery.data?.total ?? 0) / 10)}
onClick={() => setPage(current => current + 1)}
>
下一页
</Button>
</Space>
) : null}
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
import type {
CrashCategory,
CrashOrder,
CrashOrderPage,
CrashProduct,
CrashProductPage,
} from '@/features/crash/types'
export function fetchCrashCategories() {
return unwrapData<CrashCategory[]>(
apiClient.get<ApiResponse<CrashCategory[]>>('/crash/categories')
)
}
export function useCrashCategories() {
return useQuery({ queryKey: ['crash-categories'], queryFn: fetchCrashCategories })
}
export function fetchCrashProducts(query: {
category_id?: number
keyword?: string
page?: number
page_size?: number
}) {
return unwrapData<Partial<CrashProductPage>>(
apiClient.get<ApiResponse<Partial<CrashProductPage>>>('/crash/products', {
params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query },
})
).then(data => ({
items: (data.items ?? []) as CrashProduct[],
total: data.total ?? 0,
page: data.page ?? query.page ?? 1,
page_size: data.page_size ?? query.page_size ?? 20,
}))
}
export function useCrashProducts(query: {
category_id?: number
keyword?: string
page?: number
page_size?: number
}) {
return useQuery({
queryKey: ['crash-products', query],
queryFn: () => fetchCrashProducts(query),
})
}
export function useCrashProduct(id?: string) {
return useQuery({
queryKey: ['crash-product', id],
queryFn: () =>
unwrapData<CrashProduct>(
apiClient.get<ApiResponse<CrashProduct>>(`/crash/products/${id}`)
),
enabled: Boolean(id),
})
}
export function createCrashOrder(payload: { product_id: number; quantity: number }) {
return unwrapData<CrashOrder>(
apiClient.post<ApiResponse<CrashOrder>>('/crash/orders', payload)
)
}
export function fetchMyCrashOrders(page = 1, pageSize = 20) {
return unwrapData<Partial<CrashOrderPage>>(
apiClient.get<ApiResponse<Partial<CrashOrderPage>>>('/crash/orders', {
params: { page, page_size: pageSize },
})
).then(data => ({
items: (data.items ?? []) as CrashOrder[],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
}))
}
export function useMyCrashOrders(page = 1, pageSize = 20) {
return useQuery({
queryKey: ['crash-orders', page, pageSize],
queryFn: () => fetchMyCrashOrders(page, pageSize),
})
}
export function cancelCrashOrder(id: number) {
return unwrapData<CrashOrder>(
apiClient.post<ApiResponse<CrashOrder>>(`/crash/orders/${id}/cancel`)
)
}
export function startCrashPayment(id: number) {
return unwrapData<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>(
apiClient.post<ApiResponse<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>>(
`/crash/orders/${id}/start-payment`
)
)
}
+66
View File
@@ -0,0 +1,66 @@
export interface CrashCategory {
id: number
name: string
code: string
sort_order: number
status: string
product_count: number
}
export interface CrashProduct {
id: number
category_id?: number | null
category_name?: string
title: string
cover_url: string
image_urls: string[]
description: string
price_cent: number
original_price_cent: number
unit: string
stock: number
status: string
}
export interface CrashProductPage {
items: CrashProduct[]
total: number
page: number
page_size: number
}
export interface CrashOrderItem {
product_id: number
title: string
cover_url: string
unit: string
quantity: number
unit_price_cent: number
amount_cent: number
}
export interface CrashOrder {
id: number
order_no: string
status: string
amount_cent: number
quantity: number
product_title: string
product_cover_url: string
qrcode_url_snapshot: string
copy_text: string
items?: CrashOrderItem[]
admin_remark?: string
cancel_reason?: string
paid_at?: string | null
completed_at?: string | null
cancelled_at?: string | null
created_at: string
}
export interface CrashOrderPage {
items: CrashOrder[]
total: number
page: number
page_size: number
}
@@ -32,6 +32,7 @@ export function StorefrontLayout() {
</Badge>
</Link>
<Link to="/seller/listings">卖家中心</Link>
<Link to="/crash">摸红商城</Link>
<Link to="/announcements">公告</Link>
<a href="/#process">流程</a>
<a href="/#rules">规则</a>