From a6027c0c4da0ccb17e1067e44c838fe7bdaa77e5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 17:30:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(crash):=20=E6=96=B0=E5=A2=9E=E6=91=B8?= =?UTF-8?q?=E7=BA=A2=E5=95=86=E5=9F=8E=E5=89=8D=E5=8F=B0=E4=B8=89=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/AppRouter.tsx | 12 ++ .../src/features/crash/CrashDetailPage.tsx | 108 ++++++++++++ frontend/src/features/crash/CrashListPage.tsx | 109 ++++++++++++ .../src/features/crash/CrashOrdersPage.tsx | 156 ++++++++++++++++++ frontend/src/features/crash/api.ts | 102 ++++++++++++ frontend/src/features/crash/types.ts | 66 ++++++++ frontend/src/layouts/StorefrontLayout.tsx | 1 + 7 files changed, 554 insertions(+) create mode 100644 frontend/src/features/crash/CrashDetailPage.tsx create mode 100644 frontend/src/features/crash/CrashListPage.tsx create mode 100644 frontend/src/features/crash/CrashOrdersPage.tsx create mode 100644 frontend/src/features/crash/api.ts create mode 100644 frontend/src/features/crash/types.ts 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 ( +
+ + + + + + {productQuery.isError ? ( + + ) : null} + + {product ? ( + +
+ {product.cover_url ? ( + {product.title} + ) : 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 ? ( + {item.title} + ) : 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 ( +
+ + + + + {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() { 卖家中心 + 摸红商城 公告 流程 规则