feat(crash): 新增摸红商城前台三页
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user