diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx
index e44413a..2a1a147 100644
--- a/frontend/src/app/AppRouter.tsx
+++ b/frontend/src/app/AppRouter.tsx
@@ -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() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/src/features/crash/CrashDetailPage.tsx b/frontend/src/features/crash/CrashDetailPage.tsx
new file mode 100644
index 0000000..6ead12c
--- /dev/null
+++ b/frontend/src/features/crash/CrashDetailPage.tsx
@@ -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 (
+
+
+ } onClick={() => navigate('/crash')}>
+ 返回商城
+
+
+
+
+ {productQuery.isError ? (
+
+ ) : null}
+
+ {product ? (
+
+
+ {product.cover_url ? (
+
+ ) : null}
+
+
+ {product.title}
+
+
+ {product.category_name || '未分类'}
+
+ {product.stock < 0 ? '不限量' : `库存 ${product.stock}`}
+
+ {product.unit || '份'}
+
+
+
+ {formatMoney(product.price_cent)}
+
+ {product.original_price_cent > 0 ? (
+
+ {formatMoney(product.original_price_cent)}
+
+ ) : null}
+
+
+ {product.description}
+
+
+ 数量
+ setQuantity(Number(value) || 1)} />
+
+ 合计 {formatMoney(product.price_cent * quantity)}
+
+
+
+
+ 下单需登录并完成实名认证;支付成功后由客服接单处理。
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/features/crash/CrashListPage.tsx b/frontend/src/features/crash/CrashListPage.tsx
new file mode 100644
index 0000000..dc0d03b
--- /dev/null
+++ b/frontend/src/features/crash/CrashListPage.tsx
@@ -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()
+ const [page, setPage] = useState(1)
+
+ const categoriesQuery = useCrashCategories()
+ const productsQuery = useCrashProducts({ category_id: categoryId, page, page_size: 12 })
+ const items = productsQuery.data?.items ?? []
+
+ return (
+
+
+
+ JIEYOU · 撞车商城
+
+ 摸红商城
+
+
+ 明码标价、下单即锁库存;支付后由客服接单处理,订单可在「我的撞车订单」查看。
+
+
+
+ 共
+ {productsQuery.data?.total ?? 0}
+
+
+
+
+
+
+
({ value: item.id, label: item.name })),
+ ]}
+ onChange={value => {
+ setCategoryId(Number(value) || undefined)
+ setPage(1)
+ }}
+ />
+
+ {productsQuery.isError ? : null}
+
+ {items.length === 0 && !productsQuery.isPending ? (
+
+
+
+ ) : (
+
+ {items.map(item => (
+
+
+ {item.cover_url ? (
+
+ ) : null}
+
+ {item.title}
+
+ {formatMoney(item.price_cent)}
+ {item.original_price_cent > 0 ? (
+
+ {formatMoney(item.original_price_cent)}
+
+ ) : null}
+ {item.category_name || '未分类'}
+ 0 ? 'blue' : 'default'}>
+ {item.stock < 0 ? '不限量' : `库存 ${item.stock}`}
+
+
+
+
+
+ ))}
+
+ )}
+
+ {(productsQuery.data?.total ?? 0) > 12 ? (
+ setPage(next)}
+ style={{ textAlign: 'center' }}
+ />
+ ) : null}
+
+ {!user ? (
+ 登录后可下单;下单需要完成实名认证。
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/features/crash/CrashOrdersPage.tsx b/frontend/src/features/crash/CrashOrdersPage.tsx
new file mode 100644
index 0000000..f5376ad
--- /dev/null
+++ b/frontend/src/features/crash/CrashOrdersPage.tsx
@@ -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 = {
+ 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 (
+
+
navigate('/login')}>去登录}
+ />
+
+ )
+ }
+
+ return (
+
+
+ } onClick={() => navigate('/crash')}>
+ 返回商城
+
+
+
+ {ordersQuery.isError ?
: null}
+
+ {items.length === 0 && !ordersQuery.isPending ? (
+
+
+
+ ) : (
+
+ {items.map(order => {
+ const meta = statusMeta[order.status] ?? { label: order.status, color: 'default' }
+ return (
+
+
+ {order.product_cover_url ? (
+
+ ) : null}
+
+
+ {order.product_title || `订单 ${order.order_no}`}
+
+ {meta.label}
+
+
+ {order.order_no}
+
+ {formatMoney(order.amount_cent)} · 数量 {order.quantity}
+
+ 下单 {formatDateTime(order.created_at)}
+ {order.cancel_reason ? 原因:{order.cancel_reason} : null}
+
+ {order.status === 'pending_payment' ? (
+ <>
+
+
+ >
+ ) : null}
+ {order.qrcode_url_snapshot ? (
+
+ ) : null}
+
+
+
+
+ )
+ })}
+
+ )}
+
+ {(ordersQuery.data?.total ?? 0) > 10 ? (
+
+
+
+ {page} / {Math.ceil((ordersQuery.data?.total ?? 0) / 10)}
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/features/crash/api.ts b/frontend/src/features/crash/api.ts
new file mode 100644
index 0000000..ce13d58
--- /dev/null
+++ b/frontend/src/features/crash/api.ts
@@ -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(
+ apiClient.get>('/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>(
+ apiClient.get>>('/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(
+ apiClient.get>(`/crash/products/${id}`)
+ ),
+ enabled: Boolean(id),
+ })
+}
+
+export function createCrashOrder(payload: { product_id: number; quantity: number }) {
+ return unwrapData(
+ apiClient.post>('/crash/orders', payload)
+ )
+}
+
+export function fetchMyCrashOrders(page = 1, pageSize = 20) {
+ return unwrapData>(
+ apiClient.get>>('/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(
+ apiClient.post>(`/crash/orders/${id}/cancel`)
+ )
+}
+
+export function startCrashPayment(id: number) {
+ return unwrapData<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>(
+ apiClient.post>(
+ `/crash/orders/${id}/start-payment`
+ )
+ )
+}
diff --git a/frontend/src/features/crash/types.ts b/frontend/src/features/crash/types.ts
new file mode 100644
index 0000000..fbbc261
--- /dev/null
+++ b/frontend/src/features/crash/types.ts
@@ -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
+}
diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx
index 653aafa..735ba0b 100644
--- a/frontend/src/layouts/StorefrontLayout.tsx
+++ b/frontend/src/layouts/StorefrontLayout.tsx
@@ -32,6 +32,7 @@ export function StorefrontLayout() {
卖家中心
+ 摸红商城
公告
流程
规则